第十二章|浏览器音视频工程化:性能、内存、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. 本章学习目标
学完这一章,你要能做到:
- 解释为什么浏览器端音视频处理容易卡主线程。
- 设计一个主线程 + Worker 的音视频处理架构。
- 正确使用
ArrayBuffertransfer,避免大块数据来回复制。 - 正确释放
VideoFrame/AudioData,避免 GPU / native memory 泄漏。 - 用
encodeQueueSize/decodeQueueSize理解 WebCodecs 背压。 - 设计大文件分片读取与增量解析策略。
- 说明 WebCodecs、Web Audio、WASM demuxer/muxer、ffmpeg.wasm 的边界。
- 制定浏览器兼容性与 fallback 策略。
- 给面试官讲清楚一个浏览器音视频项目的工程化方案。
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 如果 moov 在 mdat 后面,你可能要先找到并解析 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 规范说明 AudioData 和 VideoFrame 都是 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 也把 VideoEncoder、VideoDecoder 等页面标记为 “Limited availability”,并提示它们在部分浏览器里不是 Baseline 特性。(MDN Web Docs)
所以产品上要按能力降级,而不是按浏览器名字硬猜。
13. Fallback 设计
13.1 推荐 fallback 矩阵
| 场景 | 首选方案 | fallback |
|---|---|---|
| 浏览器支持 WebCodecs + 目标 codec | WebCodecs + demuxer/muxer | 无 |
| WebCodecs 不支持,但只是预览/抽帧 | <video> + Canvas | 只支持浏览器能播放的格式 |
| 需要录制摄像头 | MediaRecorder | WebCodecs 自己编码 |
| 需要复杂转码,必须纯前端 | 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 模块职责
| 模块 | 负责什么 |
|---|---|
compat | API 检测、codec config 检测、fallback 决策 |
source | 文件/流的随机读取、分片读取 |
container | demux / mux |
codecs | WebCodecs 封装 |
pipeline | 把 demux、decode、process、encode、mux 串起来 |
render | Canvas / OffscreenCanvas / WebGL / WebGPU 处理 |
audio | Web Audio、混音、WAV 导出 |
workers | 主线程与 Worker 通信 |
memory | 统一释放 VideoFrame / AudioData 等资源 |
fallback | ffmpeg.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”,而是真的理解浏览器端如何读取、解析和展示媒体文件结构。