视频混剪 -WebCodecs 导出视频
整体流程
先把导出流程画出来:
逐帧渲染 → 创建 VideoFrame → 编码 → 封装成 MP4 → 下载。这个流程跑一遍,就能把时间轴上的内容变成一个可分享的视频文件。
视频编码基础知识
在讲 WebCodecs 之前,先补一些视频编码的背景知识。
为什么视频需要压缩
一帧 1080p 画面有多大?
介绍使用 WebCodecs API 在浏览器端进行视频混剪导出的技术方案。对比了 WebCodecs 与 FFmpeg.wasm 的性能差异,选用 WebCodecs 以获得硬件加速支持。详细讲解了 VideoFrame、VideoEncoder 等核心 API 的使用,以及通过 mp4-muxer 封装 H.264 数据为 MP4 文件的过程。重点强调了 VideoFrame 内存管理与队列控制的重要性,实测 M1 Mac 上可实现 6 倍实时速度的导出效率。
先把导出流程画出来:
逐帧渲染 → 创建 VideoFrame → 编码 → 封装成 MP4 → 下载。这个流程跑一遍,就能把时间轴上的内容变成一个可分享的视频文件。
在讲 WebCodecs 之前,先补一些视频编码的背景知识。
一帧 1080p 画面有多大?
1920 × 1080 × 3 bytes (RGB) = 6.2 MB
一秒 30 帧:6.2 × 30 = 186 MB/s
一分钟视频:186 × 60 = 11.2 GB
没有压缩的话,一分钟视频就要 10GB,根本没法传播。
视频压缩利用两种冗余:
1. 空间冗余(Spatial Redundancy)
一帧画面里,相邻像素往往颜色相近。比如蓝天部分,几千个像素都是差不多的蓝色。
不需要存每个像素的颜色,可以存"这一块区域都是这个颜色"。
2. 时间冗余(Temporal Redundancy)
相邻帧之间差异很小。比如一个人讲话,背景完全没动。
不需要存完整的每一帧,可以存"相对于上一帧,哪里变了"。
视频编码把帧分成三类:
| 类型 | 全称 | 作用 |
|---|---|---|
| I 帧 | Intra-coded | 完整画面,不依赖其他帧 |
| P 帧 | Predictive | 存储与前一帧的差异 |
| B 帧 | Bidirectional | 存储与前后两帧的差异 |
典型的帧序列:I P B B P B B P B B I P B B ...
│ │ └──────── GOP ───────────────┘ (一组画面)
I 帧最大,但可以独立解码(用于快进拖拽)。 B 帧最小,但解码时需要前后帧。
H.264 是目前最通用的视频编码标准,几乎所有设备都支持。
它的压缩率非常高:原本 10GB 的视频可以压到几百 MB,肉眼几乎看不出画质损失。
FFmpeg 是视频处理领域的"神器",FFmpeg.wasm 是它的 WebAssembly 移植版。
优点:
缺点:
WebCodecs 是 W3C 标准,Chrome 2020 年开始支持。
优点:
缺点:
// 编码速度对比
// FFmpeg.wasm: 10 fps → 导出 1 分钟视频需要 3 分钟
// WebCodecs: 100 fps → 导出 1 分钟视频需要 18 秒
性能差 10 倍。用户等不起 3 分钟,所以我选了 WebCodecs。
Safari 用户暂时无法导出,但考虑到主流用户在 Chrome 上,可以接受。
VideoFrame 代表一帧原始画面。
// 从 Canvas 创建 VideoFrame
const frame = new VideoFrame(canvas, {
timestamp: time * 1_000_000 // 微秒
});
// 属性
frame.timestamp // 时间戳
frame.codedWidth // 宽度
frame.codedHeight // 高度
frame.duration // 持续时间
// 用完必须关闭!
frame.close();
VideoEncoder 负责把 VideoFrame 压缩成 H.264 数据。
const encoder = new VideoEncoder({
output: (chunk, metadata) => {
// chunk 是压缩后的数据
// metadata 包含 decoderConfig 等信息
},
error: (e) => {
console.error('Encode error:', e);
}
});
// 配置编码参数
encoder.configure({
codec: 'avc1.42001f', // H.264 Baseline Profile
width: 1920,
height: 1080,
bitrate: 8_000_000, // 8 Mbps
framerate: 30
});
// 编码一帧
encoder.encode(frame, { keyFrame: true });
// 等待所有帧编码完成
await encoder.flush();
EncodedVideoChunk 是编码器输出的压缩数据。
interface EncodedVideoChunk {
type: 'key' | 'delta'; // I 帧还是 P/B 帧
timestamp: number; // 时间戳(微秒)
duration: number; // 持续时间
data: ArrayBuffer; // 压缩后的 H.264 数据
}
async function exportVideo() {
const chunks: EncodedVideoChunk[] = [];
// 1. 创建编码器
const encoder = new VideoEncoder({
output: (chunk) => { chunks.push(chunk); },
error: (e) => console.error(e)
});
// 2. 配置编码参数
encoder.configure({
codec: 'avc1.42001f',
width: 1920,
height: 1080,
bitrate: 8_000_000,
framerate: 30
});
// 3. 逐帧循环
const totalFrames = Math.ceil(duration * 30);
for (let i = 0; i < totalFrames; i++) {
const time = i / 30;
// a. 用 WebGL 渲染这一帧
await webglRenderer.render(time);
// b. 从 Canvas 创建 VideoFrame
const frame = new VideoFrame(canvas, {
timestamp: time * 1_000_000 // 微秒
});
// c. 编码(每 30 帧一个关键帧)
encoder.encode(frame, { keyFrame: i % 30 === 0 });
// d. 立即释放内存!!!
frame.close();
// e. 控制编码队列,防止内存爆炸
if (encoder.encodeQueueSize > 5) {
await new Promise(r => setTimeout(r, 1));
}
// f. 更新进度
onProgress(i / totalFrames);
}
// 4. 等待编码完成
await encoder.flush();
// 5. 封装成 MP4
const mp4Blob = await muxToMp4(chunks);
// 6. 触发下载
downloadBlob(mp4Blob, 'output.mp4');
}
WebCodecs 只管编码,输出的是裸的 H.264 数据。需要封装成 MP4 容器才能被播放器识别。
我用的是 mp4-muxer 这个库:
import { Muxer, ArrayBufferTarget } from 'mp4-muxer';
async function muxToMp4(chunks: EncodedVideoChunk[]): Promise<Blob> {
const muxer = new Muxer({
target: new ArrayBufferTarget(),
video: {
codec: 'avc',
width: 1920,
height: 1080
},
fastStart: 'in-memory' // 把 moov 放在文件开头,支持边下边播
});
for (const chunk of chunks) {
muxer.addVideoChunk(chunk);
}
muxer.finalize();
return new Blob([muxer.target.buffer], { type: 'video/mp4' });
}
VideoFrame 占用的内存很大:
1080p 一帧 = 1920 × 1080 × 4 bytes (RGBA) ≈ 8 MB
如果不及时 close():
100 帧 = 800 MB
1000 帧 = 8 GB
浏览器崩溃
// 用完立即关闭
const frame = new VideoFrame(canvas, { timestamp });
encoder.encode(frame);
frame.close(); // ← 必须的!
// 忘了 close(),内存泄漏
const frame = new VideoFrame(canvas, { timestamp });
encoder.encode(frame);
// 缺少 frame.close()
编码器内部有一个队列。如果渲染太快,队列会积压,内存暴涨。
// 等待队列消化
if (encoder.encodeQueueSize > 5) {
await new Promise(r => setTimeout(r, 1));
}
更高级的优化是用 OffscreenCanvas 在 Worker 里渲染,主线程只管编码。
这个项目没做这个优化,留给以后。
在 M1 MacBook Pro 上:
| 时长 | 导出耗时 | 速度 |
|---|---|---|
| 30s | ~5s | 6x 实时 |
| 1min | ~10s | 6x 实时 |
| 5min | ~50s | 6x 实时 |
基本是实时速度的 6 倍,还算可接受。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online