前言
在浏览器中实现视频编辑,最容易想到的方案通常是 ffmpeg.wasm。
项目中的实际实现已经开源,包括多轨时间线、字幕、贴纸、画中画、WebCodecs 离线导出、OfflineAudioContext 混音和 ffmpeg.wasm 兼容转码:
https://github.com/MartinDelophy/ai-video-editor
正文继续
FFmpeg 本身拥有完整的音视频处理能力。通过 WebAssembly,它可以在浏览器中完成音频提取、格式转换、视频编码、音视频封装和滤镜处理。
我们开发的浏览器视频编辑器最初也采用了类似思路:将素材写入 ffmpeg.wasm 的虚拟文件系统,通过 FFmpeg 命令生成最终视频。
但随着项目逐渐加入多轨时间线、字幕、贴纸、画中画、蒙版、关键帧、转场、配音和背景音乐,单纯依赖 ffmpeg.wasm 的问题开始显现:
- 首次加载成本较高
- 大文件复制带来明显的内存压力
- 预览和导出需要维护两套渲染逻辑
- 浏览器的编码能力没有被充分利用
- 长任务的进度、取消和错误恢复不够灵活
- MP4 转码失败可能导致整个导出结果丢失
经过多轮调整,我们最终采用了下面的混合架构:
Canvas
+ WebCodecs
+ OfflineAudioContext
+ Mediabunny
+ MediaRecorder
+ ffmpeg.wasm
本文将介绍各项技术在这套浏览器视频导出架构中的职责,以及 ffmpeg.wasm 更适合承担哪些工作。
第一版:以 ffmpeg.wasm 为中心
最直接的导出方式是将输入文件写入虚拟文件系统:
const {
FFmpeg } = await import("@ffmpeg/ffmpeg");
const {
fetchFile } = await import("@ffmpeg/util");
const ffmpeg = new FFmpeg();
await ffmpeg.load({
coreURL: "/ffmpeg/ffmpeg-core.js",
wasmURL: "/ffmpeg/ffmpeg-core.wasm",
});
await ffmpeg.writeFile(
"input.webm",
await fetchFile(inputBlob),
);
随后执行转码:
await ffmpeg.exec([
"-i",
"input.webm",
"-c:v",
"libx264",
"-c:a",
"aac",
"-movflags",
"+faststart",
"output.mp4",
]);
读取输出文件:
const data =
await ffmpeg.readFile("output.mp4");
const result = new Blob([data.buffer], {
type: "video/mp4",
});
对于格式转换工具来说,这套方案非常实用。
但视频编辑器不只是格式转换。编辑器需要根据时间线,在每一个时刻计算当前应该出现的内容。
例如:
- 当前使用哪个视频片段
- 视频片段应该播放到哪一帧
- 字幕是否显示
- 贴纸是否处于有效时间范围
- 画中画图层位于什么位置
- 关键帧应该插值到什么数值
- 转场进行到什么进度
- 多条音频轨道如何混合
如果全部转换为 FFmpeg 命令和 Filter Graph,前端预览和导出逻辑就会逐渐分离。
为什么预览与导出的一致性很重要?
在编辑器中,用户看到的预览就是对最终结果的承诺。
假设预览使用 Canvas 和 CSS,而导出使用另一套 FFmpeg Filter Graph,那么以下细节很容易产生差异:
- 字幕换行宽度
- 字体大小和行高
- 字幕背景内边距
- 图片的 contain/cover 计算
- 画中画的位置和缩放
- 贴纸透明度
- 蒙版圆角
- 关键帧插值
- 转场的起止时间
功能越复杂,两套渲染器越难保持一致。
因此,我们决定让预览和导出共享同一套画面合成函数。
使用 Canvas 进行逐帧合成
导出开始时,首先根据项目时长和帧率生成帧计划。
export function createFramePlan(
duration,
frameRate = 30,
) {
const fps = Math.max(
24,
Math.min(60, Math.round(frameRate)),
);
const frameCount = Math.max(
1,
Math.ceil(duration * fps),
);
return Array.from(
{
length: frameCount },
(_, index) => ({
index,
timestamp: index / fps,
duration: 1 / fps,
keyFrame: index % (fps * 2) === 0,
}),
);
}
对于每个输出时间戳,合成器解析当前时间线状态:
function resolveTimelineAtTime(
project,
timestamp,
) {
return {
visual: resolveVisual(
project.visualSegments,
timestamp,
),
captions: resolveCaptions(
project.captionSegments,
timestamp,
),
stickers: resolveStickers(
project.stickerSegments,
timestamp,
),
overlays: resolveOverlays(
project.overlaySegments,
timestamp,
),
};
}
然后绘制到离屏 Canvas:
async function renderFrame(
context,
canvas,
project,
timestamp,
) {
const state = resolveTimelineAtTime(
project,
timestamp,
);
context.clearRect(
0,
0,
canvas.width,
canvas.height,
);
drawVisual(
context,
state.visual,
canvas,
);
drawOverlays(
context,
state.overlays,
canvas,
);
drawStickers(
context,
state.stickers,
canvas,
);
drawCaptions(
context,
state.captions,
canvas,
);
}
这种方式有一个重要优势:预览与导出可以共享 drawVisual、drawCaptions、drawStickers 等几何计算。
导出不再需要重新实现一套视觉规则。
使用 WebCodecs 进行确定性编码
Canvas 完成当前帧合成后,可以通过 WebCodecs 编码。
与 MediaRecorder 的实时录制模型不同,WebCodecs 允许应用显式控制每一帧的时间戳。
这意味着即使设备实际只能以每秒 10 帧的速度完成渲染,最终输出文件仍然可以是准确的 30 FPS。
概念上可以表示为:
for (const frame of framePlan) {
await renderFrame(
context,
canvas,
project,
frame.timestamp,
);
const videoFrame = new VideoFrame(
canvas,
{
timestamp:
Math.round(
frame.timestamp * 1_000_000,
),
},
);
encoder.encode(videoFrame, {
keyFrame: frame.keyFrame,
});
videoFrame.close();
}
这种确定性导出路径适合:
- 视频编辑器
- 时间线动画
- 字幕烧录
- 数据可视化视频
- 图片序列生成
- 需要精确帧数的内容生产工具
项目中使用 Mediabunny 负责 MP4/WebM 容器,以及编码结果的音视频 mux。
使用 OfflineAudioContext 混合多轨音频
画面可以逐帧渲染,音频则更适合一次性离线混合。
编辑器包含多种音频来源:
- 视频原声
- AI 配音
- 用户录音
- 背景音乐
- 人声分离结果
- 伴奏音轨
每个音频片段具有独立的时间属性:
{
start: 3.5,
sourceOffset: 1.2,
sourceDuration: 4.8,
volume: 0.8,
playbackRate: 1,
fadeIn: 0.2,
fadeOut: 0.5,
}
首先创建最终视频长度对应的 OfflineAudioContext:
const sampleRate = 48_000;
const context =
new OfflineAudioContext(
2,
Math.ceil(duration * sampleRate),
sampleRate,
);
将音频片段放到对应位置:
for (const clip of audioClips) {
const source =
context.createBufferSource();
const gain =
context.createGain();
source.buffer = clip.buffer;
gain.gain.setValueAtTime(
clip.volume,
clip.start,
);
source
.connect(gain)
.connect(context.destination);
source.start(
clip.start,
clip.sourceOffset,
clip.sourceDuration,
);
}
如果需要淡入:
gain.gain.setValueAtTime(
0,
clip.start,
);
gain.gain.linearRampToValueAtTime(
clip.volume,
clip.start + clip.fadeIn,
);
最终生成完整混音:
const mixedAudio =
await context.startRendering();
这样可以让预览、时间线和导出的音频位置保持一致。
ffmpeg.wasm 负责哪些工作?
调整架构后,ffmpeg.wasm 不再承担主画面渲染,但依然是项目中的重要组件。
1. 提取视频原声
用户导入视频后,编辑器需要提取音频用于波形、剪辑、字幕识别和人声分离。
await ffmpeg.exec([
"-i",
"input.mp4",
"-vn",
"-acodec",
"pcm_s16le",
"-ar",
"48000",
"-ac",
"2",
"source.wav",
]);
其中:
-vn忽略视频轨道pcm_s16le输出 PCM WAV-ar 48000设置采样率-ac 2设置双声道
这类输入明确、输出明确的媒体处理任务,很适合交给 ffmpeg.wasm。
2. 拼接与标准化音频
不同视频的音频可能具有不同的编码格式、采样率和声道数。
在进行波形计算或时间线拼接前,可以先利用 FFmpeg 统一格式。
3. WebM 转 MP4
不同浏览器对 H.264 和 AAC 的 WebCodecs 编码支持不同。
如果浏览器可以直接生成 MP4,导出流程不需要 FFmpeg。
如果只能先生成 WebM,而用户明确要求 MP4,则在最后一步动态加载 ffmpeg.wasm:
async function transcodeToMp4(
webmBlob,
) {
const {
ffmpeg, fetchFile } =
await loadFFmpeg();
const taskId =
crypto.randomUUID();
const inputName =
`input-${
taskId}.webm`;
const outputName =
`output-${
taskId}.mp4`;
try {
await ffmpeg.writeFile(
inputName,
await fetchFile(webmBlob),
);
await ffmpeg.exec([
"-i",
inputName,
"-c:v",
"libx264",
"-c:a",
"aac",
"-movflags",
"+faststart",
outputName,
]);
const data =
await ffmpeg.readFile(
outputName,
);
return new Blob([data.buffer], {
type: "video/mp4",
});
} finally {
await ffmpeg
.deleteFile(inputName)
.catch(() => {
});
await ffmpeg
.deleteFile(outputName)
.catch(() => {
});
}
}
保留最后一个正确结果
导出系统中有一个容易被忽视的问题:如果 WebM 已经生成成功,但最后的 MP4 转码失败,是否应该让整个任务失败?
我们的答案是否定的。
WebM 已经是一个有效的完整视频,不应该因为后续格式转换失败而被丢弃。
if (result.nativeMp4) {
download(
result.blob,
"video.mp4",
);
return;
}
try {
const mp4 =
await transcodeToMp4(
result.blob,
);
download(
mp4,
"video.mp4",
);
} catch (error) {
console.error(error);
download(
result.blob,
"video.webm",
);
}
因此,系统始终保留“最后一个正确结果”:
- 优先生成确定性的 MP4 或 WebM
- 需要时再转换为 MP4
- MP4 转换成功后保存 MP4
- 转换失败则保存已经完成的 WebM
这种设计避免用户因为最后一个转换步骤失败,失去前面几分钟的渲染成果。
完整导出流程
当前导出链路如下:
解析时间线
↓
生成精确帧计划
↓
Canvas 逐帧合成
↓
WebCodecs 编码
↓
OfflineAudioContext 混音
↓
Mediabunny 封装 MP4/WebM
↓ WebCodecs 不可用
MediaRecorder 兼容导出
↓ 用户要求 MP4
ffmpeg.wasm 兼容转码
↓ 转码失败
保存已完成的 WebM
各组件的职责如下:
| 组件 | 职责 |
|---|---|
| Canvas | 画面、字幕、贴纸、蒙版和转场合成 |
| WebCodecs | 确定性视频编码 |
| OfflineAudioContext | 多轨音频混合 |
| Mediabunny | MP4/WebM 封装与 mux |
| MediaRecorder | 浏览器兼容回退 |
| ffmpeg.wasm | 音频处理和格式转换 |
ffmpeg.wasm 的内存管理
ffmpeg.wasm 的虚拟文件系统不是磁盘,它使用的仍然是浏览器内存。
因此,每次任务完成后都应该清理文件:
try {
await ffmpeg.writeFile(
inputName,
inputData,
);
await ffmpeg.exec(args);
return await ffmpeg.readFile(
outputName,
);
} finally {
await ffmpeg
.deleteFile(inputName)
.catch(() => {
});
await ffmpeg
.deleteFile(outputName)
.catch(() => {
});
}
同时,应避免多个任务复用固定文件名:
const id = crypto.randomUUID();
const inputName =
`input-${
id}.webm`;
const outputName =
`output-${
id}.mp4`;
这可以避免并发操作发生覆盖。
按需加载与缓存
建议只在用户真正执行 FFmpeg 任务时加载运行时:
let ffmpegPromise;
function getFFmpeg() {
ffmpegPromise ??=
createFFmpegInstance();
return ffmpegPromise;
}
第一次操作需要加载 WASM,后续操作可以复用实例。
如果项目以 PWA 形式交付,还可以通过 Service Worker 缓存 FFmpeg Core 和 WASM 文件,避免用户重复下载。
缓存时应使用版本化路径:
/vendor/ffmpeg/0.12.10/ffmpeg-core.js
/vendor/ffmpeg/0.12.10/ffmpeg-core.wasm
避免运行时代码与旧 WASM 文件不匹配。
多线程版本的部署要求
使用依赖 SharedArrayBuffer 的多线程版本时,需要启用 Cross-Origin Isolation。
常见响应头为:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
同时要注意,页面加载的跨域资源也必须满足要求,包括:
- 视频和图片
- 字体
- Worker
- FFmpeg Core
- WASM 文件
- AI 模型文件
- 第三方 CDN 资源
在云端部署时,可以先检查:
console.log(
window.crossOriginIsolated,
);
只有返回 true,依赖 SharedArrayBuffer 的能力才能正常使用。
导出进度应该分阶段展示
不要只显示一个笼统的“正在导出”。
更清晰的状态包括:
正在准备素材
正在解析视频音频
正在混合音轨
正在渲染第 120 / 900 帧
正在编码视频
正在封装 MP4
正在加载 FFmpeg
正在转换 MP4
正在验证输出文件
正在保存
ffmpeg.wasm 的加载时间和实际转码时间是两个不同阶段,应该分别显示。
ffmpeg.on(
"progress",
({
progress }) => {
updateProgress(
90 + progress * 8,
);
},
);
例如,可以把前 90% 分配给主渲染,把最后的 8% 分配给 MP4 转码,剩余部分用于验证和保存。
不要只验证 Blob 大小
视频导出函数返回了一个非空 Blob,不代表输出文件一定正常。
更可靠的测试应该验证:
- 视频宽高
- 总时长
- 视频轨道
- 音频轨道
- 帧数
- 首尾帧
- 字幕是否可见
- 透明贴纸是否正确
- 画中画是否出现在正确时间
- 音频是否真的可以播放
基础检查:
expect(
result.blob.size,
).toBeGreaterThan(0);
expect(
metadata.width,
).toBe(1920);
expect(
metadata.height,
).toBe(1080);
expect(
metadata.audioTrackCount,
).toBeGreaterThan(0);
在端到端测试中,还可以重新解码输出视频,并抽取指定时间点的帧进行像素验证。
总结
ffmpeg.wasm 为浏览器带来了非常完整的音视频处理能力,但复杂视频编辑器并不一定要把全部渲染任务交给 FFmpeg。
在我们的实践中,更合适的分工是:
- Canvas 负责与预览一致的画面合成
- WebCodecs 负责逐帧确定性编码
- OfflineAudioContext 负责多轨音频混合
- Mediabunny 负责 MP4/WebM 容器封装
- MediaRecorder 负责兼容性回退
- ffmpeg.wasm 负责音频提取、格式标准化和 MP4 转码
这种架构充分利用浏览器原生媒体能力,同时保留 FFmpeg 在格式兼容方面的优势。
当 MP4 转码失败时,系统仍然保存已经完成的 WebM,避免用户失去整个导出结果。
如果你也在开发浏览器端音视频工具,可以把 ffmpeg.wasm 看作一个专业媒体处理层,而不是必须承担整个应用的唯一渲染引擎。