FFmpeg.wasm 把成熟的媒体引擎带进浏览器标签页。一套封装就能支持音频转换、提取、合并、剪辑、压缩、音量、倍速、重采样、码率调整和去静音,而且用户选择的媒体文件无需提交到应用处理接口。
真正困难的不是写出某一条 ffmpeg 命令,而是只加载一次大型运行时、准确区分运行时下载与用户文件上传、管理内存虚拟文件系统、把产品选项转换成安全参数数组、展示进度、设置超时,并删除所有临时文件。
先说清楚哪些资源仍然需要联网
浏览器本地处理表示用户文件在当前标签页读取并写入 FFmpeg 虚拟文件系统,不代表页面完全没有网络依赖。JavaScript core、WASM、worker 和应用代码仍需要下载,除非已经缓存。当前实现从固定版本 CDN 获取 core 与 WASM,class worker 则由站点同源提供。
隐私文案和部署文档应该明确区分这两件事。要求严格离线时可以把运行时资源全部自托管;无论采用哪种方式,文件名、媒体字节和输出内容都不应进入统计或远程处理接口。
| 资源 | 位置 | 是否包含用户媒体 |
|---|---|---|
| 应用 JavaScript | 站点同源 | 否 |
| FFmpeg core 与 WASM | 固定版本 CDN 或自托管 | 否 |
| Class worker | 站点同源 | 否 |
| 输入与输出文件 | 标签页内 FFmpeg 虚拟文件系统 | 是 |
全站复用一个 FFmpeg 实例
动态 import 可以避免无关页面加载大型媒体运行时。除了缓存最终实例,还要缓存加载 Promise,防止用户快速点击两次后创建两个 WASM 运行时。如果加载失败,则清空 Promise,允许重试从干净状态开始。
toBlobURL 会把 core 资源转换成 worker 能稳定加载的 URL。这个阶段的进度应该明确显示“下载运行时”,不能把模型或 WASM 下载误写成“正在处理音频”。
let ffmpegPromise: Promise<FFmpeg> | null = null;
async function loadFfmpeg() {
if (!ffmpegPromise) {
ffmpegPromise = (async () => {
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
const { toBlobURL } = await import('@ffmpeg/util');
const coreURL = await toBlobURL(CORE_JS_URL, 'text/javascript');
const wasmURL = await toBlobURL(CORE_WASM_URL, 'application/wasm');
const instance = new FFmpeg();
await instance.load({
classWorkerURL: '/vendor/ffmpeg/worker.js',
coreURL,
wasmURL,
});
return instance;
})().catch(error => {
ffmpegPromise = null;
throw error;
});
}
return ffmpegPromise;
}把产品模式映射为参数数组
命令应构造成字符串数组,而不是一段 shell 文本。这样不存在 shell 插值,既减少转义问题,也方便逐项校验。所有数字参数都应先限制范围,再写进命令。
- 合并:添加全部输入,通过 filter_complex 串联音频流,再 map 命名输出。
- 剪辑:规范开始和结束时间,拒绝结束时间不大于开始时间的输入。
- 倍速:atempo 单个滤镜支持范围有限,需要串联多个滤镜。
- 去静音:构造 silenceremove 前限制分贝阈值和最短持续时间。
- WAV:明确指定 PCM、采样率和声道数,保证结果可预测。
把虚拟文件系统当作稀缺内存
fetchFile 会把每个文件复制到 WASM 侧文件系统,编码输出又增加一份分配,FFmpeg 解码和滤镜还有内部缓冲。因此一个 300 MB 的压缩视频可能需要远大于 300 MB 的浏览器内存。
临时文件名必须避免冲突,读取结果后先复制字节,再在 finally 中删除所有碰过的路径。这样同一个长生命周期实例才能继续处理下一项任务,而不会保留上一份媒体。
const inputNames = files.map((file, index) => `input-${index}.${extension(file)}`);
const outputName = 'output.mp3';
try {
for (const [index, file] of files.entries()) {
await ffmpeg.writeFile(inputNames[index], await fetchFile(file));
}
const exitCode = await ffmpeg.exec(buildCommand(inputNames, outputName, options), 600_000);
if (exitCode !== 0) throw new Error(`FFmpeg exited with ${exitCode}`);
const output = await ffmpeg.readFile(outputName);
return new Blob([output], { type: 'audio/mpeg' });
} finally {
await Promise.all([...inputNames, outputName].map(name => ffmpeg.deleteFile(name).catch(() => {})));
}进度、错误与取消需要产品语义
FFmpeg progress 通常表示媒体时间线比例,并不等于准确剩余时间。可以分别为运行时准备、写入输入、处理、编码和完成预留进度区间。每项任务结束后还要移除对应监听器,避免后续任务收到重复事件。
为 exec 设置超时,保留非零退出码,再把底层错误转换成稳定错误码。真正取消任务通常需要终止或重建 FFmpeg 实例;只禁用按钮并不会停止正在执行的 WASM。
为合适的负载选择浏览器处理
FFmpeg.wasm 很适合隐私敏感、偶发和中等体积的转换任务,可以省去上传耗时与服务端存储。但它会与页面争用 CPU 和内存,在手机上消耗电量,也受浏览器 WASM 上限约束。
超大媒体、固定编码器保证、批量队列、审计要求或必须在关闭标签页后继续的任务,更适合受控后端。产品应在用户等待运行时下载之前就说明限制。
总结
可靠的 FFmpeg.wasm 封装,本质上是包围命令引擎的资源管理器:缓存一个运行时、生成校验后的参数数组、隔离临时文件、诚实展示阶段,并在 finally 中清理。做好这些边界,多个音频页面才能共享同一实现。