返回文章列表

第十二章|浏览器音视频工程化:性能、内存、Worker 与兼容性

把 WebCodecs 和 Web Audio Demo 升级成项目级浏览器音视频处理系统,系统梳理主线程与 Worker 拆分、ArrayBuffer transfer、VideoFrame 和 AudioData 生命周期、背压、大文件分片、WASM 边界与兼容性 fallback。

第十二章|浏览器音视频工程化:性能、内存、Worker 与兼容性

目标: 把前面做出来的 WebCodecs / Web Audio Demo,升级成一个“像项目”的浏览器端音视频处理系统。 这一章不再只关心“API 能不能跑”,而是关心:

会不会卡 UI?
会不会爆内存?
大文件怎么处理?
不支持 WebCodecs 怎么办?
Worker 怎么拆?
VideoFrame / AudioData 什么时候释放?
编码/解码速度跟不上怎么办?
项目代码怎么组织?

WebCodecs 的价值是给前端直接处理 raw frame、encoded chunk、VideoEncoder、VideoDecoder、AudioEncoder、AudioDecoder 等底层能力;MDN 也明确说明它适合重媒体处理、低层控制,并且可在 Dedicated Worker 中使用。(MDN Web Docs) 但 WebCodecs 规范本身不要求浏览器必须支持某个具体 codec,所以工程上绝对不能只写 new VideoEncoder() 就假设“所有浏览器、所有视频都能处理”。(W3C)


1. 本章学习目标

学完这一章,你要能做到:

  1. 解释为什么浏览器端音视频处理容易卡主线程。
  2. 设计一个主线程 + Worker 的音视频处理架构。
  3. 正确使用 ArrayBuffer transfer,避免大块数据来回复制。
  4. 正确释放 VideoFrame / AudioData,避免 GPU / native memory 泄漏。
  5. encodeQueueSize / decodeQueueSize 理解 WebCodecs 背压。
  6. 设计大文件分片读取与增量解析策略。
  7. 说明 WebCodecs、Web Audio、WASM demuxer/muxer、ffmpeg.wasm 的边界。
  8. 制定浏览器兼容性与 fallback 策略。
  9. 给面试官讲清楚一个浏览器音视频项目的工程化方案。

2. 核心心智模型:音视频工程 = 流水线 + 资源所有权

前面章节我们一直在学这条链路:

File / Stream

Demux

EncodedVideoChunk / EncodedAudioChunk

Decode

VideoFrame / AudioData

Process

Encode

EncodedVideoChunk / EncodedAudioChunk

Mux

File / MSE / Preview

第十二章要给这条链路加上工程视角:

谁生产数据?
谁消费数据?
数据在主线程还是 Worker?
数据是复制、转移,还是共享?
队列堆积时谁暂停?
Frame 用完谁 close?
浏览器不支持时走哪条路?

更像项目的版本应该是这样:

Main Thread
  ├─ UI 状态
  ├─ 文件选择
  ├─ 进度展示
  ├─ 预览渲染
  └─ 与 Worker 通信

Dedicated Worker
  ├─ 文件分片读取 / 接收
  ├─ demux
  ├─ WebCodecs decode
  ├─ frame 处理
  ├─ WebCodecs encode
  ├─ mux
  └─ 资源释放 / 背压控制

Web Workers 的作用就是把繁重脚本放到与主执行线程分离的后台线程里运行,让主线程继续响应 UI;不过 Worker 不能直接操作 DOM,主线程和 Worker 之间主要通过 postMessage() 传递消息。(MDN Web Docs)


3. 为什么音视频处理容易卡主线程

音视频处理天生是“高频 + 大数据 + 多阶段”的工作。

以 1080p RGBA 视频为例,一帧大小大约是:

1920 × 1080 × 4 bytes ≈ 8.29 MB

如果你每秒处理 30 帧,理论上每秒要碰的数据量接近:

8.29 MB × 30 ≈ 248.7 MB/s

这还只是 raw pixel,不包括:

解封装读取
解码
Canvas 绘制
滤镜处理
编码
封装
内存拷贝
GC 压力
UI 渲染

所以一个看起来很普通的“给视频加水印”功能,实际链路可能是:

读取 MP4

解析 box / sample table

取出 encoded sample

VideoDecoder 解码

VideoFrame 输出

Canvas / WebGL / WebGPU 处理

VideoEncoder 编码

收集 encoded chunks

mux 成 MP4 / WebM

如果这一整套全压在主线程上,用户看到的就是:

页面卡住
按钮点不动
进度条不刷新
滚动掉帧
甚至浏览器标签页崩掉

Chrome 的 WebCodecs 文档也提醒:即使 WebCodecs 的重活是异步的,frame / chunk 回调仍可能高频触发,容易让主线程变得不响应,因此更推荐把单帧和 chunk 处理移到 Worker。(Chrome for Developers)


4. 主线程 vs Worker:怎么拆才合理

4.1 主线程应该负责什么

主线程只做“人能看见和操作”的东西:

文件选择
参数配置
任务开始 / 暂停 / 取消
进度条
缩略图展示
预览播放
错误提示
最终下载

不要让主线程长期做:

循环解析大文件
逐帧像素处理
大 ArrayBuffer copy
复杂 demux / mux
持续 encode / decode 调度

4.2 Worker 应该负责什么

Worker 负责“重计算 + 大数据 + 管线调度”:

读取或接收文件分片
解析容器
管理 sample 队列
调用 VideoDecoder / AudioDecoder
处理 VideoFrame / AudioData
调用 VideoEncoder / AudioEncoder
做 mux
发送进度和结果给主线程

4.3 推荐消息协议

不要随手 postMessage({ anything }),项目里建议定义消息协议。

// shared/messages.ts

export type MainToWorkerMessage =
  | {
      type: "load-file-chunk";
      taskId: string;
      offset: number;
      buffer: ArrayBuffer;
    }
  | {
      type: "start";
      taskId: string;
      options: ProcessOptions;
    }
  | {
      type: "cancel";
      taskId: string;
    };

export type WorkerToMainMessage =
  | {
      type: "progress";
      taskId: string;
      stage: "demux" | "decode" | "process" | "encode" | "mux";
      value: number;
    }
  | {
      type: "thumbnail";
      taskId: string;
      timestamp: number;
      bitmap: ImageBitmap;
    }
  | {
      type: "done";
      taskId: string;
      resultBuffer: ArrayBuffer;
      mimeType: string;
    }
  | {
      type: "error";
      taskId: string;
      message: string;
      stack?: string;
    };

export interface ProcessOptions {
  watermarkText?: string;
  outputWidth?: number;
  outputHeight?: number;
  videoCodec?: string;
  audioCodec?: string;
}

这个做法的好处是:

UI 不关心底层 pipeline
Worker 不关心 React / Vue 状态
任务可以取消
错误可以统一展示
后面换 demuxer / muxer 不影响 UI

5. ArrayBuffer:转移,不要复制

主线程和 Worker 之间传大文件时,最容易犯的错误是:

worker.postMessage({
  type: "load",
  buffer,
});

这会走结构化克隆。MDN 说明 Worker 与主线程之间的数据通过消息发送,默认是复制而不是共享。(MDN Web Docs) 对于几十 MB、几百 MB 的媒体数据,这个复制成本很吓人。

正确方式是使用 Transferable objects

worker.postMessage(
  {
    type: "load",
    buffer,
  },
  [buffer],
);

ArrayBuffer 是典型 transferable object。被 transfer 后,底层内存资源会从原上下文移动到新上下文,原来的 buffer 会被 detach,不能再继续读写。(MDN Web Docs)

5.1 主线程分片发送文件

// main.ts

const worker = new Worker(new URL("./media.worker.ts", import.meta.url), {
  type: "module",
});

async function sendFileInChunks(file: File, taskId: string) {
  const chunkSize = 8 * 1024 * 1024; // 8 MB

  for (let offset = 0; offset < file.size; offset += chunkSize) {
    const blob = file.slice(offset, offset + chunkSize);
    const buffer = await blob.arrayBuffer();

    worker.postMessage(
      {
        type: "load-file-chunk",
        taskId,
        offset,
        buffer,
      },
      [buffer], // 关键:转移所有权,不复制
    );
  }

  worker.postMessage({
    type: "file-end",
    taskId,
    size: file.size,
    name: file.name,
  });
}

注意:transfer 之后,主线程里的 buffer 不要再用。它已经“搬家”了。

5.2 Worker 接收分片

// media.worker.ts

self.onmessage = async (event: MessageEvent) => {
  const msg = event.data;

  switch (msg.type) {
    case "load-file-chunk": {
      const { taskId, offset, buffer } = msg as {
        taskId: string;
        offset: number;
        buffer: ArrayBuffer;
      };

      await appendChunk(taskId, offset, buffer);
      break;
    }

    case "file-end": {
      await startDemux(msg.taskId);
      break;
    }
  }
};

async function appendChunk(
  taskId: string,
  offset: number,
  buffer: ArrayBuffer,
) {
  // 这里不要无脑拼成一个大 ArrayBuffer。
  // 更好的做法是交给增量 parser,或者存成 ByteSource。
}

6. 大文件处理:不要一上来 file.arrayBuffer()

玩具 Demo 常写:

const buffer = await file.arrayBuffer();

真实项目里要谨慎。一个 2GB 视频文件,直接 arrayBuffer() 的后果可能是:

内存瞬间暴涨
浏览器标签页崩溃
移动端直接阵亡
GC 长时间停顿

更靠谱的做法是抽象一个 ByteSource

export interface ByteSource {
  size: number;
  read(offset: number, length: number): Promise<ArrayBuffer>;
}

export class FileByteSource implements ByteSource {
  constructor(private file: File) {}

  get size() {
    return this.file.size;
  }

  async read(offset: number, length: number): Promise<ArrayBuffer> {
    return this.file.slice(offset, offset + length).arrayBuffer();
  }
}

然后 demuxer 不关心来源:

async function parseTopLevelBoxes(source: ByteSource) {
  let offset = 0;

  while (offset < source.size) {
    const header = await source.read(offset, 8);
    const view = new DataView(header);

    const size = view.getUint32(0);
    const type = readFourCC(view, 4);

    console.log({ offset, size, type });

    if (size === 0) break;
    offset += size;
  }
}

function readFourCC(view: DataView, offset: number) {
  return String.fromCharCode(
    view.getUint8(offset),
    view.getUint8(offset + 1),
    view.getUint8(offset + 2),
    view.getUint8(offset + 3),
  );
}

大文件分片时要注意

MP4 不是简单“每 8MB 切一下就能解析”的格式。Box、sample table、mdat 数据都可能跨分片,所以 parser 必须维护状态:

当前 offset
当前 box header 是否读完
当前 box body 是否完整
moov 是否已解析
sample table 是否可用
mdat range 在哪里
每个 sample 的 offset / size / timestamp

普通 MP4 如果 moovmdat 后面,你可能要先找到并解析 moov,才能知道每个 sample 在哪里。Fast start MP4 把 moov 放在前面,浏览器或播放器可以更快开始播放;工程上也更适合渐进式处理。


7. VideoFrame 生命周期:谁拿到,谁负责释放

VideoFrame 很容易让人误以为是普通 JS 对象,但它背后可能绑定 GPU memory / native media resource。MDN 说明 VideoFrame 表示视频帧,关联实际像素数据和 timestamp、duration、format、resolution 等元数据。(MDN Web Docs)

关键规则:

拿到 VideoFrame 后,用完必须 close()
传给 encoder 后,通常可以立刻 close()
画到 canvas 后,如果后面不用,也要 close()
clone 出来的 frame 也要各自管理
transfer 到 Worker 后,发送方对象会被关闭/失效

W3C WebCodecs 规范说明,VideoFrame.close()AudioData.close() 会释放它们对底层 media resource 的引用;当不再有引用时,浏览器就可以销毁资源。(W3C) MDN 的 WebCodecs 使用指南甚至提醒,VideoFrame 很大,不及时释放可能少于 100 个活跃 frame 就让应用崩溃。(MDN Web Docs)

7.1 正确:编码后立刻 close

function encodeFrame(
  encoder: VideoEncoder,
  frame: VideoFrame,
  keyFrame: boolean,
) {
  try {
    encoder.encode(frame, { keyFrame });
  } finally {
    frame.close();
  }
}

7.2 正确:处理前后都释放

async function handleDecodedFrame(frame: VideoFrame) {
  let processed: VideoFrame | undefined;

  try {
    processed = await addWatermark(frame);
    await waitForEncoderBackpressure(videoEncoder, 4);

    videoEncoder.encode(processed, {
      keyFrame: shouldForceKeyFrame(processed.timestamp),
    });
  } finally {
    frame.close();

    if (processed) {
      processed.close();
    }
  }
}

7.3 注意:不要重复 close 同一个对象

如果 addWatermark(frame) 有时直接返回原 frame,就要避免重复 close。

async function handleFrame(frame: VideoFrame) {
  let output: VideoFrame | undefined;

  try {
    output = await maybeProcess(frame);
    encoder.encode(output);
  } finally {
    if (output && output !== frame) {
      output.close();
    }

    frame.close();
  }
}

8. AudioData 生命周期:音频也要释放

AudioData 也不是普通小对象。它代表一段 decoded audio samples,可能包含多声道、多帧 PCM 数据。MDN 说明 AudioData.close() 会清除状态并释放它对 media resource 的引用。(MDN Web Docs)

典型处理方式:

function handleAudioData(audioData: AudioData) {
  try {
    const planeSize = audioData.allocationSize({
      planeIndex: 0,
      format: "f32-planar",
    });

    const pcm = new Float32Array(planeSize / Float32Array.BYTES_PER_ELEMENT);

    audioData.copyTo(pcm, {
      planeIndex: 0,
      format: "f32-planar",
    });

    // 后续:送入混音器、分析器、AudioWorklet 或重新编码
  } finally {
    audioData.close();
  }
}

音频处理中常见的内存坑:

把所有 PCM 一次性展开到 Float32Array
多个音轨全部 decode 到内存
长音频混音时不分块
AudioBuffer 长期缓存不释放
实时音频处理里频繁创建临时数组

真实项目中,短音频可以用 decodeAudioData + AudioBuffer;长音频、实时音频、批量音频则更应该考虑分块处理、AudioWorklet 或 Worker 管线。


9. 背压 backpressure:队列不能无限长

WebCodecs 的 encode / decode 都是异步队列模型。问题是:你的 JS 代码生产 frame 的速度,可能比编码器消费 frame 的速度快。

比如:

Canvas 每秒生成 30 帧
VideoEncoder 每秒只能编码 10 帧

如果你继续无脑 encoder.encode(frame),队列会越来越长:

第 1 秒:积压 20 帧
第 2 秒:积压 40 帧
第 3 秒:积压 60 帧
...
最后爆内存

MDN 说明 VideoEncoder.encodeQueueSize 表示 pending encode requests 数量,VideoDecoder.decodeQueueSize 表示 queued decode requests 数量。(MDN Web Docs) Chrome 文档也强调 configure() / encode() 会立即返回,实际工作进入队列,encodeQueueSize 可以观察等待中的请求数量。(Chrome for Developers)

9.1 Encoder 背压控制

function once(target: EventTarget, type: string) {
  return new Promise<void>((resolve) => {
    target.addEventListener(type, () => resolve(), { once: true });
  });
}

async function waitForEncoderBackpressure(
  encoder: VideoEncoder,
  maxQueueSize = 4,
) {
  while (encoder.encodeQueueSize >= maxQueueSize) {
    await once(encoder, "dequeue");
  }
}

使用:

async function encodeFrames(frames: AsyncIterable<VideoFrame>) {
  let index = 0;

  for await (const frame of frames) {
    await waitForEncoderBackpressure(videoEncoder, 4);

    try {
      videoEncoder.encode(frame, {
        keyFrame: index % 60 === 0,
      });
    } finally {
      frame.close();
    }

    index++;
  }

  await videoEncoder.flush();
  videoEncoder.close();
}

MDN 也说明 dequeue 事件可以在 queue 减少时触发,避免用 setTimeout() 轮询。(MDN Web Docs)

9.2 Decoder 背压控制

解码也一样,不要把 demux 出来的所有 chunks 一股脑喂给 decoder。

async function waitForDecoderBackpressure(
  decoder: VideoDecoder,
  maxQueueSize = 8,
) {
  while (decoder.decodeQueueSize >= maxQueueSize) {
    await once(decoder, "dequeue");
  }
}

async function decodeSamples(samples: AsyncIterable<EncodedVideoChunk>) {
  for await (const chunk of samples) {
    await waitForDecoderBackpressure(videoDecoder, 8);
    videoDecoder.decode(chunk);
  }

  await videoDecoder.flush();
}

9.3 背压不是“越小越好”

队列太大:

内存暴涨
延迟变高
取消任务变慢
错误恢复困难

队列太小:

编码器吃不饱
吞吐下降
硬件加速资源利用不足

实践里可以从这些阈值开始试:

decodeQueueSize: 4 ~ 16
encodeQueueSize: 2 ~ 8

然后根据设备性能、分辨率、目标帧率动态调整。


10. Transfer VideoFrame / AudioData:少复制,但要懂所有权

W3C 规范说明 AudioDataVideoFrame 都是 transferable / serializable;transfer 会把底层 resource reference 移到目标对象,并关闭源对象,这样可以在不同执行上下文之间移动媒体资源而不复制底层资源。(W3C)

这意味着你可以这样把 frame 从一个 Worker 交给另一个 Worker:

// worker A
targetWorker.postMessage(
  {
    type: "video-frame",
    frame,
  },
  [frame],
);

// 发送后,worker A 里的 frame 不应该再使用

接收方负责释放:

// worker B
self.onmessage = (event) => {
  const msg = event.data;

  if (msg.type === "video-frame") {
    const frame = msg.frame as VideoFrame;

    try {
      // draw / process / encode
    } finally {
      frame.close();
    }
  }
};

一句话记:

谁最后拥有 frame,谁负责 close。

11. WebCodecs、Web Audio、WASM、ffmpeg.wasm 的关系

这几个东西经常被混在一起,工程上要拆清楚。

能力主要解决什么不负责什么
WebCodecs编码、解码;VideoFrame / AudioData 与 encoded chunk 转换不解析 MP4,不生成 MP4,不保证具体 codec
Web Audio音频播放、混音、滤波、分析、离线渲染不处理 MP4 容器,不做通用视频编码
JS/WASM demuxer从 MP4/WebM 等容器里拆 sample/chunk不一定解码
JS/WASM muxer把 encoded chunks 写回 MP4/WebM 等容器不一定编码
ffmpeg.wasm在浏览器里跑 FFmpeg 的 WebAssembly / JS 移植版,可做转换、处理、封装等体积大、启动慢、性能和内存压力高,不能盲目替代原生能力

ffmpeg.wasm 官方说明它是 FFmpeg 的纯 WebAssembly / JavaScript 移植,可在浏览器内做音视频 record、convert、stream。(ffmpeg.wasm) 如果用到 WASM 线程或 SharedArrayBuffer,则还要注意安全上下文和 cross-origin isolation;MDN 说明共享内存需要 secure context 和 cross-origin isolated。(MDN Web Docs)

推荐选择

能用 WebCodecs 做编解码 → 优先 WebCodecs
只需要混音 / 音频分析 → Web Audio
只需要换容器、不重编码 → demuxer + muxer
浏览器 codec 不支持、任务复杂但必须纯前端 → ffmpeg.wasm
大文件、专业转码、批量任务 → 服务端 FFmpeg

一句工程判断:

WebCodecs 是“调用浏览器已有 codec 能力”;
ffmpeg.wasm 是“把一套 FFmpeg 搬进浏览器”。

前者轻、快、原生,但兼容性和 codec 支持受浏览器限制。后者能力广,但重、吃内存、启动成本高。


12. 浏览器兼容性策略

兼容性不能只判断:

if ("VideoEncoder" in window) {
  // ok
}

这是不够的。

至少要分三层判断:

1. API 是否存在
2. 目标 codec config 是否支持
3. 实际运行是否稳定

12.1 API 检测

export function hasWebCodecs() {
  return (
    "VideoEncoder" in globalThis &&
    "VideoDecoder" in globalThis &&
    "AudioEncoder" in globalThis &&
    "AudioDecoder" in globalThis
  );
}

注意:Worker 里要检测 globalThis,不要写死 window

12.2 Codec config 检测

Chrome 文档建议在 configure() 前调用 VideoEncoder.isConfigSupported(),因为不支持的 config 会导致 configure() 抛出 NotSupportedError。(Chrome for Developers)

export async function pickVideoEncoderConfig(width: number, height: number) {
  const candidates: VideoEncoderConfig[] = [
    {
      codec: "vp09.00.10.08",
      width,
      height,
      bitrate: 2_000_000,
      framerate: 30,
    },
    {
      codec: "avc1.42E01E",
      width,
      height,
      bitrate: 2_000_000,
      framerate: 30,
    },
    {
      codec: "vp8",
      width,
      height,
      bitrate: 2_000_000,
      framerate: 30,
    },
  ];

  for (const config of candidates) {
    const result = await VideoEncoder.isConfigSupported(config);
    if (result.supported) {
      return result.config;
    }
  }

  return null;
}

12.3 运行时 fallback

即使 isConfigSupported() 通过,也要处理运行时错误:

const encoder = new VideoEncoder({
  output(chunk, metadata) {
    // collect chunks
  },
  error(error) {
    console.error("VideoEncoder error:", error);
    // 标记任务失败,切 fallback,或者提示用户
  },
});

12.4 当前支持情况要动态看

截至 Can I Use 的 2026 年数据,WebCodecs 全球支持加部分支持显示为 92.74%,但 Safari 16.4–18.7 是 partial support,Firefox for Android 仍显示不支持。(Can I Use) MDN 也把 VideoEncoderVideoDecoder 等页面标记为 “Limited availability”,并提示它们在部分浏览器里不是 Baseline 特性。(MDN Web Docs)

所以产品上要按能力降级,而不是按浏览器名字硬猜。


13. Fallback 设计

13.1 推荐 fallback 矩阵

场景首选方案fallback
浏览器支持 WebCodecs + 目标 codecWebCodecs + demuxer/muxer
WebCodecs 不支持,但只是预览/抽帧<video> + Canvas只支持浏览器能播放的格式
需要录制摄像头MediaRecorderWebCodecs 自己编码
需要复杂转码,必须纯前端ffmpeg.wasm提示性能风险
大文件 / 长视频 / 专业转码服务端 FFmpeg前端只做上传和进度
移动端弱设备限制分辨率 / 限制时长服务端处理

13.2 fallback 不是失败,是产品能力分级

不要写成:

您的浏览器不支持,无法使用。

更好的产品策略:

当前浏览器不支持高性能本地转码。
你仍可以:
1. 使用基础预览功能
2. 降低输出分辨率
3. 切换到 Chrome / Edge / Safari 新版本
4. 使用服务端处理模式

14. 硬件加速:不要当成“合同”

WebCodecs 经常能利用浏览器和平台已有的原生 codec 能力,MDN 也描述它可以高效地在浏览器中编码/解码音视频,并进行低层逐帧控制。(MDN Web Docs) 但 W3C 规范并不要求实现方支持任何特定 codec 或编码方式。(W3C)

所以工程上不要假设:

支持 WebCodecs = 支持 H.264
支持 H.264 = 支持所有 profile / level
支持 codec = 一定硬件加速
硬件加速 = 一定更快

更稳的策略:

先 isConfigSupported()
再 configure()
监听 error callback
记录 encode/decode 实际耗时
根据设备能力动态降级分辨率 / 帧率 / 码率

动态降级示例

const outputProfiles = [
  { width: 1920, height: 1080, bitrate: 5_000_000 },
  { width: 1280, height: 720, bitrate: 2_500_000 },
  { width: 854, height: 480, bitrate: 1_200_000 },
];

async function pickSupportedProfile(codec: string) {
  for (const profile of outputProfiles) {
    const config: VideoEncoderConfig = {
      codec,
      width: profile.width,
      height: profile.height,
      bitrate: profile.bitrate,
      framerate: 30,
    };

    const { supported } = await VideoEncoder.isConfigSupported(config);
    if (supported) return config;
  }

  return null;
}

15. 推荐项目架构

一个浏览器端音视频处理项目,可以这样拆:

src/
  app/
    App.tsx
    components/
      FilePicker.tsx
      ProgressPanel.tsx
      PreviewCanvas.tsx
      ExportButton.tsx

  media/
    compat/
      detectWebCodecs.ts
      pickCodecConfig.ts
      fallbackPolicy.ts

    source/
      ByteSource.ts
      FileByteSource.ts
      StreamByteSource.ts

    container/
      mp4/
        Mp4Demuxer.ts
        Mp4Muxer.ts
        boxes.ts
        sampleTable.ts
      webm/
        WebmMuxer.ts

    codecs/
      VideoDecoderWrapper.ts
      VideoEncoderWrapper.ts
      AudioDecoderWrapper.ts
      AudioEncoderWrapper.ts

    pipeline/
      VideoWatermarkPipeline.ts
      AudioMixPipeline.ts
      TranscodePipeline.ts
      scheduler.ts
      backpressure.ts

    render/
      canvasWatermark.ts
      offscreenCanvas.ts
      thumbnails.ts

    audio/
      mixer.ts
      wav.ts
      analyser.ts

    workers/
      media.worker.ts
      workerMessages.ts

    memory/
      ResourceScope.ts
      closeSafely.ts

    fallback/
      ffmpegWasmRunner.ts
      serverJobClient.ts

  shared/
    types.ts
    logger.ts

15.1 模块职责

模块负责什么
compatAPI 检测、codec config 检测、fallback 决策
source文件/流的随机读取、分片读取
containerdemux / mux
codecsWebCodecs 封装
pipeline把 demux、decode、process、encode、mux 串起来
renderCanvas / OffscreenCanvas / WebGL / WebGPU 处理
audioWeb Audio、混音、WAV 导出
workers主线程与 Worker 通信
memory统一释放 VideoFrame / AudioData 等资源
fallbackffmpeg.wasm 或服务端任务

16. 一个 ResourceScope 小工具

为了避免忘记 close(),可以写一个简单的资源管理器。

type Closable = {
  close(): void;
};

export class ResourceScope {
  private resources: Closable[] = [];

  use<T extends Closable>(resource: T): T {
    this.resources.push(resource);
    return resource;
  }

  release(resource: Closable) {
    const index = this.resources.indexOf(resource);
    if (index >= 0) {
      this.resources.splice(index, 1);
    }

    resource.close();
  }

  dispose() {
    for (let i = this.resources.length - 1; i >= 0; i--) {
      try {
        this.resources[i].close();
      } catch {
        // ignore
      }
    }

    this.resources = [];
  }
}

使用:

async function processOneFrame(input: VideoFrame) {
  const scope = new ResourceScope();

  try {
    scope.use(input);

    const output = scope.use(await addWatermark(input));

    await waitForEncoderBackpressure(encoder, 4);
    encoder.encode(output);
  } finally {
    scope.dispose();
  }
}

这个工具不复杂,但面试时很好讲:

VideoFrame / AudioData 是显式生命周期资源。
我不会完全依赖 JS GC,而是用 ResourceScope 统一释放。

17. 常见误区

误区 1:WebCodecs 能直接读 MP4

不能。WebCodecs 处理的是 encoded chunk 和 raw frame,不负责 MP4 box 解析,也不负责 mux MP4。你需要 demuxer / muxer。

误区 2:file.arrayBuffer() 很方便,所以大文件也这么读

Demo 可以,项目不建议。大文件要分片、随机读取、增量解析。

误区 3:VideoFrame 交给 GC 就行

不行。VideoFrame 背后可能是 GPU / native resource,用完要 close()

误区 4:Worker 里什么都能做

Worker 不能直接操作 DOM。UI、DOM、React 状态还是在主线程,Worker 负责计算和媒体管线。

误区 5:WebCodecs 支持就等于所有 codec 都支持

不对。具体 codec、profile、level、硬件路径都要检测。规范不要求浏览器支持某个固定 codec。(W3C)

误区 6:ffmpeg.wasm 是万能解法

它能力很强,但很重。大文件、长视频、移动端、低内存设备上,ffmpeg.wasm 可能不是好选择。

误区 7:队列越满吞吐越高

队列适度可以提高吞吐,但无限堆积会爆内存。要用 encodeQueueSize / decodeQueueSize 做背压。


18. 面试可能怎么问

问题 1:为什么浏览器端音视频处理要放 Worker?

简洁回答:

音视频处理是高频、大数据、长耗时任务。逐帧解码、Canvas 处理、编码和封装如果放在主线程,会阻塞 UI,导致页面卡顿。Worker 可以把重计算放到后台线程,主线程只负责交互和展示。

深入回答:

我会把 UI、进度、预览留在主线程,把 demux、decode、frame processing、encode、mux 放到 Dedicated Worker。主线程和 Worker 之间通过消息协议通信,大块 ArrayBuffer 使用 transfer list 转移所有权,避免复制。对于 VideoFrame / AudioData,谁最终持有谁负责 close()


问题 2:postMessage() 传 ArrayBuffer 时,复制和 transfer 有什么区别?

简洁回答:

默认结构化克隆会复制数据。transfer 会把 ArrayBuffer 的底层内存所有权移动到接收方,发送方 buffer 被 detach,避免大块内存复制。

深入回答:

媒体文件可能几十 MB 到几个 GB,如果主线程和 Worker 来回复制,会有严重内存和性能问题。我会这样写:

worker.postMessage({ buffer }, [buffer]);

这样 buffer 被转移后,主线程不再使用它。


问题 3:VideoFrame 为什么要 close?

简洁回答:

因为 VideoFrame 不是普通 JS 对象,它可能引用 GPU / native media resource。用完不 close(),内存不会及时释放,可能导致页面崩溃。

深入回答:

我会在三个地方释放:decoder output frame 处理完释放;frame 送入 encoder 后释放;任务取消或异常时用 finally 或 ResourceScope 统一释放。clone 或 transfer 后,也要明确所有权。


问题 4:WebCodecs 的背压怎么处理?

简洁回答:

encodeQueueSize / decodeQueueSize,超过阈值就暂停继续喂数据,等待 dequeue 事件后再继续。

深入回答:

demuxer 是生产者,decoder / encoder 是消费者。如果生产速度超过消费速度,队列会堆积。我的做法是设置 high-water mark,例如 encoder queue 大于 4 就等待 dequeue,避免内存无限增长。


问题 5:WebCodecs 不支持怎么办?

简洁回答:

做能力检测和 fallback。能用 WebCodecs 就走高性能本地处理;不支持时,根据需求降级到 <video> + Canvas、MediaRecorder、ffmpeg.wasm 或服务端处理。

深入回答:

我不会只判断浏览器名字,而是检测 API 是否存在、isConfigSupported() 是否支持目标配置、运行时是否报错。复杂任务或大文件可以切服务端 FFmpeg;必须纯前端但 codec 不支持时,可以考虑 ffmpeg.wasm,但要提示性能和内存风险。


19. 实践任务

任务 1:把 WebCodecs Demo 改成 Worker 架构

要求:

主线程:
- 选择视频文件
- 分片读取
- transfer ArrayBuffer 给 Worker
- 显示进度

Worker:
- 接收分片
- 模拟 demux
- 输出进度

重点练习:

Worker 通信
Transferable objects
任务 ID
错误消息
取消任务

任务 2:实现一个背压调度器

写一个通用函数:

async function waitForQueue<T extends EventTarget>(
  codec: T,
  getQueueSize: () => number,
  maxQueueSize: number,
): Promise<void> {
  while (getQueueSize() >= maxQueueSize) {
    await new Promise<void>((resolve) => {
      codec.addEventListener("dequeue", () => resolve(), { once: true });
    });
  }
}

然后分别用于:

VideoDecoder.decodeQueueSize
VideoEncoder.encodeQueueSize
AudioDecoder.decodeQueueSize
AudioEncoder.encodeQueueSize

任务 3:给 VideoFrame 处理加 ResourceScope

要求:

- decoder output 里拿到 VideoFrame
- Canvas 加水印生成新 VideoFrame
- encode 新 frame
- finally 中释放所有 frame
- 故意抛异常,确认也能释放

任务 4:实现 FileByteSource

要求:

- 不允许直接 file.arrayBuffer()
- 用 file.slice(offset, offset + length)
- 实现 read(offset, length)
- 写一个函数读取 MP4 顶层 box header

任务 5:做一个兼容性检测面板

页面展示:

WebCodecs 是否可用
VideoEncoder 是否可用
VideoDecoder 是否可用
AudioEncoder 是否可用
AudioDecoder 是否可用
目标 video codec 是否支持
目标 audio codec 是否支持
是否 secure context
是否 crossOriginIsolated
fallback 将走哪条路径

20. 自测题

题 1:为什么不能把所有音视频处理都放主线程?

答案: 因为音视频处理是大数据、高频、长耗时任务,会阻塞 UI 渲染和用户交互。主线程应该负责 UI,重处理应放到 Worker。


题 2:postMessage({ buffer })postMessage({ buffer }, [buffer]) 有什么区别?

答案: 前者默认结构化克隆,可能复制数据;后者 transfer ArrayBuffer 所有权,避免复制。transfer 后发送方的 buffer 会被 detach,不能继续使用。


题 3:VideoFrame.close() 应该什么时候调用?

答案: 当这个 frame 不再需要时调用。常见时机包括:绘制完成后、送入 encoder 后、处理失败的 finally 中、任务取消时。


题 4:encodeQueueSize 表示什么?

答案: 表示当前 VideoEncoder 中等待处理的 encode 请求数量。它不是最终输出 chunk 数,也不是 GPU 使用率,但可以用来判断编码队列是否堆积。


题 5:为什么 WebCodecs 可用不代表 H.264 一定可编码?

答案: 因为 WebCodecs 规范不要求浏览器支持特定 codec。具体 codec、profile、level、硬件能力都要通过 isConfigSupported() 和实际运行检测。


题 6:ffmpeg.wasm 和 WebCodecs 的核心区别是什么?

答案: WebCodecs 是调用浏览器已有的原生 codec 能力;ffmpeg.wasm 是把 FFmpeg 编译到 WebAssembly 里运行。WebCodecs 通常更轻,但受浏览器 codec 支持限制;ffmpeg.wasm 能力广,但更重、更吃内存。


题 7:大文件为什么不建议直接 file.arrayBuffer()

答案: 因为会把整个文件一次性加载进内存,容易造成内存暴涨、GC 卡顿甚至页面崩溃。更好的方式是用 File.slice() 做分片读取或随机读取。


题 8:Worker 能直接操作 DOM 吗?

答案: 不能。Worker 没有 window DOM 环境。Worker 负责计算,DOM 更新要通过消息发回主线程处理。


21. 本章总结

这一章的核心不是“多学几个 API”,而是建立工程化意识:

主线程只做 UI
Worker 做重活
大数据用 transfer
大文件分片读
VideoFrame / AudioData 用完 close
encode/decode 队列要做背压
codec 支持要运行时检测
不支持时要有 fallback

你现在应该能把前面章节的知识组织成一个项目级方案:

用户上传媒体

主线程创建任务

文件分片 transfer 到 Worker

Worker demux

WebCodecs decode

VideoFrame / AudioData 处理

WebCodecs encode

mux 输出

transfer 结果回主线程

UI 下载 / 预览

面试时可以这样总结:

浏览器端音视频项目的难点不只是 API 会不会调,而是管线调度、内存生命周期、线程拆分、背压控制和兼容性 fallback。我的设计会把 UI 和媒体处理拆开,用 Worker 承担 demux/decode/process/encode/mux,大块数据用 transferable,VideoFrame / AudioData 显式释放,WebCodecs 支持和 codec config 都做运行时检测,不支持时降级到 Canvas、MediaRecorder、ffmpeg.wasm 或服务端处理。


22. 下一章衔接

下一章进入综合项目:

Chapter 13:浏览器端 MP4 Inspector

前面第 3 章学了 MP4 box,第 11 章学了时间戳,第 12 章学了工程化。 第 13 章会把它们合起来,做一个真正可以展示的项目:

上传 MP4

分片读取

解析 box tree

展示 ftyp / moov / trak / stbl

展示 track、duration、timescale、codec 信息

这个项目是你面试时非常好讲的作品:它能证明你不只是“听说过 MP4”,而是真的理解浏览器端如何读取、解析和展示媒体文件结构。