作者:互联网 时间: 2026-07-23 17:25:01
在浏览器中实现视频编辑,最容易想到的方案通常是 ffmpeg.wasm。

项目中的实际实现已经开源,包括多轨时间线、字幕、贴纸、画中画、WebCodecs 离线导出、OfflineAudioContext 混音和 ffmpeg.wasm 兼容转码:
https://github.com/MartinDelophy/ai-video-editor
FFmpeg 本身拥有完整的音视频处理能力。通过 WebAssembly,它可以在浏览器中完成音频提取、格式转换、视频编码、音视频封装和滤镜处理。
我们开发的浏览器视频编辑器最初也采用了类似思路:将素材写入 ffmpeg.wasm 的虚拟文件系统,通过 FFmpeg 命令生成最终视频。
但随着项目逐渐加入多轨时间线、字幕、贴纸、画中画、蒙版、关键帧、转场、配音和背景音乐,单纯依赖 ffmpeg.wasm 的问题开始显现:
经过多轮调整,我们最终采用了下面的混合架构:
Canvas+ WebCodecs+ OfflineAudioContext+ Mediabunny+ MediaRecorder+ 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,那么以下细节很容易产生差异:
功能越复杂,两套渲染器越难保持一致。
因此,我们决定让预览和导出共享同一套画面合成函数。
导出开始时,首先根据项目时长和帧率生成帧计划。
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 等几何计算。
导出不再需要重新实现一套视觉规则。
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。
画面可以逐帧渲染,音频则更适合一次性离线混合。
编辑器包含多种音频来源:
每个音频片段具有独立的时间属性:
{ 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 不再承担主画面渲染,但依然是项目中的重要组件。
用户导入视频后,编辑器需要提取音频用于波形、剪辑、字幕识别和人声分离。
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。
不同视频的音频可能具有不同的编码格式、采样率和声道数。
在进行波形计算或时间线拼接前,可以先利用 FFmpeg 统一格式。
不同浏览器对 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",);
}
因此,系统始终保留“最后一个正确结果”:
这种设计避免用户因为最后一个转换步骤失败,失去前面几分钟的渲染成果。
当前导出链路如下:
解析时间线 ↓
生成精确帧计划 ↓
Canvas 逐帧合成 ↓
WebCodecs 编码 ↓
OfflineAudioContext 混音 ↓
Mediabunny 封装 MP4/WebM ↓ WebCodecs 不可用
MediaRecorder 兼容导出 ↓ 用户要求 MP4
ffmpeg.wasm 兼容转码 ↓ 转码失败
保存已完成的 WebM
各组件的职责如下:
| 组件 | 职责 |
|---|---|
| Canvas | 画面、字幕、贴纸、蒙版和转场合成 |
| WebCodecs | 确定性视频编码 |
| OfflineAudioContext | 多轨音频混合 |
| Mediabunny | MP4/WebM 封装与 mux |
| MediaRecorder | 浏览器兼容回退 |
| 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
同时要注意,页面加载的跨域资源也必须满足要求,包括:
在云端部署时,可以先检查:
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,不代表输出文件一定正常。
更可靠的测试应该验证:
基础检查:
expect(result.blob.size,
).toBeGreaterThan(0);
expect(metadata.width,
).toBe(1920);
expect(metadata.height,
).toBe(1080);
expect(metadata.audioTrackCount,
).toBeGreaterThan(0);
在端到端测试中,还可以重新解码输出视频,并抽取指定时间点的帧进行像素验证。
ffmpeg.wasm 为浏览器带来了非常完整的音视频处理能力,但复杂视频编辑器并不一定要把全部渲染任务交给 FFmpeg。
在我们的实践中,更合适的分工是:
这种架构充分利用浏览器原生媒体能力,同时保留 FFmpeg 在格式兼容方面的优势。
当 MP4 转码失败时,系统仍然保存已经完成的 WebM,避免用户失去整个导出结果。
如果你也在开发浏览器端音视频工具,可以把 ffmpeg.wasm 看作一个专业媒体处理层,而不是必须承担整个应用的唯一渲染引擎。