在浏览器里做视频压缩,我踩的三个坑
我做了个纯客户端的视频压缩工具,整个过程不上传文件。
下面三件事里有两件是靠掐表测出来的,代码审查和单元测试都没抓到,
趁数字还在手上记一下。
管线分三条路:文件本来就达标,就直接复制编码样本,一帧都不重编;
浏览器能解这个容器,就用 WebCodecs 转码;两者都不行,退回 ffmpeg.wasm,
慢一个数量级。解复用和封装用的是 mediabunny。
AVI 直接 -c copy 到 Matroska,产出 1151 字节
快速路径要求 mediabunny 能读懂容器。它读不了 AVI,但很多 AVI 里装的是 H.264,
所以思路是无损换个容器、继续留在快速路径上:
ffmpeg -i in.avi -map 0✌️0 -map 0:a? -c copy
-avoid_negative_ts make_zero out.mkv
这条命令是失败的,而且失败得很直接:
[matroska] Timestamps are unset in a packet for stream 0
[matroska] Can't write packet with unknown timestamp
AVI 不存逐包时间戳,它靠固定帧率加一个索引,让解复用器自己算。
而 Matroska 要求每个 block 都有时间戳,复制过去就无从下笔。
结果是一个 1151 字节、只有头没有 cluster 的文件,它是合法的 Matroska,
只是里面没有媒体数据。
-avoid_negative_ts 治不了这个。我卡在这里比应该的时间长,
因为时间戳不是「负数」,是「不存在」。两个不同的问题,报错长得像。
修法是输入侧加一个 flag:
ffmpeg -fflags +genpts -i in.avi ...
+genpts 会按流的帧率合成出 PTS。20 秒 1080p30 的素材,产出从 1151 字节
变成 55.8 MB,600 帧全部解码正常。
然后做了个对照组:同样的分辨率、时长、目标,但编码换成 Xvid,
这样它必须走 ffmpeg 那条路。
H.264 AVI,换容器后走 WebCodecs 7.5 秒 53.3 MB → 12.3 MB
Xvid AVI,走 ffmpeg.wasm 53.4 秒 48.2 MB → 12.5 MB
输出体积差不到 2%,墙上时间差 7 倍。这还是无头 Chromium 的软件编码,
真机上有硬件编码器会更好。
另外提一句:ffmpeg.wasm 是把退出码 resolve 出来而不是 reject,
而中途死掉的 muxer 仍会留下一个能读的文件。不检查退出码的话,
截断的产物和完整的产物长得一模一样。
所有 AAC 音轨都会让 mediabunny 的复制路径失效
passthrough 这条路本该什么都不动:给 mediabunny 一个空的 video 配置,
它就直接复制编码样本,因为 forceTranscode 默认是 false。
视频确实复制了,音频没有。3.3 MB 的源出来变成 3.6 MB,于是我去拆 MP4 的 box:
源 moov 33,948 mdat 3,432,287
产物 moov 17,879 mdat 3,788,908
moov 反而更小,增长全在 mdat。分轨看:
avc1 900 个样本 3,071,520 字节 (和源一字节不差)
mp4a 1295 个样本 717,380 字节 (源是 360,476)
视频是精确复制,音频翻了一倍:源 96 kbps,出来约 191 kbps。
原因在 mediabunny 的复制条件里。快速路径要求 !needsTrimming,
而 needsTrimming 是 firstTimestamp < startTimestamp。直接问库:
video codec: avc firstTimestamp: 0
audio codec: aac firstTimestamp: -0.023219954648526078
是负的。而 44100 Hz 下 0.0232 秒正好是 1024 个样本,也就是一个 AAC 帧。
这就是编码器的 priming delay,任何正常编码器产出的 AAC 都带。
所以音频复制路径不是偶尔不可用,是永远不可用。
这个在调用侧绕不过去:传 bitrate 想控制体积,本身就会强制转码,
因为 !trackOptions.bitrate 也是复制条件之一。什么都不传,
它就按自己的默认码率重编。
我最后加了个下限:如果 passthrough 产出的字节数不小于输入,
且源本来就是 MP4,就直接把源文件还回去。实测字节完全一致,
3,466,275 进、3,466,275 出,原来是 3,806,815。对一个本来就接近最优的文件,
「无事可做」比「大了 10%」诚实得多。
多线程的 ffmpeg core 要拿广告收入去换
@ffmpeg/core-mt 比单线程快 4 到 8 倍,但它要 SharedArrayBuffer,
就要跨源隔离,也就是要发 COOP 和 COEP 头。
而 COEP: require-corp 会打断没有主动 opt-in 的第三方嵌入。
对一个靠 AdSense 的站,这不是技术权衡,是收入决策。
我留在单线程,把力气花在「尽量不走到 ffmpeg」上,也就是上面那条换容器的路。
想说的一点
两个真 bug,代码审查和单元测试都没抓到。AVI 那个当时测试全绿、
实现看起来也挺合理。真正找到它的是把一个真实的 AVI 拖进真实的浏览器,
然后发现 54 MB 的输入产出了 1151 字节。
有兴趣可以拿手上难搞的文件试试。被问得最多的两个场景各有单独的页面:
把视频压到 Discord 的 10MB 以内、
以及把视频压缩到 10MB。
所有处理都在你自己机器上。
我做了个[纯客户端的视频压缩工具](https://videocompress.dev/zh),整个过程不上传文件。
下面三件事里有两件是靠掐表测出来的,代码审查和单元测试都没抓到,
趁数字还在手上记一下。
浙公网安备 33010602011771号