您的位置:首页 > 手游攻略 > 从 ffmpeg.wasm 到 WebCodecs:浏览器视频编辑器导出架构的演进及实践

从 ffmpeg.wasm 到 WebCodecs:浏览器视频编辑器导出架构的演进及实践

作者:互联网  时间: 2026-07-23 17:25:01  

前言

在浏览器中实现视频编辑,最容易想到的方案通常是 ffmpeg.wasm。

从 ffmpeg.wasm 到 WebCodecs:浏览器视频编辑器导出架构的演进与实践

项目中的实际实现已经开源,包括多轨时间线、字幕、贴纸、画中画、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,);
}


这种方式有一个重要优势:预览与导出可以共享 drawVisualdrawCaptionsdrawStickers 等几何计算。

导出不再需要重新实现一套视觉规则。

使用 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",);
}


因此,系统始终保留“最后一个正确结果”:

  1. 优先生成确定性的 MP4 或 WebM
  2. 需要时再转换为 MP4
  3. MP4 转换成功后保存 MP4
  4. 转换失败则保存已经完成的 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 看作一个专业媒体处理层,而不是必须承担整个应用的唯一渲染引擎。

最新游戏

更多

Copyright©2010-2019. All rights reserved | 波波三国游戏官网|[email protected]

备案编号:湘ICP备2022015115号-4