FFmpeg 是视频处理的瑞士军刀,但它是个几千万行的 C 项目——把它完整塞进浏览器里跑,听起来像天方夜谭。但这正是 ffmpeg.wasm 每天在做的事,也是本站所有"浏览器本地处理、文件不上传"工具的技术底座。这篇笔记记录我们把它从"能跑"调到"可用"踩过的坑。
为什么是 WASM,而不是服务端
服务端转码方案(上传 → 服务器跑 ffmpeg → 回传)有三个绕不开的问题:带宽成本、排队延迟、隐私顾虑。用户只是想剪掉视频开头 10 秒,却要把几个 GB 的原片传上云。
WebAssembly 给了第三条路:把 FFmpeg 编译成 .wasm 字节码,在浏览器沙箱里以接近原生的速度执行。代价是:
- 单文件核心(core)约 30MB,首次加载慢
- 内存上限受浏览器 32 位 WASM 限制(约 2–4GB)
- 多线程需要跨域隔离头(下文详述)
对"小文件、单步处理、隐私敏感"的场景(裁剪、转格式、压缩、提取音频),这套取舍非常划算。
加载策略:CDN 优先 + 本地兜底
core 文件 30MB,加载策略直接决定首体验。我们的做法是 CDN 优先、失败回退本地:
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { toBlobURL } from '@ffmpeg/util'
const CDN_BASE = 'https://unpkg.com/@ffmpeg/core@0.12.10/dist/esm'
async function loadCore(ffmpeg: FFmpeg) {
try {
// CDN 优先:命中缓存时几十毫秒
await ffmpeg.load({
coreURL: await toBlobURL(`${CDN_BASE}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${CDN_BASE}/ffmpeg-core.wasm`, 'application/wasm'),
})
} catch {
// 回退本地前必须先 terminate,否则 worker 处于半初始化状态
ffmpeg.terminate()
await ffmpeg.load({
coreURL: await toBlobURL('/ffmpeg-core/ffmpeg-core.js', 'text/javascript'),
wasmURL: await toBlobURL('/ffmpeg-core/ffmpeg-core.wasm', 'application/wasm'),
})
}
}
三个细节值得强调:
- 回退前必须
terminate()。失败的load()可能留下一个半初始化的 worker,直接二次 load 会报诡异错误。 - 用
toBlobURL而不是直接给 URL。Worker 从 blob URL 加载脚本可绕开跨域限制,同时让 CDN 和本地资源在加载逻辑上完全对称。 - 下载进度和转码进度是两回事。
ffmpeg.load()阶段的进度是 30MB core 的下载进度,ffmpeg.exec()阶段的才是处理进度,UI 上要分开呈现,否则用户会在"下载 core"时看到一个卡在 0% 的进度条。
SharedArrayBuffer 与跨域隔离
这是部署环节最大的坑。ffmpeg.wasm 的多线程版本依赖 SharedArrayBuffer,而浏览器规定:只有跨域隔离的页面才能使用它。你的 nginx 需要加两个响应头:
add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;
缺了这两个头不会报错——页面照常运行,只是 SharedArrayBuffer 变成 undefined,ffmpeg.wasm 静默退化为单线程(或直接加载失败,取决于版本)。这类"性能悄悄减半"的问题最难排查,建议上线前先在控制台确认:
console.log(typeof SharedArrayBuffer !== 'undefined') // 必须是 true
注意副作用:加了 COEP: require-corp 后,页面里所有跨域资源(第三方图片、字体、统计脚本)都必须自带 CORS 授权,否则直接加载失败。接入新的第三方服务时记得检查。
MemFS:大文件的内存账
ffmpeg.wasm 的文件系统是内存里的虚拟 FS(MemFS),读写都走 JS 堆:
// 写入:JS 内存 → MemFS(注意:这里会复制一份)
await ffmpeg.writeFile('input.mp4', await fetchFile(file))
// 执行
await ffmpeg.exec(['-i', 'input.mp4', '-ss', '10', '-c', 'copy', 'output.mp4'])
// 读出:MemFS → JS 内存(又一份)
const data = await ffmpeg.readFile('output.mp4')
一笔账:一个 500MB 的视频,writeFile 后内存里有两份(File 对象缓冲 + MemFS 副本),加上 WASM 堆的处理开销,峰值很容易逼近浏览器 tab 的内存上限——超了就是一句无情的 AbortError: out of memory。
实践上的对策:
- 能
-c copy就-c copy。裁剪、封装格式转换这类操作不重编码,内存和时间开销都低一个量级。 - 用完即删。
exec完成后立刻deleteFile()清理 MemFS,长会话(连续处理多个文件)不清理会持续累积。 - 产品层面限流。我们的工具对超过一定体积的文件会提示建议下载桌面版 FFmpeg——承认边界比假装能处理更好。
性能:从"能跑"到"可接受"
单线程的 ffmpeg.wasm 大约只有原生速度的 20%–50%(视操作类型)。几个有效的优化手段:
- 多线程 core:
@ffmpeg/core-mt配合跨域隔离头,能吃到多核,速度快 2–4 倍(注意 core 体积也更大)。 -threads参数:多线程 core 下显式指定['-threads', String(navigator.hardwareConcurrency ?? 4)]。- 精准裁剪任务:能用
-ss(输入端 seek)就不要用输出端 trim,编码量可能差一个数量级。 - 避免无谓的重编码:转封装(MP4↔MKV)、无损裁剪走 stream copy;真正需要重编码的才付全价。
踩坑清单(速记版)
| 现象 | 根因 | 解法 |
|---|---|---|
| 加载 CDN 失败后二次 load 报错 | worker 半初始化 | 回退前先 terminate() |
| 线上比开发环境慢数倍 | 缺 COOP/COEP 头,静默退化为单线程 | nginx 加跨域隔离头并验证 SharedArrayBuffer |
大文件 out of memory | MemFS + JS 堆多份数据叠加 | -c copy 优先、及时 deleteFile、限制输入体积 |
| 进度条卡在 0% | 把 core 下载进度当成了处理进度 | 区分 load 与 exec 两阶段进度 |
| 输出文件 0 字节 | exec 参数错误但未检查 return code | 检查 exec 返回值并 readFile 前判空 |
什么时候不该用 WASM
诚实地说,浏览器端不是万能的:超过 1GB 的长视频重编码、需要 GPU 加速的批量任务、H.265 等授权敏感的编码场景,服务端或本地 CLI 仍是更优解。混合策略才是答案——轻量单步操作在浏览器本地完成,重任务给出明确指引。
想直接体验效果?FFmpeg 命令实验室让你在浏览器里试跑任意 FFmpeg 命令,视频裁剪则是这套技术栈的典型应用——文件全程不离开你的电脑。
FAQ
Q:ffmpeg.wasm 支持所有 FFmpeg 滤镜和编码器吗? A:不支持全量。官方 core 裁剪了部分编码器(如 libx265 多线程版本),也排除了需要系统依赖的功能。选型前先确认目标编码器在构建里。
Q:文件真的不经过服务器吗? A:是的。核心逻辑全部在浏览器沙箱内完成,服务器只负责下发 HTML/JS/wasm 静态资源——这也是我们敢把"不上传"写进产品文案的原因。
Q:Safari 支持吗?
A:支持,但历史版本上 OffscreenCanvas 与 Worker 的组合有过兼容性问题,多线程 core 在 Safari 的表现需要实测验证。