高度なブラウザベースのビデオ処理を実現。WebCodecs APIを使用して、VideoFrameのRAWプレーンデータに直接アクセス・操作し、カスタムエフェクトや分析を行う方法を学びます。
WebCodecs VideoFrameプレーンアクセス:RAWビデオデータ操作の詳細解説
長年、ウェブブラウザにおける高性能なビデオ処理は、遠い夢のように感じられていました。開発者はしばしば<video>要素と2D Canvas APIの制約に縛られていました。これらは強力ではあるものの、パフォーマンスのボトルネックを生み、根底にあるRAWビデオデータへのアクセスを制限していました。WebCodecs APIの登場は、この状況を根本的に変え、ブラウザの組み込みメディアコーデックへの低レベルアクセスを提供します。その最も革新的な機能の一つが、VideoFrameオブジェクトを通じて個々のビデオフレームのRAWデータに直接アクセスし、操作する能力です。
この記事は、単純なビデオ再生を超えたいと考える開発者のための包括的なガイドです。私たちはVideoFrameのプレーンアクセスの複雑さを探求し、色空間やメモリレイアウトといった概念を解き明かし、リアルタイムフィルターから高度なコンピュータービジョンタスクまで、次世代のブラウザ内ビデオアプリケーションを構築するための実践的な例を提供します。
前提条件
このガイドを最大限に活用するためには、以下の項目について十分な理解が必要です:
- モダンJavaScript: 非同期プログラミング(
async/await、Promise)を含む。 - 基本的なビデオの概念: フレーム、解像度、コーデックなどの用語に精通していることが役立ちます。
- ブラウザAPI: Canvas 2DやWebGLなどのAPIの経験があれば有益ですが、必須ではありません。
ビデオフレーム、色空間、プレーンの理解
APIに飛び込む前に、まずビデオフレームのデータが実際にどのように見えるかについて、しっかりとしたメンタルモデルを構築しなければなりません。デジタルビデオは静止画像、つまりフレームの連続です。各フレームはピクセルのグリッドであり、各ピクセルには色があります。その色がどのように保存されるかは、色空間とピクセルフォーマットによって定義されます。
RGBA: ウェブのネイティブ言語
ほとんどのウェブ開発者はRGBAカラーモデルに精通しています。各ピクセルは、赤(Red)、緑(Green)、青(Blue)、アルファ(Alpha、透明度)の4つのコンポーネントで表現されます。データは通常、メモリ内でインターリーブ形式で格納されます。つまり、1つのピクセルのR、G、B、Aの値が連続して格納されます:
[R1, G1, B1, A1, R2, G2, B2, A2, ...]
このモデルでは、画像全体が単一の連続したメモリブロックに格納されます。これは、単一の「プレーン」のデータを持つと考えることができます。
YUV: ビデオ圧縮の言語
しかし、ビデオコーデックがRGBAを直接扱うことは稀です。それらはYUV(より正確にはY'CbCr)色空間を好みます。このモデルは画像情報を以下のように分離します:
- Y(輝度): 明るさ、またはグレースケール情報。人間の目は輝度の変化に最も敏感です。
- U (Cb) と V (Cr): クロミナンス、または色差情報。人間の目は、明るさのディテールよりも色のディテールには鈍感です。
この分離は、効率的な圧縮の鍵となります。UとVコンポーネントの解像度を下げること(クロマ・サブサンプリングと呼ばれる技術)によって、知覚できる品質の損失を最小限に抑えつつ、ファイルサイズを大幅に削減できます。これにより、Y、U、Vコンポーネントが別々のメモリブロック、つまり「プレーン」に格納されるプレーナーピクセルフォーマットが生まれます。
一般的なフォーマットはI420(YUV 4:2:0の一種)で、2x2のピクセルブロックごとに4つのYサンプルがありますが、UとVのサンプルはそれぞれ1つだけです。これは、UとVのプレーンがYプレーンの半分の幅と半分の高さを持つことを意味します。
この違いを理解することは非常に重要です。なぜなら、WebCodecsはデコーダーが提供するままの、まさにこれらのプレーンへの直接アクセスを提供するからです。
VideoFrameオブジェクト:ピクセルデータへのゲートウェイ
このパズルの中心的なピースはVideoFrameオブジェクトです。これはビデオの単一フレームを表し、ピクセルデータだけでなく、重要なメタデータも含まれています。
VideoFrameの主要なプロパティ
format: ピクセルフォーマットを示す文字列(例:'I420', 'NV12', 'RGBA')。codedWidth/codedHeight: コーデックが必要とするパディングを含む、メモリに格納されているフレームの完全な寸法。displayWidth/displayHeight: フレームを表示するために使用されるべき寸法。timestamp: フレームの表示タイムスタンプ(マイクロ秒単位)。duration: フレームの持続時間(マイクロ秒単位)。
魔法のメソッド: copyTo()
RAWピクセルデータにアクセスするための主要なメソッドはvideoFrame.copyTo(destination, options)です。この非同期メソッドは、フレームのプレーンデータをあなたが提供するバッファにコピーします。
destination: データを保持するのに十分な大きさのArrayBufferまたは型付き配列(Uint8Arrayなど)。options: どのプレーンをコピーするかとそのメモリレイアウトを指定するオブジェクト。省略された場合、すべてのプレーンを単一の連続したバッファにコピーします。
このメソッドは、フレーム内の各プレーンに対応するPlaneLayoutオブジェクトの配列で解決されるPromiseを返します。各PlaneLayoutオブジェクトには、2つの重要な情報が含まれています:
offset: このプレーンのデータが宛先バッファ内で開始されるバイトオフセット。stride: そのプレーンにおいて、あるピクセル行の開始から次の行の開始までのバイト数。
重要な概念:ストライド vs. 幅
これは、低レベルのグラフィックスプログラミングに慣れていない開発者にとって、最も一般的な混乱の原因の一つです。各ピクセルデータの行が互いに密に詰まっていると仮定することはできません。
- 幅(Width)は、画像の一行に含まれるピクセルの数です。
- ストライド(Stride)(ピッチまたはラインステップとも呼ばれる)は、メモリ内での一行の開始から次行の開始までのバイト数です。
多くの場合、strideはwidth * bytes_per_pixelよりも大きくなります。これは、CPUやGPUによる高速な処理のために、メモリがハードウェアの境界(例:32または64バイト境界)に合わせるためにパディングされることが多いためです。特定の行にあるピクセルのメモリアドレスを計算するには、常にストライドを使用しなければなりません。
ストライドを無視すると、画像が歪んだり、データアクセスが不正確になったりします。
実践例1:グレースケールプレーンへのアクセスと表示
シンプルかつ強力な例から始めましょう。ウェブ上のほとんどのビデオは、I420のようなYUV形式でエンコードされています。「Y」プレーンは、事実上、画像の完全なグレースケール表現です。このプレーンだけを抽出し、canvasにレンダリングすることができます。
async function displayGrayscale(videoFrame) {
// videoFrameが 'I420' や 'NV12' のようなYUV形式であると仮定します。
if (!videoFrame.format.startsWith('I4')) {
console.error('この例では、YUV 4:2:0 プレーナー形式が必要です。');
videoFrame.close();
return;
}
const yPlaneInfo = videoFrame.layout[0]; // Yプレーンは常に最初です。
// Yプレーンデータのみを保持するバッファを作成します。
const yPlaneData = new Uint8Array(yPlaneInfo.stride * videoFrame.codedHeight);
// Yプレーンを我々のバッファにコピーします。
await videoFrame.copyTo(yPlaneData, {
rect: { x: 0, y: 0, width: videoFrame.codedWidth, height: videoFrame.codedHeight },
layout: [yPlaneInfo]
});
// これで、yPlaneDataにRAWグレースケールピクセルが含まれました。
// これをレンダリングする必要があります。canvas用にRGBAバッファを作成します。
const canvas = document.getElementById('my-canvas');
canvas.width = videoFrame.displayWidth;
canvas.height = videoFrame.displayHeight;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(canvas.width, canvas.height);
// canvasのピクセルを反復処理し、Yプレーンデータから埋めていきます。
for (let y = 0; y < videoFrame.displayHeight; y++) {
for (let x = 0; x < videoFrame.displayWidth; x++) {
// 重要:ストライドを使用して正しいソースインデックスを見つけます!
const yIndex = y * yPlaneInfo.stride + x;
const luma = yPlaneData[yIndex];
// RGBA ImageDataバッファ内の宛先インデックスを計算します。
const rgbaIndex = (y * canvas.width + x) * 4;
imageData.data[rgbaIndex] = luma; // 赤
imageData.data[rgbaIndex + 1] = luma; // 緑
imageData.data[rgbaIndex + 2] = luma; // 青
imageData.data[rgbaIndex + 3] = 255; // アルファ
}
}
ctx.putImageData(imageData, 0, 0);
// 重要:メモリを解放するために、常にVideoFrameをcloseしてください。
videoFrame.close();
}
この例は、正しいプレーンレイアウトの特定、宛先バッファの割り当て、copyToを使用したデータの抽出、そしてstrideを使用してデータを正しく反復処理して新しい画像を構築するという、いくつかの重要なステップを強調しています。
実践例2:インプレース操作(セピアフィルター)
では、直接的なデータ操作を行ってみましょう。セピアフィルターは実装が簡単な古典的なエフェクトです。この例では、canvasやWebGLコンテキストから取得できるようなRGBAフレームを扱う方が簡単です。
async function applySepiaFilter(videoFrame) {
// この例では、入力フレームが 'RGBA' または 'BGRA' であることを想定しています。
if (videoFrame.format !== 'RGBA' && videoFrame.format !== 'BGRA') {
console.error('セピアフィルターの例にはRGBAフレームが必要です。');
videoFrame.close();
return null;
}
// ピクセルデータを保持するためのバッファを割り当てます。
const frameDataSize = videoFrame.allocationSize();
const frameData = new Uint8Array(frameDataSize);
await videoFrame.copyTo(frameData);
const layout = videoFrame.layout[0]; // RGBAは単一プレーンです
// 次に、バッファ内のデータを操作します。
for (let y = 0; y < videoFrame.codedHeight; y++) {
for (let x = 0; x < videoFrame.codedWidth; x++) {
const pixelIndex = y * layout.stride + x * 4; // 1ピクセルあたり4バイト (R,G,B,A)
const r = frameData[pixelIndex];
const g = frameData[pixelIndex + 1];
const b = frameData[pixelIndex + 2];
const tr = 0.393 * r + 0.769 * g + 0.189 * b;
const tg = 0.349 * r + 0.686 * g + 0.168 * b;
const tb = 0.272 * r + 0.534 * g + 0.131 * b;
frameData[pixelIndex] = Math.min(255, tr);
frameData[pixelIndex + 1] = Math.min(255, tg);
frameData[pixelIndex + 2] = Math.min(255, tb);
// アルファ (frameData[pixelIndex + 3]) は変更されません。
}
}
// 変更されたデータで*新しい* VideoFrame を作成します。
const newFrame = new VideoFrame(frameData, {
format: videoFrame.format,
codedWidth: videoFrame.codedWidth,
codedHeight: videoFrame.codedHeight,
timestamp: videoFrame.timestamp,
duration: videoFrame.duration
});
// 元のフレームを閉じるのを忘れないでください!
videoFrame.close();
return newFrame;
}
これは、データをコピーアウトし、ストライドを使用してループ処理し、各ピクセルに数学的な変換を適用し、結果のデータで新しいVideoFrameを構築するという、完全な読み取り-変更-書き込みサイクルを示しています。この新しいフレームは、canvasにレンダリングしたり、VideoEncoderに送信したり、別の処理ステップに渡したりすることができます。
パフォーマンスの問題:JavaScript vs. WebAssembly (WASM)
すべてのフレームで数百万のピクセル(1080pフレームには200万以上のピクセル、RGBAでは800万のデータポイントがあります)をJavaScriptで反復処理するのは遅くなる可能性があります。現代のJSエンジンは非常に高速ですが、高解像度ビデオ(HD、4K)のリアルタイム処理では、このアプローチは簡単にメインスレッドを圧倒し、途切れ途切れのユーザーエクスペリエンスにつながる可能性があります。
ここでWebAssembly (WASM)が不可欠なツールとなります。WASMを使用すると、C++、Rust、Goなどの言語で書かれたコードをブラウザ内でほぼネイティブの速度で実行できます。ビデオ処理のワークフローは次のようになります:
- JavaScript側:
videoFrame.copyTo()を使用して、RAWピクセルデータをArrayBufferに取得します。 - WASMへ渡す: このバッファへの参照をコンパイル済みのWASMモジュールに渡します。これはデータをコピーしないため、非常に高速な操作です。
- WASM側 (C++/Rust): 高度に最適化された画像処理アルゴリズムをメモリバッファ上で直接実行します。これはJavaScriptのループよりも桁違いに高速です。
- JavaScriptへ戻す: WASMの処理が終わると、制御がJavaScriptに戻ります。その後、変更されたバッファを使用して新しい
VideoFrameを作成できます。
仮想背景、オブジェクト検出、または複雑なフィルターなど、本格的なリアルタイムビデオ操作アプリケーションにとって、WebAssemblyの活用は単なる選択肢ではなく、必須事項です。
異なるピクセルフォーマットの扱い(例:I420, NV12)
RGBAはシンプルですが、VideoDecoderからはプレーナーYUV形式のフレームを受け取ることがほとんどです。I420のような完全なプレーナー形式の扱い方を見てみましょう。
I420形式のVideoFrameは、そのlayout配列に3つのレイアウト記述子を持ちます:
layout[0]: Yプレーン(輝度)。寸法はcodedWidthxcodedHeightです。layout[1]: Uプレーン(クロマ)。寸法はcodedWidth/2xcodedHeight/2です。layout[2]: Vプレーン(クロマ)。寸法はcodedWidth/2xcodedHeight/2です。
以下は、3つのプレーンすべてを単一のバッファにコピーする方法です:
async function extractI420Planes(videoFrame) {
const totalSize = videoFrame.allocationSize({ format: 'I420' });
const allPlanesData = new Uint8Array(totalSize);
const layouts = await videoFrame.copyTo(allPlanesData);
// layouts は3つの PlaneLayout オブジェクトの配列です
console.log('Y Plane Layout:', layouts[0]); // { offset: 0, stride: ... }
console.log('U Plane Layout:', layouts[1]); // { offset: ..., stride: ... }
console.log('V Plane Layout:', layouts[2]); // { offset: ..., stride: ... }
// これで `allPlanesData` バッファ内の各プレーンにアクセスできます
// それぞれのオフセットとストライドを使用して。
const yPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[0].offset,
layouts[0].stride * videoFrame.codedHeight
);
// クロマの寸法は半分になることに注意してください!
const uPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[1].offset,
layouts[1].stride * (videoFrame.codedHeight / 2)
);
const vPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[2].offset,
layouts[2].stride * (videoFrame.codedHeight / 2)
);
console.log('Accessed Y plane size:', yPlaneView.byteLength);
console.log('Accessed U plane size:', uPlaneView.byteLength);
videoFrame.close();
}
もう一つの一般的なフォーマットはNV12で、これはセミプレーナーです。これには2つのプレーンがあります:1つはY用、もう1つはUとVの値がインターリーブされた(例:[U1, V1, U2, V2, ...])プレーンです。WebCodecs APIはこれを透過的に処理します。NV12形式のVideoFrameは、単純にそのlayout配列に2つのレイアウトを持つことになります。
課題とベストプラクティス
この低レベルでの作業は強力ですが、それには責任が伴います。
メモリ管理が最重要
VideoFrameは大量のメモリを保持しており、それはしばしばJavaScriptのガベージコレクタのヒープ外で管理されます。このメモリを明示的に解放しないと、ブラウザのタブをクラッシュさせる可能性のあるメモリリークを引き起こします。
フレームの処理が終わったら、必ず、必ずvideoFrame.close()を呼び出してください。
非同期性
すべてのデータアクセスは非同期です。アプリケーションのアーキテクチャは、競合状態を避け、スムーズな処理パイプラインを確保するために、Promiseとasync/awaitの流れを適切に処理する必要があります。
ブラウザの互換性
WebCodecsはモダンなAPIです。すべての主要なブラウザでサポートされていますが、常にその利用可能性を確認し、ベンダー固有の実装詳細や制限に注意してください。APIを使用する前に機能検出を使用してください。
結論:ウェブビデオの新境地
WebCodecs APIを介してVideoFrameのRAWプレーンデータに直接アクセスし操作する能力は、ウェブベースのメディアアプリケーションにとってパラダイムシフトです。それは<video>要素のブラックボックスを取り除き、以前はネイティブアプリケーションにのみ許されていた粒度の高い制御を開発者に与えます。
プレーン、ストライド、カラーフォーマットといったビデオメモリレイアウトの基礎を理解し、パフォーマンスが重要な操作にはWebAssemblyの力を活用することで、非常に高度なビデオ処理ツールをブラウザ内で直接構築できるようになります。リアルタイムのカラーグレーディングやカスタムビジュアルエフェクトから、クライアントサイドの機械学習やビデオ分析まで、可能性は広大です。ウェブ上での高性能、低レベルビデオの時代が、真に始まったのです。