从 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 看作一个专业媒体处理层,而不是必须承担整个应用的唯一渲染引擎。

相关文章
|
2月前
|
人工智能 缓存 编解码
把 AI 视频剪辑搬进浏览器:Timeline Studio 的本地优先架构与工程实践
视频剪辑、语音合成、自动字幕和 AI 视觉处理,过去往往意味着桌面客户端、服务端转码集群以及漫长的素材上传。Timeline Studio 尝试了另一条路线:以浏览器为完整运行环境,把多轨时间线、ONNX 端侧推理、Canvas 合成、离线音频混音和 WebCodecs 编码连接成一个可用的 AI 视频编辑器。本文结合项目实践,拆解这套架构为何成立、最难的三个工程问题,以及我们如何处理预览与导出一致性、模型缓存、时间线精度和兼容回退
|
2月前
|
缓存 人工智能 API
CLAUDE.md 不只是写规则,写法本身决定你的 token 账单
CLAUDE.md 不只是项目说明文档,它被注入到每次请求的缓存链条里,排在历史对话之前。Anthropic 的 Prompt Cache 用精确前缀匹配:内容不变才能按 10% 价格命中缓存,哪怕改一个字,后面所有历史对话都要按 125% 重新计算。会话越长,中途改动的代价越大。文章从这个机制出发,讲清楚"CLAUDE.md 要简洁""别在会话中途改""别写临时易变内容"这些常见建议背后的真实原因,并给出通过 API 返回的 usage 字段验证缓存是否真正生效的方法。
|
2月前
|
人工智能 前端开发 数据库
一人用 Qoder 开十二个对话,五天写完全部代码
瑕玉Xiayu 在 AdventureX 黑客松比赛中开发了多智能体决策系统 Ludus,5 天时间 + 12 个 Qoder Agent 并行完成。系统以“可追溯、可审计、可复盘”为核心,通过规则先行、跨会话记忆、契约卡点保障质量,实现结构化决策支持。
318 0
|
2月前
|
人工智能 运维 IDE
零成本开启AI编程!阿里云Qoder CN(原灵码)免费社区版功能、额度规则全解析
在AI赋能研发的时代,AI编码工具已经成为开发者日常迭代、项目开发、代码调试的必备生产力工具。市面上多数优质AI编程产品均采用高额订阅收费模式,对于编程学习者、个人开发者、业余技术爱好者而言,长期使用成本较高,入门门槛居高不下。为普惠基层开发群体,降低全民AI编程落地门槛,阿里云将**原通义灵码全新升级为Qoder CN**,推出永久免费的社区版本,搭配专属Credits额度体系,让普通用户无需付费,即可体验专业级AI编码智能体能力,覆盖基础开发、代码调试、项目学习、脚本编写等全场景。
923 0
|
6天前
|
人工智能 前端开发 开发者
整理了一个 GPT-6 Astra 游戏与交互作品合集,分享几个案例
Awesome GPT-6 Astra 是一个开源合集项目,收录4款由GPT-6 Astra辅助开发的交互作品:瓜体实验室、Mosswing、魔毯巫师与轨道花园,涵盖合成游戏、飞行挑战与粒子艺术。含体验入口、源码、Prompt及作者署名,旨在展示AI协作潜力,促进开发者交流。(239字)
|
2月前
|
人工智能 自然语言处理 数据可视化
2026年AI办公助手场景化提效全指南
本文分享作者半年AI办公实战经验,聚焦内容生产、会议沟通、项目管理、知识沉淀四大提效场景,剖析主流工具特点,并为管理者、HR、行政、销售等角色提供分岗位落地建议,强调AI核心价值是解放人力于低价值搬运,专注高价值决策与创意。
|
2月前
|
存储 人工智能 前端开发
为浏览器音视频编辑器开发 Agent Skill,实现 AI 自动化剪辑自测
本文介绍基于离线AI剪辑工具Timeline Studio构建的Agent Skill规范体系,通过开源技能库`edit-timeline-studio`,实现AI对前端编辑器的标准化UI操作、异常处理与全链路自动化测试,推动产品鲁棒性提升
|
1月前
|
缓存 人工智能 前端开发
浏览器本地跑中文 TTS:我们将 Kokoro 1.1 转为 FP16,替换了 Piper 中文配音
Timeline Studio v1.0.0 实现中文TTS浏览器端本地运行:将Kokoro multi-lang v1.1转为FP16,通过sherpa-onnx WASM+Web Worker在前端高效推理,支持中英混读、4种音色,全程离线、隐私安全,已开源。
|
1月前
|
存储 缓存 人工智能
浏览器端多语种 AI 配音卡在 86%?从 WebGPU、WASM 到模型缓存治理的完整修复实践
本项目实现浏览器端AI配音,无需上传数据、降低服务器成本。针对模型常卡在86%、多语言加载失败等痛点,通过统一缓存身份、双镜像下载、Q8量化模型、WASM回退、智能缓存清理及分阶段进度提示等方案,彻底解决QuotaExceededError,支持中英日韩泰等多语种稳定本地合成。
|
25天前
|
人工智能 计算机视觉
别再让 AI 只会“粗剪”:开源项目 Timeline Studio 正在把专业后期交给 Agent
Timeline Studio 是开源AI视频编辑项目,本次升级聚焦专业后期理解:新增动作感知变速、镜头匹配调色、动态抠图与描边四大能力,强调“为何做”而非“能做什么”,让AI从自动粗剪迈向可验证、可编辑的专业成片。

热门文章

最新文章