tips

WebCodecs + Web Workers + OPFS:ブラウザで動画処理をする実戦パス

WebCodecs でハードウェアデコード、Web Workers でマルチスレッド、OPFS でローカルファイル I/O を高スループット化——FFmpeg WASM との実測比較付き。
FlowPick チーム
15 分で読了
# webcodecs # web workers # opfs # パフォーマンス # wasm # ディープ

ブラウザでの動画処理といえば FFmpeg WASM がデフォルト。問題ない。でも 25MB のダウンロードサイズが必要だし、デフォルトはシングルスレッドだし、ブラックボックス。ワークロードによっては、新しいブラウザネイティブ API —— WebCodecs、Web Workers、OPFS —— の方が速くて小さくてデバッグしやすいものを作れる。

この記事は実際に動くもの:動画ファイルをデコードして、指定したタイムスタンプでフレームを抽出して、ローカルストレージに書き出す。FFmpeg に触れない。最後に実測データを載せる。

3 つの API がそれぞれ担う役割

WebCodecsVideoDecoderVideoEncoderAudioDecoderAudioEncoder)——ハードウェアアクセラレーションされたコーデックインターフェース。<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 })
}

いくつかのハマりどころ:

  1. frame.close() は必須。 VideoFrame は GPU バッファへの参照を保持する。閉じ忘れると VRAM がリークし続け、100 フレームくらいでデコーダが黙って失敗し始める。try/finally で必ず。
  2. Worker 内の OffscreenCanvas。 Worker 内でレンダリングするにはこれが必要。Chrome 69+、Safari 16.4+、Firefox 105+ で利用可能。
  3. createSyncAccessHandle() は他の OPFS 操作をブロックする。 開いたまま非同期操作をまたがないこと——書き終わったら閉じて、次へ。

ステップ 4:デコーダに食わせる chunk をどこから手に入れるか

ここが一番面倒。WebCodecs はデマルチプレクスしない。生の codec chunk を欲しがる。MP4 コンテナを自分で解析して、H.264 NAL unit を抽出する必要がある。

2 つの道:

  1. mp4box.js を使うnpm)——純 JS の MP4 パーサー。サンプルを取り出してそのまま WebCodecs に渡せる。
  2. 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)18s80MBなし(インストール済み)
FFmpeg WASM(シングルスレッド)145s220MB25MB
FFmpeg WASM(マルチスレッド)52s280MB25MB
WebCodecs + Worker + OPFS31s95MB<100KB

WebCodecs の勝因:

  1. ハードウェアアクセラレーション——H.264 デコードが本当に GPU で走る。WASM ではなく
  2. 25MB の WASM バイナリがない
  3. メモリ使用量が低い(FFmpeg の内部バッファがない)
  4. Worker は真のマルチスレッド(FFmpeg WASM の pthreads は改善されたが、まだオーバーヘッドがある)

FFmpeg WASM の勝因:

  1. どんな codec でも食える(WebCodecs はブラウザがサポートするものだけ——大抵は H.264、H.265、VP8/VP9、AV1)
  2. コンテナ化/デマルチプレクスができる(WebCodecs はコーデックだけ)
  3. エッジケースに強い
  4. 同じコードがどこでも走る(機能検出不要)

私の見解: codec が分かっていてパフォーマンスが要るなら WebCodecs。広さが要るなら FFmpeg WASM。FlowPick は両方使う——汎用リマックスパスは FFmpeg WASM、特定操作(サムネイル生成、入力が H.264 と分かっていて高速化したい場合など)は WebCodecs。

OPFS パフォーマンスの妙

「OPFS はネイティブディスク並みに速い」という説がある。嘘。IndexedDB や localStorage よりは速いが、オーバーヘッドはある:

操作OPFS(Worker 同期)IndexedDBネイティブディスク
1KB 書き込み0.05ms1.2ms0.01ms
1MB 書き込み1.1ms8.5ms0.4ms
100MB 書き込み95ms850ms35ms
1MB 読み込み0.8ms4.5ms0.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 + Workers + OPFS はパフォーマンス重視のシナリオで FFmpeg WASM の真の代替になる。完全な代替ではない——FFmpeg WASM は codec の幅とエッジケースで勝つ——が、codec が分かっている、ハードウェアアクセラレーション、フレームレベルの処理ワークロードでは、WebCodecs は 4-5 倍速く 250 倍小さい。次は ストリーミングのダウンロードは合法か

完全な codec、解像度、ファイルサイズでのベンチマークデータは ブラウザ動画処理パフォーマンスベンチマーク で。


おすすめ記事