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

简介: 本文介绍了一种混合架构的浏览器视频编辑器导出方案:以Canvas+WebCodecs逐帧合成与编码、OfflineAudioContext混音为核心,ffmpeg.wasm仅负责音频提取、格式标准化及MP4转码等专项任务,兼顾预览一致性、性能与容错性。(239字)

前言

在浏览器中实现视频编辑,最容易想到的方案通常是 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,
  );
}

这种方式有一个重要优势:预览与导出可以共享 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 看作一个专业媒体处理层,而不是必须承担整个应用的唯一渲染引擎。

相关文章
|
1天前
|
人工智能 JSON 安全
|
1天前
|
云安全 人工智能 安全
|
3天前
|
人工智能
Qwen3.8抢先体验!正式版即将发布并开源!
千问Qwen3.8即将开源,参数达2.4T,进化速度以“天”计,实力媲美Fable 5。预览版Qwen3.8-Max已上线阿里Token Plan等平台,限时优惠:日间Credits低至1折,夜间更优,个人/团队版月付仅35元起!
538 20
|
3天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
Qwen3.8-Max-Preview是通义千问Qwen3系列旗舰MoE大模型,参数达2.4万亿,综合推理能力居行业第一梯队。支持思考/快速双模式,擅长大模型五大高难场景。现于阿里云百炼Token Plan、Qoder及QoderWork上线体验,个人版低至39元/月。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
438 1
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
|
2天前
|
人工智能 测试技术 语音技术
Qwen-Audio-3.0-TTS 正式发布!AI 语音从 “能说话” 升级到 “会带情绪表达”
阿里云发布Qwen-Audio-3.0-TTS语音合成大模型,支持细粒度标签控制(如[gasp][angry])、freestyle自由风格、16种语言及20种方言,声学鲁棒性强。含Flash(首包延时300ms)和Plus(全球榜单冠军)双版本,已在百炼平台开放调用。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
462 0
|
9天前
|
缓存 UED 开发者
Codex109天重置23次,明天还要再送一次
Codex近109天完成23次额度重置,7月14日将迎来第24次。Tibo高频响应用户反馈:优化GPT-5.6高消耗问题、补发失效福利、调整重置时间——形成“反馈→回应→修复→补偿”正向闭环,彰显以用户为中心的产品哲学。(239字)
820 12
|
1天前
|
人工智能 自然语言处理 数据挖掘
最新版通义千问(Qwen3.8-Max-Preview)功能介绍
2026年,通义千问正式推出全新旗舰级大模型 **Qwen3.8-Max-Preview 预览版**,作为首款突破万亿参数规格的新一代基座模型,该模型总参数量达到**2.4万亿**,采用全新迭代的MoE混合专家架构,综合推理性能、长文本处理、多模态理解、复杂任务规划能力全面超越前代Qwen3.7-Max版本,整体实力跻身全球第一梯队,可对标海外顶级旗舰模型,是当前面向复杂工程开发、多智能体协同、超长文档解析、专业办公自动化场景的最优国产基座模型。
519 0
|
12天前
|
存储 人工智能 JSON
Qwen 本地部署搭配 ComfyUI 生成 AI 漫剧完整实操指南(小白零基础可落地,零成本无限生成+角色一致性天花板)
2026全网最优本地漫剧流水线:零成本、离线运行、角色统一、低配(8G显卡)可跑。融合Qwen本地大模型+ComfyUI双引擎,实现剧本生成→分镜绘图→动态成片全自动,隐私安全、无审核限流,新手30分钟上手,日更无忧。(239字)

热门文章

最新文章