12k
All articles

WebCodecs APIによるリアルタイム動画処理

MediaStreamTrackProcessor、TransformStream、VideoTrackGeneratorを使うWebCodecs動画処理。frame.close、backpressure、worker、対応状況も解説。

OpenReplay Team
OpenReplay Team
WebCodecs APIによるリアルタイム動画処理

WebCodecsの動画パイプラインは3つの部分で構成されています。MediaStreamTrackReadableStream<VideoFrame>に変換するMediaStreamTrackProcessor、各フレームを操作するTransformStream、そして処理済みフレームを<video>要素に割り当て可能なMediaStreamTrackに戻すVideoTrackGeneratorです。VideoTrackGeneratorは現行の仕様名であり、Chromiumのサンプルコードでは今もなお旧来の非標準名称であるMediaStreamTrackGeneratorが使われています。パイプライン全体を示すコードは以下のとおりです。

const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];

const processor = new MediaStreamTrackProcessor({ track });
const generator = new VideoTrackGenerator();

const grayscale = new TransformStream({
  async transform(frame, controller) {
    try {
      const canvas = new OffscreenCanvas(frame.displayWidth, frame.displayHeight);
      const ctx = canvas.getContext('2d');
      ctx.filter = 'grayscale(1)';
      ctx.drawImage(frame, 0, 0);
      controller.enqueue(new VideoFrame(canvas, {
        timestamp: frame.timestamp,
        duration: frame.duration,
      }));
    } finally {
      frame.close();
    }
  },
});

processor.readable.pipeThrough(grayscale).pipeTo(generator.writable);
videoEl.srcObject = new MediaStream([generator.track]);

本記事では、既存のチュートリアルが軒並み省略している部分、すなわち障害モードについて解説します。上記のハッピーパス版は問題なく動作しますが、フレームがリークしたり、変換処理が遅延したり、エンコーダーがクローズ状態に入ったり、あるいはSafariでは動作しないと決め込んだコードをリリースしてしまったりすると、途端に破綻します。これらはいずれも実際の本番環境で発生する障害であり、それぞれ固有の原因と対処法があります。以降ではその詳細を説明します。

重要なポイント

  • WebCodecsパイプラインの構成はMediaStreamTrackProcessorTransformStreamVideoTrackGeneratorです。非推奨の位置引数形式ではなく、仕様に準拠したコンストラクターnew MediaStreamTrackProcessor({ track })を使用してください。
  • frame.close()を忘れると、パイプラインが依存する有限のメディアリソースが枯渇します。リソースが枯渇すると、フレームの出力が停止し、ページの他の部分は正常に動作しているにもかかわらず、動画がコマ落ちしてやがてフリーズするという症状が現れます。
  • MediaStreamTrackProcessorはバックプレッシャーをアップストリームに伝播しません。変換処理が遅延した場合、プロセッサーはエラーを発生させることなく、最も古いフレームを静かにドロップします。
  • VideoFrameに関わる処理はすべて単一のWorker内で行ってください。Workerの境界をまたいで転送されたフレームは、送信側で自動的にクローズされるため、再度アクセスしようとすると例外がスローされます。
  • WebCodecsのサポート状況はインターフェイスによって異なります。コアのVideoEncoder/VideoFrameはChrome 94+、Firefox 130+、Safari 16.4+で利用可能ですが、MediaStreamTrackProcessor/VideoTrackGeneratorは対応が遅れており(Safari 18+、Firefoxは未対応)、注意が必要です。

WebCodecsとは何か、なぜパイプラインがこのような形をしているのか

WebCodecsは、ブラウザーに組み込まれたメディアコーデック(多くの場合ハードウェアアクセラレーション対応)と生の動画フレームへの直接的なアクセス手段をJavaScriptに提供します。これ以前は、MediaStreamは不透明な存在でした。<video>要素にパイプするだけで、キャプチャから表示までのすべてをブラウザーが処理していました。WebCodecsはそのパイプラインを開放します。VideoFrameインターフェイスは、キャプチャとエンコードの間にある生のピクセルデータを公開します。フィルター、バーチャル背景、カスタムエンコーダーが必要とするのは、まさにその位置です。

パイプラインにStreamsを使用する理由は、デコードされた生のフレームが大きく(1フレームあたり数メガバイト)、高速に到着する(毎秒25フレーム以上)ため、すべてをメモリにバッファリングするのではなく、フロー制御とインクリメンタルな処理が必要だからです。WHATWGのStreams APIは、まさにこのようなパイプチェーンを通じたアトミックなチャンク処理のために設計されました。MediaStreamTrackProcessorはライブトラックをストリームにブリッジし、TransformStreamでフレームごとの処理を行い、VideoTrackGeneratorは処理結果を<video>RTCPeerConnectionなど、プラットフォームの他の部分が理解できるトラックにブリッジします。

WebCodecsはコンテナーなしのストリームのみを対象としています。MP4/ISOBMFFの読み書きが必要な場合は、独自のコンテナーロジックを実装する必要があります。音声には並行するインターフェイス(AudioDataAudioEncoder)がありますが、本記事では扱いません。以降のパターンは動画専用です。

動作するカメラ → フィルター → 表示パイプライン

動作するWebCodecsフィルターパイプラインは、MediaStreamTrackProcessorでキャプチャし、TransformStream内でCanvas2Dを使ってVideoFrameに直接フィルターを適用し、VideoTrackGeneratorで表示するという、冒頭のコードブロックで示した構成になります。効率化のポイントはctx.drawImage(frame, 0, 0)です。drawImageVideoFrameを直接ソースとして受け取れるため、PNGへの変換や中間的なImageBitmapの生成なしにフレームをCanvasに描画できます。

Canvas2Dのカラーフィルターには、ctx.filter文字列が最も低コストな手段です。クロマキーやカスタム畳み込みなど、ピクセル単位でアクセスする処理にはgetImageData/putImageDataを使用します。

const filter = new TransformStream({
  async transform(frame, controller) {
    try {
      const w = frame.displayWidth, h = frame.displayHeight;
      const canvas = new OffscreenCanvas(w, h);
      const ctx = canvas.getContext('2d', { willReadFrequently: true });
      ctx.drawImage(frame, 0, 0);

      const imageData = ctx.getImageData(0, 0, w, h);
      const px = imageData.data;
      for (let i = 0; i < px.length; i += 4) {
        const lum = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
        px[i] = px[i + 1] = px[i + 2] = lum;
      }
      ctx.putImageData(imageData, 0, 0);

      controller.enqueue(new VideoFrame(canvas, {
        timestamp: frame.timestamp,
        duration: frame.duration,
      }));
    } finally {
      frame.close();
    }
  },
});

元のフレームから新しいフレームに引き継ぐべき情報はtimestampdurationの2つです。timestampはパイプライン全体を通じてフレームを識別するものであり、エンコード/デコードサイクルを経ても保持され、後でレイテンシーを計測する際にも使用します。これを省略すると、ダウンストリームのコンシューマーはフレームの順序を失います。

フルレゾリューションでの重いピクセル処理では、getImageDataによる読み戻しがボトルネックになります。WebGLやWebGPUimportExternalTexture経由)を使用すれば、フレームをGPU上に保持したままCPUへの読み戻しを回避できます。色変換や単純な合成にはCanvas2Dを使用し、ピクセル単位のコストがフレームバジェットを圧迫する場合はGPUパスを検討してください。

VideoFrameのライフサイクル:frame.close()が必須である理由

frame.close()を忘れることは、単なる通常のメモリリークではありません。パイプラインが依存する有限のメディアリソースを枯渇させます。リソースが枯渇すると、新しいフレームを割り当てたり出力したりできなくなるため、デコードやフレームの出力が停止します。その結果、ページの他の部分は正常に動作しているにもかかわらず、動画が徐々にコマ落ちしてやがて完全にフリーズするという特徴的な症状が現れます。VideoFrame.close()はフレームが保持する基盤となるメディアリソースを解放します。WebCodecs仕様は、これらのリソースが有限であることを明示しています。ハードウェアバッファーに裏付けられたフレームは限られたプールから取得されるため、プールが満杯になるとソースは新しいフレームを出力できなくなります。

これが、close()をガベージコレクションに任せることができない理由です。ガベージコレクターは独自のスケジュールで動作しており、基盤となるメディアリソースについては把握していません。ガベージコレクターが実行される頃には、プールはすでに枯渇しています。プロセッサーから読み取ったすべてのVideoFrameと、新たに生成したすべてのVideoFrameは、使用が終わったタイミングで必ず1回だけクローズしなければなりません。

見落としやすい障害はエラーパスです。変換処理がフレームを読み取った後、クローズする前に例外をスローした場合、そのフレームはリークします。あるフレームで例外をスローした変換処理は通常、次のフレームでも例外をスローするため、リークは急速に蓄積されます。対処法はtry/finallyの使用です。

async transform(frame, controller) {
  try {
    // ...例外をスローする可能性のあるフィルター処理...
    controller.enqueue(newFrame);
  } finally {
    frame.close(); // 本体が例外をスローしたかどうかにかかわらず実行される
  }
}

finallyは、成功パスとエラーパスの両方でframe.close()が実行されることを保証します。これはWebCodecsパイプラインにおいて最も重要なパターンです。

バックプレッシャー:遅い変換処理がフレームを静かにドロップする理由

MediaStreamTrackProcessorはバックプレッシャーをアップストリームに伝播しません。TransformStreamの処理が遅延した場合、プロセッサーはカメラを遅くするのではなく、最も古いフレームを静かにドロップします。エラーは一切発生せず、フレームが欠落するだけです。実際の影響として、30fps(33msのバジェット)のソースに対して1フレームあたり50msかかる変換処理は、エラーを発生させたり無限にキューイングしたりすることなく、差分をドロップしながら約20fpsで静かに動作し続けます。

この状況は、変換処理の内部からreadable側のキューを監視することで検出できます。TransformStreamDefaultController.desiredSizeはreadable側のバックプレッシャー状態を反映しており、負の値になった場合はreadable側がハイウォーターマークを超えており、コンシューマーが遅延していることを示します。

const filter = new TransformStream({
  async transform(frame, controller) {
    try {
      if (controller.desiredSize !== null && controller.desiredSize < 0) {
        // コンシューマーが遅延しています。さらに遅延が拡大しないよう、
        // このフレームを意図的にドロップします。
        return;
      }
      // ...フィルター処理...
      controller.enqueue(newFrame);
    } finally {
      frame.close();
    }
  },
});

バックプレッシャーを検出した場合、2つの対処法があります。1つ目は、上記のように現在のフレームをスキップして意図的にドロップすることです。これにより、無秩序なランダムなフレーム欠落が意図的なリズムに置き換わります。2つ目は入力を削減することです。MediaTrackConstraintsを通じてgetUserMediaに低い解像度やフレームレートを要求するか、track.applyConstraints()を呼び出してランタイムで段階的に下げます。解像度を下げると1フレームあたりのピクセル処理が直接削減されるため、CPUバウンドなフィルターに対して通常最も効果的な対処法となります。

Worker:VideoFrameの処理をすべて単一のWorkerで行うべき理由

VideoFrameに関わる処理はすべて単一のWorker内で行ってください。VideoFramepostMessageでWorkerの境界をまたいで転送されると、送信側の参照は自動的にクローズされます。再度読み取ったりクローズしようとすると例外がスローされますが、これはWorkerのメッセージキューをまたぐサイレントなデータ競合であり、デバッグが非常に困難です。転送されたストリーム内のフレームはシリアライズされてクローンされるため、両側で明示的なクローズが必要です。この2つを混在させると、早期クローズの障害が発生します。

controller.enqueue(frame);
frame.close(); // 早すぎます — enqueueは非同期であり、フレームはまだ転送中の可能性があります

controller.enqueue()はコンシューマーWorkerに対して非同期であるため、送信側の参照を早期にクローズするとシリアライズエラーが発生し、一方でクローズしないと前述のリーク→フリーズが発生します。MediaStreamTrackProcessorTransformStreamVideoTrackGeneratorのチェーン全体を1つのWorker内に収めることで、所有権の問題を完全に回避できます。(WebTransportやデータチャンネルを使ったエンコード済みチャンクのデバイスからの送信については、webrtcHacksのパイプラインシリーズを参照してください。それ自体が独立したトピックです。)

パイプラインへの供給のためにフレームをWorkerに渡す場合(分割するためではなく)は、明示的に転送し、送信側ではそれ以降アクセスしないようにしてください。

// メインスレッド
worker.postMessage({ frame }, { transfer: [frame] });
// `frame`はここで無効化されます。メインスレッドで読み取ったりクローズしたりしないでください。

転送後は、受信側のWorkerがフレームの所有権を持ち、クローズする責任を負います。送信スレッドは自身の参照が無効になったものとして扱わなければなりません。

送信または録画のためのエンコード

VideoEncoderは生のVideoFrameオブジェクトをEncodedVideoChunkオブジェクトに圧縮し、録画や送信のための出力コールバックを通じて配信します。コーデック文字列、解像度、ビットレート、フレームレートで設定します。

const chunks = [];
const encoder = new VideoEncoder({
  output: (chunk, metadata) => {
    // chunk.typeは'key'または'delta'。chunkはtimestamp、duration、byteLengthを持つ
    chunks.push(chunk);
  },
  error: (e) => console.error('encoder error', e),
});

encoder.configure({
  codec: 'vp8',          // またはH.264ベースラインの場合は'avc1.42001f'など
  width: 640,
  height: 480,
  bitrate: 1_000_000,
  framerate: 30,
});

outputコールバックはEncodedVideoChunkとオプションのメタデータを提供します。チャンクにはtype'key'または'delta')、timestampduration、エンコードされたバイト列が含まれます。コーデック文字列については、AVCプロファイル文字列を推測するのではなく、WebCodecsコーデックレジストリMDNのコーデックガイドを参照してください。

ストリーム開始時、シーク後、リカバリーポイントなどイントラフレームが必要な場合は、encoder.encode(frame, { keyFrame: true })(Fが大文字であることに注意)でキーフレームを要求します。すべてのフレームをキーフレームとしてエンコードするとフレーム間圧縮が無効になり、ビットレートが大幅に増加します。このオプションの綴りはMDNのWebCodecs APIの使用ガイドに記載されています。

クローズされたエンコーダーからの回復

VideoEncoderのエラーコールバックが発火してエンコーダーが'closed'状態に移行した場合、そのエンコーダーは再利用できません。VideoEncoder.reset()は非終端的なケースに対応していますが、クローズされたエンコーダーからの回復は、新しいインスタンスを生成して同じパラメーターでconfigure()を再度呼び出すことを意味します。encode()の前に状態を確認し、クローズ時に再構築してください。

function encodeFrame(frame, keyFrame = false) {
  if (encoder.state === 'closed') {
    encoder = makeEncoder();   // 新しいVideoEncoderを生成して設定する
  }
  if (encoder.state === 'configured') {
    encoder.encode(frame, { keyFrame });
  }
}

encode()を状態チェックと再構築パスで保護することが、一時的なコーデックエラーを経ても長時間稼働するセッションを維持する方法です。

2026年時点のブラウザーサポート状況

WebCodecsのサポート状況はインターフェイスによって異なります。単一のバージョン番号として扱うことが、古くなったチュートリアルが犯す典型的な誤りです。コアのVideoEncoder/VideoFrameインターフェイスは広くサポートされていますが、Insertable Streamsの部分、すなわちMediaStreamTrackProcessorVideoTrackGeneratorは、異なる遅いタイムラインで提供されています。

インターフェイスChrome / EdgeFirefoxSafari
VideoEncoder / VideoFrame(コアWebCodecs)94+130+16.4+
MediaStreamTrackProcessor94+未対応18+
VideoTrackGenerator未対応未対応18+
MediaStreamTrackGenerator(非標準)94+未対応未対応

MDNのVideoEncoderブラウザー互換性データおよびMediaStreamTrackProcessorに基づいて確認済みです。多くのチュートリアルで見られる「SafariはWebCodecsをサポートしていない」という注意書きは、時代遅れかつ不正確です。SafariはWebCodecsのコア機能を16.4からサポートしており、Safari 17.4ではHEVCを含むコーデックサポートが拡充されています。SafariとFirefoxに欠けているのは、Insertable Streamsのキャプチャ/出力レイヤーです。つまり、冒頭のカメラ → フィルター → 表示パイプラインは現在Chromiumで動作し、専用Workerで実装した場合はSafari 18+でも動作しますが、FirefoxではInsertable Streamsが利用できないため、別の方法でフレームを取得しながらエンコード/デコードを行うことになります。

実践的な教訓として、ブラウザーではなくインターフェイスごとに機能を検出してください。window.MediaStreamTrackProcessorwindow.VideoEncoderを個別に確認し、Insertable Streamsのキャプチャレイヤーが利用できない環境向けには、CanvasとrequestVideoFrameCallbackによるフォールバックを用意してください。

デバッグチェックリスト

WebCodecsパイプラインにおける3つの障害モード、すなわちフレームのドロップ、メモリの際限ない増加、レイテンシーのスパイクには、それぞれ固有の症状と直接的な診断手順があります。

症状考えられる原因診断手順
徐々にコマ落ちしてフリーズするが、ページの他の部分は正常何らかのパスでframe.close()が欠落 → 有限のメディアリソースが枯渇すべてのVideoFrameの読み取りと生成箇所でclose()が正確に1回呼ばれているか確認し、try/finallyでカバーされているかを検証する
フレームが欠落しているが、コンソールにエラーなし変換処理が遅く、プロセッサーが最も古いフレームを静かにドロップしている変換処理内でcontroller.desiredSizeをログ出力し、負の値に推移していればコンシューマーが遅延している
レイテンシーが時間とともに増加フレームごとの重いフィルター処理がフレームバジェットを消費している各ステップの処理時間を計測し、フレームレートのバジェット(30fpsで33ms)と比較する
エンコーダーがチャンクの出力を停止エラー発生後にVideoEncoder'closed'状態に移行したencode()の前にencoder.stateを確認し、'closed'の場合は再構築する

コマ落ちからフリーズへという症状のパターンは、見た目で判断できるようになっておく価値があります。WebCodecsベースの機能のセッションリプレイでは、このパターンが確実に浮かび上がります。最初は正常に再生されていた動画が、目に見えてコマ落ちし始め、UIの他の部分はインタラクティブなままで完全にフリーズします。これが、クローズされていないフレームによる有限メディアリソース枯渇の視覚的なシグネチャです。リプレイでは症状が明確に見えますが、パイプラインコードのどこかにクローズされていないフレームがあることを知らなければ、原因は見えてきません。

エンドツーエンドの真のレイテンシー(カメラキャプチャから表示まで)を計測するには、パイプラインの前段でフレームのtimestampをピクセルオーバーレイとしてエンコードし、requestVideoFrameCallbackでレンダリング済み出力からデコードします。参考値として、webrtcHacksのパイプラインベンチマーク(2023年3月)では以下の1フレームあたりのコストが報告されています。

ステップ処理時間
背景除去22ms
オーバーレイ追加1ms
エンコード8ms
デコード1ms
表示38ms

実際の数値はハードウェアとフィルターの複雑さによって異なります。注目すべき結果は、表示だけで約38msを占めており、これが支配的な項目だということです。つまり、30fpsのバジェット内に十分収まるフィルターでも、表示の末尾を考慮しないと遅延を感じることがあります。変換処理だけでなく、パス全体を計測してください。

まとめ

WebCodecsのパイプライン構成(MediaStreamTrackProcessorTransformStreamVideoTrackGenerator)は1つのコードブロックに収まるほどシンプルですが、デモと本番リリース可能な機能の差は、まさに障害モードにあります。すべてのフレームをクローズすること、サイレントなバックプレッシャーを検出すること、チェーン全体を1つのWorkerに収めること、クローズされたエンコーダーから回復すること、そしてブラウザーではなくインターフェイスごとに機能を検出すること。本記事冒頭のtry/finallyの例を出発点として、desiredSizeチェックとエンコーダー状態ガードを追加すれば、ハッピーパスのチュートリアルが決して到達しないケースを乗り越えられるパイプラインが完成します。

よくある質問

WebCodecsフィルターにCanvas2DとWebGL/WebGPUのどちらを使うべきですか?

色変換や単純な合成にはCanvas2Dを使用してください。ctx.filterの文字列や軽量なgetImageDataループがフレームバジェット内に収まる場合に適しています。ピクセル単位のコストが支配的な場合はWebGLまたはWebGPUを検討してください。importExternalTextureを通じてフレームをGPU上に保持し、getImageDataが強制するCPUへの読み戻しを回避できます。フルレゾリューションでは読み戻しがボトルネックになることが多いため、クロマキーのような重いピクセル処理にはGPUパスが有効です。

Workerをまたいでフレームを渡すとVideoFrameが予期せずクローズされるのはなぜですか?

postMessageでWorkerの境界をまたいでVideoFrameを転送すると、送信側の参照が自動的にクローズされるため、送信側で再度読み取ったりクローズしようとすると例外がスローされます。これは、転送されたストリーム内のフレームとは異なります。後者はシリアライズされてクローンされるため、両側で明示的なクローズが必要です。データ競合を避けるには、パイプライン全体を1つのWorkerに収めるか、転送後は送信側の参照を無効なものとして扱い、受信側のWorkerにフレームの所有権とクローズの責任を持たせてください。

カメラ→フィルター→表示パイプラインはFirefoxで動作しますか?

完全には動作しません。Firefox 130以降はコアのVideoEncoderとVideoFrameインターフェイスをサポートしていますが、Insertable Streamsのキャプチャ/出力レイヤー、すなわちMediaStreamTrackProcessorとVideoTrackGeneratorは利用できません。Firefoxではフレームのエンコード/デコードは可能ですが、requestVideoFrameCallbackを使ったCanvasなど別の方法でフレームを取得する必要があります。ブラウザーではなくインターフェイスごとに機能を検出し、window.MediaStreamTrackProcessorとwindow.VideoEncoderを個別に確認してください。

VideoEncoder.reset()とエンコーダーの再構築の違いは何ですか?

VideoEncoder.reset()は非終端的なケースに対応しており、まだ使用可能なエンコーダーの保留中の処理をクリアします。エラー発生後にクローズ状態に移行したエンコーダーは、再設定も再利用もできないため、reset()では回復できません。クローズされたエンコーダーからの回復は、新しいVideoEncoderインスタンスを生成して同じパラメーターでconfigure()を再度呼び出すことを意味します。encode()の前にencoder.stateを確認し、closedの場合は再構築してください。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.