把 FFmpeg 塞进浏览器之后,真正的麻烦才开始。上一篇《把 FFmpeg 塞进浏览器》讲了怎么把 30MB 的 core 跑起来,这篇讲跑起来之后的事:用户拿 16GB 的 DJI 航拍素材来抽帧,拿 5 分钟的视频按 0.1 秒间隔出图——wasm32 的 2GB 堆就摆在那里,绕不过去,只能拆。
本文是 ffmpegtool.com 抽帧工具几个月排障的复盘,数字都来自线上真实失败。
一、2GB 到底卡在哪
@ffmpeg/core 是 32 位 wasm,指针 32 位,堆上限锁死在 2GiB。我们在 core 里实测过:
getHeapMax() = 2147483648 // 精确的 2^31
能用多少比 2GB 小得多。输入文件要整体写进 MEMFS(wasm 的虚拟文件系统);fetchFile 会先把整个文件读进 JS 堆,再复制进 MEMFS,一个 800MB 的视频堆里实际躺着两份;ffmpeg 运行期还要 malloc 内部缓冲。经验上输入超过 1.5GB 就很危险,2GB 只是理论死线。
这里记录一个我们判断错过的方向。最早的断言是"批量抽帧把输入加全部输出帧同时压在 WASM 堆里,峰值超限"。后来对抗审查拿代码拆开看,发现 MEMFS 文件内容主要占的是 Worker 的 JS 堆(Uint8Array),而输出收集在 exec() 结束之后才跑——exec 期间逐帧删文件,对 exec 期的峰值没有影响。真正的爆点是 ffmpeg 自己 exec 期内部的缓冲。
这个过程中还翻出一个更糟的隐藏问题:抽帧循环里的 catch { break } 不区分错误类型,中段 readFile 抛 OOB 会被当成"帧读完了",返回残缺帧、记一次假成功、自愈逻辑永远不触发。宽泛的 catch 比错误本身更会撒谎。
二、堆越界后,worker 已经中毒
堆越界有两副面孔。第一副是抛异常:memory access out of bounds 之类,正则能匹配。第二副不抛异常:worker 内存损坏后 argv 被改写,自动前置的 -y 被解析成孤立选项,ffmpeg 以非 0 退出码正常返回,stderr 尾部是 Unrecognized option 'y' 加一段乱码。第二副面孔匹配不到任何 OOB 正则,半坏的 worker 被反复复用,连环产出同样的错。当时缺陷后台出现这条日志,第一反应是数据库字符集问题,查了 utf8mb4,方向全错。
最后按日志指纹识别:
const WORKER_CORRUPT_RE = /Aborted|Unrecognized option|Error splitting the argument list/i
两类失败走同一套自愈:terminate() 强杀 worker(MEMFS 清空)→ 重建引擎 → 整体重试一次。core 的 32MB 要缓存成 blob URL,terminate 之后重建是零网络依赖的,不然自愈本身就要再花几十秒。
自愈之后仍失败的情况也要区分。我们曾把重试后的所有失败一律包装成"浏览器引擎内存不足",哪怕真实错误是 Conversion failed 这种确定性失败。用户按提示减负重试当然没用。现在只有 OOB 才配得上内存文案,其余原样上抛。错误文案是给用户的一句诊断,诊断错了比不诊断更糟。
三、时间窗分段
峰值问题的解法很朴素:不要让一次 exec 的输出无界。按时间窗拆,每窗 -ss <start> -t <window>,单窗帧数有预算。线上代码里的公式:
const budgetMB = Math.max(96, (2048 - inputMB) * 0.6)
再除以单帧体积(1080p PNG 按 1920×1080×3 字节估,约 5.9MB),得到单窗帧数上限,另配 -frames:v 双重封顶,防的是全 I 帧源或 duration 拿不到时数千帧堆积。
这个公式后来破过一个悬案。用户报:5 分 12 秒视频、0.1 秒间隔,为什么只出 207 帧?207 恰好就是当时的单窗预算——只跑完第一个窗,后面的窗没活下来。让用户重跑,拿到 3120 帧(理论 3122,差的几帧是窗边界对齐的漂移),16 个窗全部跑完,抽帧本身是好的。
分段带出一个进度条问题:每窗独立 exec,ffmpeg 的 progress 事件每窗都从 0 报到 100%,16 个窗就回跳 16 次。改成整批口径 (已完成窗数 + 当前窗进度) / 总窗数,按钮上写"第 x/N 段",才正常。
四、输出侧:帧落 OPFS,ZIP 走流
输入分段之后,输出侧还剩一堵墙。1000 帧上限的真相是:1080p PNG 一帧 13MB,1000 帧 23GB,JS 堆装不下结果——不是引擎抽不出更多帧。blob URL 的底层字节全驻堆,结果卡、lightbox、JSZip 打包又各持一份引用。
重构后三件事:帧写进 OPFS(浏览器文件系统),页面只留句柄,按运行建目录隔离,另跑一个清扫器清历史残留;结果卡每页 100 个 URL 按需物化,翻页 revoke;打包不经过 JSZip,手写 STORE 加 ZIP64 布局,逐文件从 OPFS 读出来流式送出,万帧级别 JS 堆峰值约等于单帧。
手写 ZIP64 踩了两个规范坑。一是 zip64 locator 必须指向 zip64 EOCD 记录(中央目录起点加中央目录长度),写成中央目录起点,解压器就找不到 EOCD。二是 zip64 extra 字段只写被哨兵化的 offset 字段,按规范(APPNOTE 4.5.3)多写 unc/comp size,按序消费的读取器会把 size 当 offset 用,整个错位。验证大于 4GB 的包不能真造 4GB 素材,做法是把 zip64 触发阈值临时调小,布局代码不变,生成带 zip64 结构的包交给 Python 3.14 的 zipfile 严格校验——它对格式错误毫不客气,正好当裁判。
还有一个哭笑不得的 bug:清扫器的规则是删本会话标签的全部目录,而本次运行目录也带本会话标签——清扫把刚建好的目录自己删了。抽帧要几秒,清扫毫秒级,写结果的时候目录已经没了,报 OPFS 的 NotFoundError。后来清扫加了 keep 参数豁免当前 run。
五、把日志窗口还给真实错误
最后一个小问题。流式重构上线后,失败详情还是不可诊断,缺陷 #334 的 2000 字符日志窗口里全是这种行:
Last message repeated 4197 times
Last message repeated 4198 times
...
约 4200 行递增的重复计数,把窗口全部吃光,真实解码错误被顶出窗外。修法是在构造错误详情前剔掉 Last message repeated N times 这类行(万一全被剔光,保留原始日志兜底)。
小结
这套东西拆下来,输入按需读(WORKERFS 挂载,不整文件进堆)、输出落磁盘(OPFS 加流式打包,不 blob 驻堆)、worker 会中毒(退出码和异常是两套失败,都要能自愈)。帧上限从 1000/4000 提到 10000,它从内存墙变成了防失控的保险丝——这正是拆约束的顺序:先把 wasm32 → 2GiB → 输入进 MEMFS → exec 期峰值 → 输出驻堆逐层拆开,每层哪些能工程化、哪些是物理墙,就清楚了。