WebCodecs + Web Workers + OPFS:ブラウザで動画処理をする実戦パス
ブラウザでの動画処理といえば FFmpeg WASM がデフォルト。問題ない。でも 25MB のダウンロードサイズが必要だし、デフォルトはシングルスレッドだし、ブラックボックス。ワークロードによっては、新しいブラウザネイティブ API —— WebCodecs、Web Workers、OPFS —— の方が速くて小さくてデバッグしやすいものを作れる。
この記事は実際に動くもの:動画ファイルをデコードして、指定したタイムスタンプでフレームを抽出して、ローカルストレージに書き出す。FFmpeg に触れない。最後に実測データを載せる。
3 つの 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() を使えば同期アクセスが取れる。これがパフォーマンスの命。
搭載するアーキテクチャ
作ったもの:ユーザーが動画ファイルをページにドラッグし、毎秒 1 フレームで JPEG として抽出して OPFS に書き込み、最後にダウンロードリンクを出す。実際のシナリオは動画ライブラリのサムネイル生成。
メインスレッド:
- ファイル入力
- Worker を立てる
- UI 更新(進捗、結果)
Worker:
- OPFS パスからファイルを取得(postMessage 経由じゃない——遅すぎる)
- WebCodecs VideoDecoder でデコード
- Canvas レンダリング + toBlob で JPEG 化
- JPEG を OPFS に書き込む
- 進捗をメインスレッドに報告
「OPFS パスから取得」がポイント。postMessage で 500MB のファイルを転送(Transferable にしても)は遅くてメモリコピーが走る。ファイルを先に OPFS に書いて、パスを Worker に渡す方がずっと速い。
ステップ 1:機能検出
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 をサポートしていても同期 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 にフォールバック。
ステップ 2:ファイルを 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() を使えば、小さな書き込みが多い場合は速い。
ステップ 3: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 はマイクロ秒
// 毎秒 1 フレームだけ抽出
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 は必ず閉じる。さもないと GPU メモリがリークする
}
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 バッファへの参照を保持する。閉じ忘れると VRAM がリークし続け、100 フレームくらいでデコーダが黙って失敗し始める。try/finallyで必ず。- Worker 内の OffscreenCanvas。 Worker 内でレンダリングするにはこれが必要。Chrome 69+、Safari 16.4+、Firefox 105+ で利用可能。
createSyncAccessHandle()は他の OPFS 操作をブロックする。 開いたまま非同期操作をまたがないこと——書き終わったら閉じて、次へ。
ステップ 4:デコーダに食わせる chunk をどこから手に入れるか
ここが一番面倒。WebCodecs はデマルチプレクスしない。生の codec chunk を欲しがる。MP4 コンテナを自分で解析して、H.264 NAL unit を抽出する必要がある。
2 つの道:
mp4box.jsを使う(npm)——純 JS の MP4 パーサー。サンプルを取り出してそのまま WebCodecs に渡せる。- FFmpeg WASM をデマルチプレクスだけに使う——chunk を抽出して、WebCodecs に渡してデコード。無駄に聞こえるが、FFmpeg WASM は「デコード+レンダリング」よりデマルチプレクスの方がずっと速いので、実は理にかなっている。
パターン 1 の 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 ストリームの場合、このステップは完全にスキップできる——セグメントをフェッチしているので、手元に生 chunk がある。FlowPick は内部的に一部のパスでこうしている。
実測:WebCodecs vs FFmpeg WASM vs ネイティブ
テストシナリオ:30 分の 1080p H.264 動画から毎秒 1 フレーム、計 1800 フレームを抽出。
| 手段 | 実測時間 | ピークメモリ | ダウンロードサイズ |
|---|---|---|---|
| ネイティブ FFmpeg(CLI) | 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 は VRAM をリークする。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 の書き込みがディスク一杯で失敗することがある。QuotaExceededError を catch して、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 はどうやってブラウザだけで数百の動画セグメントを 1 つの MP4 にまとめるのか——FlowPick がどこで FFmpeg WASM を使い、どこで WebCodecs を使うか
- DASH ディープ:SegmentTemplate、ContentProtection、視点切り替え MPD——WebCodecs に渡す chunk がどこから来るか
- HLS ディープ:暗号化、マルチトラック、覚えておくべき EXT-X タグ——セグメントの供給元
- 3 カ月で FlowPick を作った全過程——開発ストーリー。WebCodecs vs FFmpeg WASM の決断も収録