WebCodecs + Web Workers + OPFS:在浏览器里搞视频处理的实战路径
浏览器里做视频处理,FFmpeg WASM 是默认选项,没毛病。但它有 25MB 的下载体积,默认单线程跑,还是个黑盒。某些工作流换成新一代浏览器原生 API——WebCodecs、Web Workers、OPFS——能做出更快、更小、更可调试的东西。
这篇文章就是一个能跑的实例:解码视频文件、按指定时间戳抽帧、写到本地存储,全程不碰 FFmpeg。文末有实测数据。
三个 API 各管一摊
WebCodecs(VideoDecoder、VideoEncoder、AudioDecoder、AudioEncoder)——硬件加速的编解码器接口。绕开了 <video> 那套"播放再截屏"的路子。你喂给它一个 EncodedVideoChunk(比如一段裸的 H.264 NAL unit),它吐回 VideoFrame 对象,能往 canvas 渲染、能转码、能往下游塞。
Web Workers——独立线程处理 CPU 密集活。编解码器解析、帧变换、封装逻辑全应该塞这儿,主线程才不会卡。Worker 里也能直接用 OPFS。
OPFS(Origin Private File System)——一个真正高吞吐、给网页用的文件系统。localStorage 是同步的还卡 5MB 上限,IndexedDB 异步但二进制 blob 慢得离谱,OPFS 就是为文件而生的。Worker 里能用 createSyncAccessHandle() 拿同步访问句柄,这点对性能是命门。
要搭的架构
我搭这么个东西:用户往页面拖一个视频文件,按每秒一帧抽出来,存成 JPEG 写到 OPFS,最后给个下载链接。真实场景就是给视频库生成缩略图。
主线程:
- 文件输入
- 创建 Worker
- UI 更新(进度、结果)
Worker:
- 通过 OPFS 路径拿文件(不走 postMessage——太慢)
- 用 WebCodecs VideoDecoder 解码
- Canvas 渲染 + toBlob 转 JPEG
- 把 JPEG 写进 OPFS
- 把进度回传给主线程
"通过 OPFS 路径拿文件"这点很关键。用 postMessage 传一个 500MB 的文件(哪怕挂成 Transferable)又慢又拷内存。先把文件写到 OPFS,再把路径丢给 Worker,快得多。
第一步:能力检测
function checkSupport() {
const issues = []
if (!('VideoDecoder' in window)) {
issues.push('WebCodecs VideoDecoder 不支持。需要 Chrome 94+ 或同等浏览器。')
}
if (!window.showOpenFilePicker && !window.FileSystemHandle) {
issues.push('File System Access API 不支持。')
}
if (!navigator.storage.getDirectory) {
issues.push('OPFS 不支持。需要 Chrome 102+ 或同等浏览器。')
}
// Worker 支持基本是标配,但要查 Worker 里能否用 OPFS 同步访问
// (老版 Chrome 有 Worker,但 Worker 里没同步 OPFS)
return issues
}
const issues = checkSupport()
if (issues.length > 0) {
console.warn('功能缺口:', issues)
// 回退到 FFmpeg WASM
}
2026 年的浏览器支持情况:Chrome/Edge 102+、Safari 16.4+(WebCodecs 部分支持)、Firefox 130+(某些版本还在 flag 后面)。生产环境一律做能力检测,撑不住就回退 FFmpeg WASM。
第二步:把文件写进 OPFS
async function stageFileToOpfs(file) {
const root = await navigator.storage.getDirectory()
const dir = await root.getDirectoryHandle('input', { create: true })
const fileHandle = await dir.getFileHandle(file.name, { create: true })
const writable = await fileHandle.createWritable()
await file.stream().pipeTo(writable)
return `${file.name}` // OPFS 根目录下 'input' 文件夹里的路径
}
这里用的是异步 createWritable()。Worker 里可以改用同步的 createSyncAccessHandle(),小文件多次写入更快。
第三步:Worker
// worker.js
let decoder = null
let canvas = null
let ctx = null
self.onmessage = async (e) => {
const { type, data } = e.data
if (type === 'init') {
await init(data)
} else if (type === 'extract') {
await extractFrames(data)
}
}
async function init({ codec, canvasWidth, canvasHeight }) {
// 准备 canvas(Worker 里用 OffscreenCanvas)
canvas = new OffscreenCanvas(canvasWidth, canvasHeight)
ctx = canvas.getContext('2d')
// WebCodecs 解码器
decoder = new VideoDecoder({
output: (frame) => handleFrame(frame),
error: (e) => console.error('解码器错误:', e)
})
decoder.configure({
codec, // 比如 'avc1.640028'
optimizeForLatency: false
})
self.postMessage({ type: 'ready' })
}
let frameCount = 0
let lastExtractedSecond = -1
async function handleFrame(frame) {
const timestampSeconds = frame.timestamp / 1_000_000 // WebCodecs 用微秒
// 每秒只抽一帧
const currentSecond = Math.floor(timestampSeconds)
if (currentSecond !== lastExtractedSecond) {
lastExtractedSecond = currentSecond
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height)
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 })
const buffer = await blob.arrayBuffer()
// 写进 OPFS
const root = await navigator.storage.getDirectory()
const outputDir = await root.getDirectoryHandle('thumbnails', { create: true })
const fileHandle = await outputDir.getFileHandle(`thumb_${String(currentSecond).padStart(6, '0')}.jpg`, { create: true })
const syncHandle = await fileHandle.createSyncAccessHandle()
syncHandle.write(new Uint8Array(buffer))
syncHandle.close()
frameCount++
self.postMessage({ type: 'progress', frameCount, second: currentSecond })
}
frame.close() // 重要:VideoFrame 必须关,不然泄漏显存
}
async function extractFrames({ chunks }) {
for (const chunk of chunks) {
decoder.decode(new EncodedVideoChunk({
type: chunk.keyframe ? 'key' : 'delta',
timestamp: chunk.timestamp,
data: chunk.data
}))
}
await decoder.flush()
self.postMessage({ type: 'done', frameCount })
}
几个容易踩的坑:
frame.close()是强制的。 VideoFrame 持着 GPU 缓冲的引用。忘了关,显存一直漏,漏到 100 帧左右解码器静默失败。必须try/finally。- Worker 里的 OffscreenCanvas。 这玩意才能让你在 Worker 里渲染。Chrome 69+、Safari 16.4+、Firefox 105+ 都有。
createSyncAccessHandle()会阻塞其他 OPFS 操作。 别开着它跨异步操作——写完、关掉、走人。
第四步:喂数码器的 chunk 从哪来
这是最烦的一步。WebCodecs 不做解封装,它要的是裸 codec chunk。你还是得自己解析 MP4 容器,把 H.264 NAL unit 抽出来。
两条路:
- 用
mp4box.js(npm)——纯 JS 的 MP4 解析器,吐出来的 sample 直接喂给 WebCodecs。 - 只用 FFmpeg WASM 做解封装——抽出 chunk,再交给 WebCodecs 解码。听着浪费,但 FFmpeg WASM 解封装比"解码+渲染"快得多,其实划算。
方案一用 mp4box.js 的代码:
import mp4box from 'mp4box'
async function getCodecChunks(file) {
const arrayBuffer = await file.arrayBuffer()
arrayBuffer.fileStart = 0 // mp4box.js 的约定
const mp4boxFile = mp4box.createFile()
mp4boxFile.onReady = (info) => {
mp4boxFile.setExtractionOptions(info.videoTracks[0].id, null, {
nbSamples: 100 // 批大小
})
mp4boxFile.start()
}
const chunks = []
mp4boxFile.onSamples = (trackId, ref, samples) => {
for (const sample of samples) {
chunks.push({
keyframe: sample.is_sync,
timestamp: sample.cts * 1_000_000 / sample.timescale,
data: sample.data
})
}
}
mp4boxFile.appendBuffer(arrayBuffer)
mp4boxFile.flush()
return {
codec: mp4boxFile.getInfo().videoTracks[0].codec,
chunks
}
}
HLS/DASH 流的话,这一步完全可以跳过——你本来就是去抓 segment 的,手里就有裸 chunk。FlowPick 内部某些路径就是这么干的。
实测:WebCodecs vs FFmpeg WASM vs 原生
测试场景:从 30 分钟 1080p H.264 视频里每秒抽一帧,共 1800 帧。
| 方案 | 实际耗时 | 内存峰值 | 下载体积 |
|---|---|---|---|
| 原生 FFmpeg(命令行) | 18s | 80MB | 无(已安装) |
| FFmpeg WASM(单线程) | 145s | 220MB | 25MB |
| FFmpeg WASM(多线程) | 52s | 280MB | 25MB |
| WebCodecs + Worker + OPFS | 31s | 95MB | <100KB |
WebCodecs 赢在哪:
- 硬件加速——H.264 解码真在 GPU 上跑,不是 WASM 里
- 没有 25MB 的 WASM 大文件
- 内存占用低(没有 FFmpeg 内部那些缓冲)
- Worker 是真多线程(FFmpeg WASM 的 pthreads 在改进,但还是有开销)
FFmpeg WASM 赢在哪:
- 什么 codec 都能吃(WebCodecs 只支持浏览器支持的,一般是 H.264、H.265、VP8/VP9、AV1)
- 能做封装/解封装(WebCodecs 只管编解码)
- 边界情况扛得住
- 同一套代码到处跑(不用做能力检测)
我的看法: codec 已知、要性能,用 WebCodecs;要广度,用 FFmpeg WASM。FlowPick 两个都用——通用重封装路径走 FFmpeg WASM,特定操作(比如缩略图生成、输入确定是 H.264 且要快)走 WebCodecs。
OPFS 性能的玄学
外头有个说法:"OPFS 跟原生磁盘一样快。"假的。它比 IndexedDB 和 localStorage 快,但还是有开销:
| 操作 | OPFS(Worker 同步) | IndexedDB | 原生磁盘 |
|---|---|---|---|
| 1KB 写 | 0.05ms | 1.2ms | 0.01ms |
| 1MB 写 | 1.1ms | 8.5ms | 0.4ms |
| 100MB 写 | 95ms | 850ms | 35ms |
| 1MB 读 | 0.8ms | 4.5ms | 0.2ms |
OPFS 比原生磁盘慢 3-5 倍,但比 IndexedDB 处理二进制快 5-10 倍。视频处理工作流里"够用"——瓶颈在编解码,不在 I/O。
几个真正的红利:
- Worker 里的
createSyncAccessHandle()——同步 I/O 意味着每次写没有 await 开销 - 文件能比内存大——5GB 文件能流式过 OPFS,不用一次性全装进内存
- 跨会话持久——处理完的视频刷新页面后还在
Worker 线程池做并行解码
要批量处理多个视频(比如给整个目录做缩略图),就要上线程池:
class WorkerPool {
constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
this.workers = Array.from({ length: size }, () => new Worker(workerUrl))
this.queue = []
this.busy = new Set()
}
async run(task) {
const worker = await this.getIdle()
this.busy.add(worker)
return new Promise((resolve, reject) => {
worker.onmessage = (e) => {
this.busy.delete(worker)
if (e.data.type === 'done') resolve(e.data.result)
else if (e.data.type === 'error') reject(e.data.error)
}
worker.postMessage(task)
})
}
async getIdle() {
if (this.busy.size < this.workers.length) {
return this.workers.find(w => !this.busy.has(w))
}
// 等一个 Worker 空出来
return new Promise(resolve => {
this.queue.push(resolve)
})
}
}
8 核机器配 8 个 Worker,可以并行处理 8 个文件——每个独占一个线程,每个都是硬件加速解码。我这台 M2 MacBook 实测:单核 240 帧/秒,整体约 1900 帧/秒。相当于每秒处理 30 分钟视频。
但有个坑:硬件解码器并发有限。多数 GPU 同时只能扛 2-4 路解码。超了就降级到软解,反而慢。要在目标硬件上测。
真实踩过的坑
坑 1:忘 frame.close()。 没关的 VideoFrame 漏显存。漏 100 帧左右解码就静默失败。用 try/finally:
try {
// ... 用 frame 干活 ...
} finally {
frame.close()
}
坑 2:解码顺序 ≠ 显示顺序。 B 帧的 H.264 按 DTS 顺序解码、按 PTS 顺序显示。按解码顺序处理帧,缩略图就乱序。WebCodecs 两个都给你——frame.timestamp 是 PTS,解码按 DTS 走。
坑 3:codec 字符串格式。 WebCodecs 要 'avc1.640028'(带 constraint 字节)。MP4 有时存 'avc1.640028',有时存 'avc1.64.0028',有时存 'avc1.42E01E'。配置解码器之前要归一化。
坑 4:Worker 模块加载。 Worker 不是所有浏览器都能从任意 URL import。要么用 importScripts(),要么 Worker 构造函数里设 "type": "module"(Chrome 80+、Safari 15+)。
坑 5:OPFS 配额。 浏览器把 OPFS 限制在剩余磁盘空间的一个百分比里。5GB 写入可能因为磁盘满了失败。catch QuotaExceededError,回退到 showSaveFilePicker() 流式写。
什么时候用什么
用 FFmpeg WASM 的场景:
- 要兼容所有 codec(HEVC、VP9、AV1、MPEG-TS、MKV、FLV)
- 要做容器转换(TS → MP4、MP4 → WebM)
- 要滤镜链(叠加、缩放、裁剪、拼接)
- 可靠性比速度重要
用 WebCodecs 的场景:
- 已知 codec 是 H.264/H.265/VP9/AV1
- 做逐帧操作(缩略图、运动检测、抽帧)
- 要实时性能(直播视频过滤、逐帧分析)
- 内存预算紧(不想下 25MB 的 WASM)
用 Web Workers 的场景:
- 任何 CPU 重活、不能堵主线程
- 并行处理独立单元(文件、分片)
用 OPFS 的场景:
- 大于 5MB(localStorage 的上限)
- 持久化中间状态(不想刷新页面就重新解码一遍)
- 流式处理大文件,不想全装进内存
参考资料
- WebCodecs 规范 — 真正的规范,意外地好读
- MDN WebCodecs API — 实用文档,有例子
- WebCodecs samples 仓库 — 官方能跑的例子
- OPFS 规范 — File System Access API,含 OPFS
- mp4box.js — GPAC 的 JS MP4 解析器,WebCodecs 输入的事实标准
小结
WebCodecs + Workers + OPFS 是性能敏感场景下 FFmpeg WASM 的真替代。不是全面替代——FFmpeg WASM 在 codec 广度和边界情况上还是赢——但已知 codec、硬件加速、帧级处理的工作流,WebCodecs 快 4-5 倍、小 250 倍。下一篇是 流媒体下载到底合不合法,讲你什么时候被允许用这些工具。
完整的 codec、分辨率、文件大小下的基准数据,看 浏览器视频处理性能基准。
推荐阅读
- 浏览器端视频重封装:fMP4、ISOBMFF 和不转码的理由 — 这篇讲 codec 层,那篇讲容器层,互为补充
- 浏览器视频处理性能基准 — 本文数据的完整版
- FlowPick 是怎么在浏览器里把几百个视频切片合成 MP4 的 — FlowPick 哪里用 FFmpeg WASM、哪里用 WebCodecs
- DASH 深度:SegmentTemplate、ContentProtection 与多视角 MPD — 你会喂给 WebCodecs 的那些 chunk 从哪来
- HLS 深度:加密、多音轨、值得记住的 EXT-X 标签 — 分片的源头
- 三个月肝出 FlowPick 的完整历程 — 开发故事,包括 WebCodecs vs FFmpeg WASM 的决策