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。文末有實測資料。

三個 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() 拿同步存取控制代碼,這點對效能是命門。

要搭的架構

我搭這麼個東西:使用者往頁面拖一個影片檔案,按每秒一幀抽出來,存成 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 })
}

幾個容易踩的坑:

  1. frame.close() 是強制的。 VideoFrame 持著 GPU 緩衝的參考。忘了關,顯示記憶體一直漏,漏到 100 幀左右解碼器靜默失敗。必須 try/finally
  2. Worker 裡的 OffscreenCanvas。 這玩意才能讓你在 Worker 裡渲染。Chrome 69+、Safari 16.4+、Firefox 105+ 都有。
  3. createSyncAccessHandle() 會阻塞其他 OPFS 操作。 別開著它跨非同步操作——寫完、關掉、走人。

第四步:餵解碼器的 chunk 從哪來

這是最煩的一步。WebCodecs 不做解封裝,它要的是裸 codec chunk。你還是得自己解析 MP4 容器,把 H.264 NAL unit 抽出來。

兩條路:

  1. mp4box.jsnpm)——純 JS 的 MP4 解析器,吐出來的 sample 直接餵給 WebCodecs。
  2. 只用 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(命令列)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 漏顯示記憶體。漏 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 + Workers + OPFS 是效能敏感場景下 FFmpeg WASM 的真替代。不是全面替代——FFmpeg WASM 在 codec 廣度和邊界情況上還是贏——但已知 codec、硬體加速、幀級處理的工作流,WebCodecs 快 4-5 倍、小 250 倍。下一篇是 串流媒體下載到底合不合法,講你什麼時候被允許用這些工具。

完整的 codec、解析度、檔案大小下的基準資料,看 瀏覽器影片處理效能基準


推薦閱讀