断网也能传文件:只用一块屏幕和一个摄像头,这个项目把"光传输"做成了网页
先想象一个场景:两台设备,一台不能联网(或者不想联网),蓝牙配对嫌烦,数据线不在手边,又都装不了任何软件。这时候怎么把一张照片、一段视频、一个文档从 A 弄到 B?
我上周刷到的这个项目给了一个特别极客的答案:让屏幕闪起来,用摄像头把它"拍"下来。
项目叫 Decimen(github.com/bashalarmistalt/decimen-optical-transfer)。发送端打开一个网页选个文件,屏幕就开始播放一串动态二维码;接收端(通常是手机)打开另一个网页、用摄像头对着屏幕,文件就这么一点一点从光里被"抠"了出来,校验通过后保存。
两台设备之间没有任何网络连接——没有 WiFi、没有蓝牙、不配对、不装 App、不要账号,唯一要的权限是摄像头。文件是以可见光的形式飞过去的。
一、它能干什么:不只是传照片
先把功能说全,因为它比"二维码传图"能干的事多得多:
传任意文件,上限 64MB。 照片、视频、PDF、Word、压缩包、apk 安装包——anything。容器里带着原始文件名和媒体类型,接收端还原出来的就是原文件,不是一堆散字节。传之前如果 gzip 能让它变小就自动压(压不动就不压,不浪费帧),收完先做 SHA-256 校验,校验不过就不让你保存——宁可明确失败,也不塞给你一个坏文件。
也能只传一段文字。 不想传文件,可以直接在发送页粘贴一段文本发过去。接收页识别出来这是文本后,直接显示出来给一个复制按钮——整个过程不落地、不存储,关掉标签页就没了。临时把一段密码、一个链接、一段命令从一台不联网的机器挪到手机上,这个用法特别顺手。
纯网页,但离线也能用。 在线版 decimen.app 第一次加载后,Service Worker 会把全部页面(包括解码器)缓存下来,之后断网打开照常用。
还能脱离网站单独存在。 项目能构建出两个单文件版本:发送页 decimen-sender.html 只有 55KB,拷进 U 盘、发邮件附件都行,双击打开就闪码;接收页 1.3MB(里面内嵌了一个 940KB 的 WASM 二维码解码器),同样零依赖零安装。也就是说,你甚至可以提前把这两个文件存在手机里,到了一个完全没有互联网的环境照样用。
有一键演示模式。 如果要把发送电脑放在人前做展示,npm run demo 会把发送端锁死在两个内置示例图上——文件选择器和文本框整个不接,防止有人当着大家的面翻你电脑的文件。这个细节说明作者是真考虑过实际使用场景的。
二、真正的难点:这是一条"单向、必丢帧、还不能喊重传"的信道
功能说完,来讲最有意思的部分:它为什么能 work。
我们平时传文件,背后都有"确认—重传"机制:收方发现丢了 3 号包,喊一句"3 号再来一遍"。但屏幕到摄像头这条信道,接收方没有任何办法把信息传回发送端——它是一条纯单向信道。
而且这条信道必然丢帧。手会抖、摄像头对焦会拉风箱、屏幕刷新和摄像头曝光会错位……一帧糊了就是糊了,永远找不回来。
最直观的解法是循环播放:文件切成 N 帧一遍遍轮播,缺哪帧等它转回来。体验是灾难——传 2000 帧时你错过第 7 帧,就得干等一整圈。
Decimen 用的是另一个思路,也是整个项目最优雅的部分:喷泉码(Fountain Code,具体是 Luby Transform / LT 码)。
想法很反直觉:发送端永远不直接发送文件的任何一块。 每一帧发出去的,是随机抽取的若干个文件块做 XOR 混合的结果。抽几块、抽哪几块,由一个叫 robust-soliton 的分布控制,而这个"随机"过程完全由帧序号确定性推导——收发两端各自独立计算,结果一模一样,全程零协商。
接收端做的事叫"剥离":收到一帧如果只混了一个块,这个块就直接解出来了;把它从其它帧里 XOR 掉,又会剥出新的单块……连锁反应滚下去,整个文件就还原了。
关键在于:接收端不需要收齐所有帧,只需要收到任意 ~K×1.15 个互不相同的帧(K 是文件切成的块数)。丢帧?无所谓,喷泉一直在冒新水珠,随便接够 115% 就行。发送端 60fps 闪、接收端 30fps 拍,两边速率不匹配也毫无关系。丢帧的代价从"等一整圈"变成"多花一点点时间",而且永远不会影响正确性。
第二个聪明设计是没有握手。每帧前面有 20 字节的头:会话 ID、帧序号、块数、块长、文件总长、整包哈希。这意味着你传到一半才把摄像头怼上去,也能从任意一帧读出全部参数接着收;发送端重启了,新会话 ID 一出现,接收端自动清空重来。整个系统没有任何状态同步的负担。
三、README 里的踩坑清单,比代码还值钱
如果说喷泉码是教科书知识,那这个项目 README 里"血泪细节"一节,就是只有真做过才会撞上的东西。挑几个最有价值的:
1. Math.log 在跨浏览器时是不可移植的
收发两端要算出 bit 级完全一致的随机分布,但 JS 规范里 Math.log 是"实现近似"——V8(发送端 Chrome)和 JavaScriptCore(接收端 iPhone Safari)可能差 1 ulp。这一丁点差别足以让某个采样度数翻转,然后两条流静默地、彻底地失同步——不报错,只是永远解不出来。作者的解法是自己写了一个只用 IEEE-754 精确定义运算的确定性 log。这种 bug 单端测试永远测不出来,跨端联调时就是"玄学不好使",看到这个解法我是真的服气。
2. iOS 会谎报摄像头帧率
你写 frameRate: {ideal: 60},iOS 嘴上答应、实际给 30。要拿 60 必须写 {exact: 60},而且永远要 getSettings() 读回真实值,别信你请求的值。
3. requestVideoFrameCallback 的回调链比流活得久
摄像头流停掉后挂着它的回调链不会死,下一条流启动时它会"复活",每次停止/启动就泄漏一个僵尸采集循环。做 Web 摄像头应用的人大概率都踩过这个坑,只是很少有人说清楚。
4. 进度条不能按"已解出的块数"显示
LT 解码的剥离连锁是后载的:前 80% 传输里解出的块数看起来一动不动,最后哗啦一下全部解开。进度条跟块数走,用户会以为卡死了。这个项目的进度条跟的是"收集到的帧数",100% 只在校验通过时到达。
5. QR 自带的纠错反而调到最低档
直觉上二维码纠错级别越高越稳,但作者把 ECC 设为最低的 L 级。理由是:帧内 ECC 和喷泉码解决的是两种不同的问题——前者管"这一帧里像素读错了",后者管"整帧丢了"。在这个帧尺寸下,L 级 + "糊帧直接丢给喷泉吸收"的组合,吞吐量反而最高。
6. 连 gzip 解压都要防一手
gzip 尾部声明的解压后大小是"信道那头"控制的、不可信——一个 80KB 的流可以声称自己很小、解出来几个 GB。所以解压是边解边数、超过上限立刻掐断。
四、速度、场景和要注意的坑
先说速度,免得期待错位。作者实测(注意是作者的数据):完整实验版做到了手机对手机 128 KB/s 手持、186 KB/s 支稳。开源出来的这个 PoC 是精简版,README 截图里一张 2MB 图片传输瞬时 129 KB/s。
128 KB/s 什么概念?几 MB 的照片几十秒,小文档几秒。不快,但它的对手从来不是 AirDrop,而是"什么都用不了"的那些时刻:
- 没网(或不想联网)的会议室、展会、考场、保密区
- 隔离的内网/气隙环境,要从里面往外导小文件
- 两台不互信、也不能互相连的设备之间挪东西
- 纯粹不想在烂 WiFi 上折腾配对
还有一个很容易被忽略的用法:一对多广播。因为喷泉码不需要回传,一百台手机同时对着一块屏幕收,和一台手机收没有任何区别——发布会现场给所有人同步发一份资料,一台电脑闪、全场拍,这个场景其实是喷泉码的主场。
两个使用提示:
- 手机最好靠着东西固定住。 手持抖动的对焦拉风箱是吞吐量的头号杀手,支稳和手持能差出 50%。
- 如果传得慢,先在设置里把每帧字节数从 2953 降到 1465、帧率降到 24-30,比乱调别的都管用。
最后必须说清楚两点边界:
- 它不加密。 屏幕上的内容任何摄像头对着都能读。它给你的属性是"无网络",不是"保密"——传敏感东西之前想清楚周围环境。
- 那个 128 KB/s 的完整实验版(更密的帧、多码网格、彩色纠错通道)目前没有开源,放出来的只有这个 PoC。期待作者后续放出来。
五、为什么我觉得它值得写一篇文章
坦白说,"动画二维码传文件"不是新主意——2018 年就有 txqr(Go 写的,同样用喷泉码),libcimbar 更是干脆抛开 QR 自研了高密度彩色编码。作者自己也大方承认概念是独立想到的,并把这些前辈都列在了 README 里。
但这个项目的独特价值在两点:
一是门槛拉到了"会开浏览器"。发送页 55KB、接收页 1.3MB 单文件,零安装零配置,把"光传文件"从极客玩具变成了普通人也能随手用的东西。
二是文档质量。它不只告诉你"我用了喷泉码",而是把跨浏览器 1 ulp 失同步、iOS 帧率谎言、回调僵尸这些只有实战才撞得到的坑全部摊开。对想做类似东西的人,这份 README 比代码本身还值钱。
把一件看起来不可能的小事做扎实、再把过程讲透——这是我最喜欢看到的开源项目的样子。