透明谱面视频导出的目标是提供单一视频文件输出,替代 PNG 序列 zip,方便二次剪辑
基于 ffmpeg.wasm v0.12.10 实现
一、VP9 alpha:直接炸内存
VP9 是 libvpx 的新一代编码器,alpha 通道支持更完善,理论上压缩率优于 VP8。ffmpeg.wasm 已编译 libvpx-vp9,因此优先尝试 VP9 alpha
实测启动即 OOM
第一段编码 exec 刚启动就稳定崩溃:
RuntimeError: memory access out of bounds测试条件:
- 2 倍导出分辨率(2716×280):崩溃
- 半分辨率降采样:崩溃
- ffmpeg.wasm 0.12.6 与 0.12.10 两个版本:仍崩溃
为什么
32-bit WASM 的堆上限约为 2GB。VP9 编码器的上下文结构占用较大,即使在低分辨率下,初始化也接近可用堆上限。加上 wasm 文件系统已为帧 PNG 预留部分空间,直接越界
降采样不能阻止崩溃,如果瓶颈在像素数,降采样应能缓解;照样崩溃说明问题出在 VP9 编码器的内部固定开销,与分辨率无关
结论
VP9 alpha 在 32-bit wasm 下无法分配足够堆内存,下一个
二、VP8 alpha:参数不支持导致黑底
VP9 走不通后,自然想到 VP8。VP8 编码内存开销更小,且 WebM 容器原生支持 alpha
两条路径均失败
路径一:默认配置(-auto-alt-ref=1)
ffmpeg -i frame_%05d.png -c:v libvpx -pix_fmt yuva420p -auto-alt-ref 1 out.webmencoder 初始化阶段直接断言失败:
Transparency encoding with auto_alt_ref does not worklibvpx 中 alt-ref 参考帧使用 Y 通道重建,alpha 通道无法被正确重建,因此 alpha 与 auto-alt-ref 互斥
路径二:关闭 alt-ref + alpha_mode
ffmpeg -i frame_%05d.png -c:v libvpx -pix_fmt yuva420p -auto-alt-ref 0 -alpha_mode 1 out.webm按官方文档,关闭 alt-ref 后需通过 -alpha_mode 1 在 bitstream header 中标记 AlphaMode=1,告知解码器存在 alpha 通道
结果这个参数在当前 wasm 构建里压根不认:
Unrecognized option 'alpha_mode'后果
没有 alpha_mode,WebM 文件里的 AlphaMode 字段默认就是 0,所有剪辑软件和浏览器都会将 yuva420p 的 alpha 通道视为 padding 丢弃——输出必然是黑底
结论
VP8 alpha 在本 ffmpeg.wasm 构建下无法生成有效透明 WebM,下一个
三、MOV PNG 段编码 + concat demuxer:累积同步误差
排除 libvpx 两条路径后,可行的透明编码方案仅剩:
MOV 容器 + FFmpeg 内置 PNG 编码器 + pix_fmt rgba
优势:
- 不依赖 libvpx,编码内存仅为单帧 zlib 压缩缓冲区(峰值不到 30MB)
- 无损 + 真 alpha,剪辑软件原生识别
初版架构:分段编码
出于内存安全考虑,设计成每 10 帧一段:
- 每段独立 new/terminate ffmpeg 实例,避免同实例累积损坏
- 段产物存主线程 Blob,不占 wasm FS
- concat demuxer 无损拼接所有段,再另起一个实例混入 AAC 音频
崩溃问题与修复
开发过程中修复了若干崩溃问题:
| 问题 | 根因 | 解决 |
| 段 146 累积崩溃 | 同实例多段编码导致永久损坏 | 每段独立创建和销毁实例 |
| 92% mux 阶段崩溃 | 两次 exec 导致堆增长冲突 | 分两个独立实例 |
| DataCloneError | logger 函数传入 load config,postMessage 时无法克隆 | 改用 on("log") 先绑定回调,load 仅传 URL 对象 |
| alert undefined | wasm abort 抛出非 Error 异常 | toErr(e) 统一规范化 |
核心问题:音画累积不同步
此模式下画面比声音越来越慢
同一套画帧逻辑和总帧数计算,在 mp4/zip 模式下完全正常,问题必然出在 MOV 链路——段编码 + concat demuxer + AAC mux
多轮修复均无效
为定位和修复同步问题,先后尝试了以下方案:
- 段编码加
-vf settb=AVTB,setpts=N/(FPS*TB)写死每帧 PTS - 合成后叠加
fps=30 + settb=AVTB + setpts=N/(FPS*TB) - 音频加
asetpts=N/SR/TB按采样号写死 PTS - 时长从 JS 理论值改为 AudioBuffer 真实物理时长
-frames:v totalFrames强制输出帧数-t durSec强制截流- 调整参数顺序:
-r放-i前 +-fflags +genpts+-avoid_negative_ts make_zero
全部无效。
根因:concat demuxer 的累积 rounding
concat demuxer 的段尾 dur 推断与时基 rounding 误差累积不可修复
具体机制:
- 每段 MOV PNG 的最后一帧 duration,在 concat demuxer 进行
-c copy合并时,存在不可见的 rounding 误差 - MOV 容器时基(tbn)约为 1/15360,stts 表段尾做 dur 推断时可能多加 1 个 tbn 的段尾 gap
- 474 段累积后,视频总时长明显长于音频真实物理时长
fps filter + setpts仅在输入时长范围内重采样,不会缩短总时长或丢帧——输入已偏长,输出仍偏慢-t durSec/-frames:v totalFrames仅做截断,不修复前面帧的平均间隔问题
结论
只要依赖 concat demuxer copy 合并多段,累积误差就无法通过后处理消除。段编码 + concat 架构整体推翻
四、最终方案:单实例一次性编码
既然 PNG 序列 zip 模式音画 100% 同步,透明视频的帧循环直接复用 zip 的画帧逻辑与时间参数,然后一次性喂给 ffmpeg 编码,同步精度应与 zip 同级
完整流程
单 new FFmpeg() 实例
│
├ 逐帧循环 (idx 0..totalFrames)
│ drawExportFrame((idx / EXPORT_FPS) * speed) ← 与 zip 模式参数完全一致
│ → canvas.toBlob('image/png')
│ → Uint8Array
│ → inst.writeFile("frame_" + pad(idx,5) + ".png", u8)
│ 每 5 帧更新进度 (0% → 75%)
│
├ 有音频时: audioBufferToWav(mixed) → writeFile("audio.wav")
│
├ 一次 exec 编码
│ -f image2 -start_number 0 -r FPS -i frame_%05d.png
│ -i audio.wav
│ -fflags +genpts -avoid_negative_ts make_zero
│ -map 0:v:0 -map 1:a:0
│ -vf fps=FPS:round=near:start_time=0,settb=AVTB,setpts=N/(FPS*TB)
│ -frames:v totalFrames
│ -c:v png -pred mixed -pix_fmt rgba
│ -af asetpts=N/SR/TB
│ -c:a aac -b:a 192k -ar 48000 -ac 2
│ -t durSec
│ -f mov out.mov
│
├ 删除所有帧 PNG + audio.wav(释放 FS 空间)
│
├ readFile out.mov → Uint8Array
│
└ Blob → 格式转换 → 下载几个需要注意的参数:
-f image2 -start_number 0 -r FPS必须置于-i之前:image2 demuxer 的参数顺序敏感,放错位置会被当作输出参数,时间戳生成直接乱掉fps=FPS:round=near:start_time=0:对齐帧率网格,从零时刻开始计数settb=AVTB, setpts=N/(FPS*TB):切换时基后按帧号直接写死 PTS,完全绕过 demuxer 的 dur 推断,从根源消除 roundingasetpts=N/SR/TB:按采样号写死音频 PTS,抵消 AAC 编码器 pre-roll 偏移-frames:v totalFrames+-t durSec双保险:视频帧数与音频时长双重约束,末尾截流对齐
验证结果
- 无 OOM 完成整张谱导出与下载
- 音画全程同步
- 透明通道有效