~/blog/ffmpeg-in-browser-wasm
WASM2026-08-10 · 10 min

把 FFmpeg 塞进浏览器:WebAssembly 落地实践笔记

ffmpeg.wasm 的加载策略、SharedArrayBuffer 跨域隔离、大文件内存管理——把 30MB 的 core 跑到可用状态,我们踩过的坑都在这。

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'),
    })
  }
}

三个细节值得强调:

  1. 回退前必须 terminate()。失败的 load() 可能留下一个半初始化的 worker,直接二次 load 会报诡异错误。
  2. toBlobURL 而不是直接给 URL。Worker 从 blob URL 加载脚本可绕开跨域限制,同时让 CDN 和本地资源在加载逻辑上完全对称。
  3. 下载进度和转码进度是两回事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 memoryMemFS + JS 堆多份数据叠加-c copy 优先、及时 deleteFile、限制输入体积
进度条卡在 0%把 core 下载进度当成了处理进度区分 loadexec 两阶段进度
输出文件 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 的表现需要实测验证。

RELATED TOOLS · 把文章里的东西用起来