WebCodecs による動画処理

動画ストリーム コンポーネントの操作

Eugene Zemtsov
Eugene Zemtsov
François Beaufort
François Beaufort

最新のウェブ技術では、さまざまな方法で動画を活用できます。Media Stream APIMedia Recording APIMedia Source APIWebRTC API が加わり、動画ストリームの記録、転送、再生を行うための豊富なツールセットとなります。これらの API を使用すると、ウェブ プログラマーは高レベルのタスクを解決できますが、エンコードされた動画や音声のフレームや多重化されていないチャンクなど、動画ストリームの個々のコンポーネントを操作できません。これらの基本コンポーネントに低レベルでアクセスするために、デベロッパーは WebAssembly を使用して動画コーデックとオーディオ コーデックをブラウザに取り込んできました。しかし、最新のブラウザにはすでにさまざまなコーデックが付属しているため(多くの場合、ハードウェアによって高速化されます)、WebAssembly として再パッケージ化することは、人とコンピュータのリソースの無駄のように思えます。

WebCodecs API は、ブラウザにすでに存在するメディア コンポーネントをプログラマーが使用できるようにすることで、この非効率性を排除します。詳細:

  • 動画デコーダとオーディオ デコーダ
  • 動画と音声のエンコーダ
  • 未加工動画フレーム
  • 画像デコーダ

WebCodecs API は、動画エディタ、ビデオ会議、動画ストリーミングなど、メディア コンテンツの処理方法を完全に制御する必要があるウェブ アプリケーションに役立ちます。

動画処理ワークフロー

フレームは動画処理の中心的な要素です。したがって WebCodecs では、ほとんどのクラスがフレームを使用または生成します。動画エンコーダはフレームをエンコードされたチャンクに変換します。動画デコーダはその逆を行います。

また、VideoFrameCanvasImageSource であり、CanvasImageSource を受け入れるコンストラクタを持つことで、他のウェブ API とうまく連携します。そのため、drawImage()texImage2D() などの関数で使用できます。また、キャンバス、ビットマップ、動画要素、その他の動画フレームから構成することもできます。

WebCodecs API は、WebCodecs をメディア ストリーム トラックに接続する Insertable Streams API のクラスと適切に連携します。

  • MediaStreamTrackProcessor は、メディア トラックを個々のフレームに分割します。
  • MediaStreamTrackGenerator: フレームのストリームからメディア トラックを作成します。

WebCodecs と Web Worker

WebCodecs API は設計上、面倒な処理をすべて非同期でメインスレッドから行います。ただし、フレームとチャンクのコールバックは多くの場合 1 秒間に複数回呼び出される可能性があるため、メインスレッドが雑然とし、ウェブサイトの応答性が低下する可能性があります。そのため、個々のフレームとエンコードされたチャンクの処理をウェブワーカーに移動することをおすすめします。

これを実現するため、ReadableStream は、メディア トラックからのすべてのフレームをワーカーに自動的に転送する便利な方法を提供します。たとえば、MediaStreamTrackProcessor を使用すると、ウェブカメラからのメディア ストリーム トラックの ReadableStream を取得できます。その後、ストリームはウェブワーカーに転送され、ウェブワーカーでフレームが 1 つずつ読み取られ、VideoEncoder のキューに追加されます。

HTMLCanvasElement.transferControlToOffscreen を使用すると、レンダリングもメインスレッドの外部で実行できます。ただし、すべての高レベルのツールが不便な場合、VideoFrame 自体は移行可能であり、ワーカー間で移動される可能性があります。

WebCodec の実例

エンコード

Canvas または ImageBitmap からネットワークまたはストレージへのパス
Canvas または ImageBitmap からネットワークまたはストレージへのパス

すべては VideoFrame から始まります。動画フレームを作成する方法は 3 つあります。

  • キャンバス、画像ビットマップ、動画要素などの画像ソースから。

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • MediaStreamTrackProcessor を使用して MediaStreamTrack からフレームを pull する

    const stream = await navigator.mediaDevices.getUserMedia({…});
    const track = stream.getTracks()[0];
    
    const trackProcessor = new MediaStreamTrackProcessor(track);
    
    const reader = trackProcessor.readable.getReader();
    while (true) {
      const result = await reader.read();
      if (result.done) break;
      const frameFromCamera = result.value;
    }
    
  • BufferSource のバイナリ ピクセル表現からフレームを作成する

    const pixelSize = 4;
    const init = {
      timestamp: 0,
      codedWidth: 320,
      codedHeight: 200,
      format: "RGBA",
    };
    const data = new Uint8Array(init.codedWidth * init.codedHeight * pixelSize);
    for (let x = 0; x < init.codedWidth; x++) {
      for (let y = 0; y < init.codedHeight; y++) {
        const offset = (y * init.codedWidth + x) * pixelSize;
        data[offset] = 0x7f;      // Red
        data[offset + 1] = 0xff;  // Green
        data[offset + 2] = 0xd4;  // Blue
        data[offset + 3] = 0x0ff; // Alpha
      }
    }
    const frame = new VideoFrame(data, init);
    

送信元に関係なく、フレームは VideoEncoder を使用して EncodedVideoChunk オブジェクトにエンコードできます。

エンコードする前に、VideoEncoder に次の 2 つの JavaScript オブジェクトを渡す必要があります。

  • エンコードされたチャンクとエラーを処理するための 2 つの関数を含む初期化辞書。これらの関数はデベロッパーが定義し、VideoEncoder コンストラクタに渡した後は変更できません。
  • エンコーダ構成オブジェクト。出力動画ストリームのパラメータが含まれます。これらのパラメータは configure() を呼び出して後で変更できます。

ブラウザで構成がサポートされていない場合、configure() メソッドは NotSupportedError をスローします。構成を使用して静的メソッド VideoEncoder.isConfigSupported() を呼び出し、構成がサポートされているかどうかを事前に確認し、その Promise を待つことをおすすめします。

const init = {
  output: handleChunk,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  width: 640,
  height: 480,
  bitrate: 2_000_000, // 2 Mbps
  framerate: 30,
};

const { supported } = await VideoEncoder.isConfigSupported(config);
if (supported) {
  const encoder = new VideoEncoder(init);
  encoder.configure(config);
} else {
  // Try another config.
}

エンコーダを設定すると、encode() メソッドでフレームを受け取れるようになります。 configure()encode() はどちらも、実際の処理の完了を待たずにすぐに戻ります。複数のフレームを同時にエンコードのキューに入れることができます。encodeQueueSize は、前のエンコードの完了をキューで待機しているリクエストの数を示します。エラーは、引数またはメソッド呼び出しの順序が API コントラクトに違反している場合に直ちに例外をスローするか、コーデックの実装で発生した問題について error() コールバックを呼び出すことによって報告されます。エンコードが正常に完了すると、新しいエンコードされたチャンクを引数として output() コールバックが呼び出されます。ここでのもう一つの重要な点は、フレームが不要になったときに close() を呼び出して通知する必要があることです。

let frameCounter = 0;

const track = stream.getVideoTracks()[0];
const trackProcessor = new MediaStreamTrackProcessor(track);

const reader = trackProcessor.readable.getReader();
while (true) {
  const result = await reader.read();
  if (result.done) break;

  const frame = result.value;
  if (encoder.encodeQueueSize > 2) {
    // Too many frames in flight, encoder is overwhelmed
    // let's drop this frame.
    frame.close();
  } else {
    frameCounter++;
    const keyFrame = frameCounter % 150 == 0;
    encoder.encode(frame, { keyFrame });
    frame.close();
  }
}

最後に、エンコードされた動画のチャンクがエンコーダから出力されるときに処理する関数を記述して、コードのエンコードを完了します。通常、この関数はネットワーク経由でデータチャンクを送信するか、保存のためにメディア コンテナに多重化します。

function handleChunk(chunk, metadata) {
  if (metadata.decoderConfig) {
    // Decoder needs to be configured (or reconfigured) with new parameters
    // when metadata has a new decoderConfig.
    // Usually it happens in the beginning or when the encoder has a new
    // codec specific binary configuration. (VideoDecoderConfig.description).
    fetch("/upload_extra_data", {
      method: "POST",
      headers: { "Content-Type": "application/octet-stream" },
      body: metadata.decoderConfig.description,
    });
  }

  // actual bytes of encoded data
  const chunkData = new Uint8Array(chunk.byteLength);
  chunk.copyTo(chunkData);

  fetch(`/upload_chunk?timestamp=${chunk.timestamp}&type=${chunk.type}`, {
    method: "POST",
    headers: { "Content-Type": "application/octet-stream" },
    body: chunkData,
  });
}

保留中のすべてのエンコード リクエストが完了していることを確認する必要がある場合は、flush() を呼び出して Promise を待ちます。

await encoder.flush();

デコード

ネットワークまたはストレージから Canvas または ImageBitmap へのパス。
ネットワークまたはストレージから Canvas または ImageBitmap へのパス。

VideoDecoder のセットアップは VideoEncoder のセットアップに似ています。デコーダの作成時に 2 つの関数が渡され、コーデック パラメータが configure() に渡されます。

コーデック パラメータのセットはコーデックによって異なります。たとえば、H.264 コーデックでは、いわゆる Annex B 形式(encoderConfig.avc = { format: "annexb" })でエンコードされていない限り、AVCC のバイナリ blob が必要になる場合があります。

const init = {
  output: handleFrame,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  codedWidth: 640,
  codedHeight: 480,
};

const { supported } = await VideoDecoder.isConfigSupported(config);
if (supported) {
  const decoder = new VideoDecoder(init);
  decoder.configure(config);
} else {
  // Try another config.
}

デコーダが初期化されたら、EncodedVideoChunk オブジェクトでデコーダへのフィードを開始できます。チャンクを作成するには、次のものが必要です。

  • エンコードされた動画データの BufferSource
  • マイクロ秒単位のチャンクの開始タイムスタンプ(チャンク内の最初にエンコードされたフレームのメディア時間)
  • チャンクのタイプ。次のいずれかです。
    • チャンクを以前のチャンクとは別にデコードできる場合は key
    • 前の 1 つ以上のチャンクがデコードされた後にのみチャンクをデコードできる場合は delta

また、エンコーダから出力されたチャンクも、そのままデコーダで利用できるようになります。上記のエラー報告とエンコーダのメソッドの非同期の性質はすべて、デコーダにも同様に当てはまります。

const responses = await downloadVideoChunksFromServer(timestamp);
for (let i = 0; i < responses.length; i++) {
  const chunk = new EncodedVideoChunk({
    timestamp: responses[i].timestamp,
    type: responses[i].key ? "key" : "delta",
    data: new Uint8Array(responses[i].body),
  });
  decoder.decode(chunk);
}
await decoder.flush();

次に、新たにデコードされたフレームをページに表示する方法を示します。デコーダの出力コールバック(handleFrame())が迅速に返されるようにすることをおすすめします。以下の例では、レンダリングの準備ができているフレームのキューにフレームを追加するだけです。レンダリングは個別に行われ、次の 2 つのステップで構成されます。

  1. フレームを表示する適切なタイミングを待機しています。
  2. キャンバスにフレームを描画する。

フレームが不要になったら、close() を呼び出して、ガベージ コレクタがフレームに到達する前に基盤となるメモリを解放します。これにより、ウェブ アプリケーションが使用する平均メモリ量が減少します。

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let pendingFrames = [];
let underflow = true;
let baseTime = 0;

function handleFrame(frame) {
  pendingFrames.push(frame);
  if (underflow) setTimeout(renderFrame, 0);
}

function calculateTimeUntilNextFrame(timestamp) {
  if (baseTime == 0) baseTime = performance.now();
  let mediaTime = performance.now() - baseTime;
  return Math.max(0, timestamp / 1000 - mediaTime);
}

async function renderFrame() {
  underflow = pendingFrames.length == 0;
  if (underflow) return;

  const frame = pendingFrames.shift();

  // Based on the frame's timestamp calculate how much of real time waiting
  // is needed before showing the next frame.
  const timeUntilNextFrame = calculateTimeUntilNextFrame(frame.timestamp);
  await new Promise((r) => {
    setTimeout(r, timeUntilNextFrame);
  });
  ctx.drawImage(frame, 0, 0);
  frame.close();

  // Immediately schedule rendering of the next frame
  setTimeout(renderFrame, 0);
}

開発のヒント

Chrome DevTools の [Media] パネルを使用して、メディアログを表示し、WebCodec をデバッグします。

WebCodec をデバッグするためのメディアパネルのスクリーンショット
WebCodecs をデバッグするための Chrome DevTools の [Media] パネル

デモ

以下のデモは、キャンバスのアニメーション フレームがどのようになるかを示しています。

  • MediaStreamTrackProcessor によって 25 fps で ReadableStream にキャプチャされました
  • ウェブワーカーに転送
  • H.264 動画形式にエンコード
  • 一連の動画フレームに再度デコードされ、
  • transferControlToOffscreen() を使用して 2 つ目のキャンバスにレンダリングされます。

その他のデモ

その他のデモもご覧ください。

WebCodecs API の使用

機能検出

WebCodecs のサポートを確認するには:

if ('VideoEncoder' in window) {
  // WebCodecs API is supported.
}

WebCodecs API はセキュア コンテキストでのみ使用できるため、self.isSecureContext が false の場合、検出に失敗します。

フィードバック

Chrome チームでは、WebCodecs API の使用体験をお伺いしたいと存じます。

API の設計についてお聞かせください

API に想定したとおりに動作しない点はありますか。あるいは、アイデアを実装するために必要なメソッドやプロパティが欠落していないか。セキュリティ モデルについてご質問やご意見がある場合は、対応する GitHub リポジトリで仕様の問題を提出するか、既存の問題にご意見をお寄せください。

実装に関する問題を報告する

Chrome の実装にバグが見つかりましたか?それとも 実装が仕様と異なっているか?new.crbug.com でバグを報告します。できる限り詳細な情報と再現手順を記載し、[Components] ボックスに「Blink>Media>WebCodecs」と入力します。Glitch を使えば、再現をすばやく簡単に共有できます。

API のサポートを表示する

WebCodecs API を使用する予定はありますか?公開サポートにより、Chrome チームは機能の優先順位付けを行い、他のブラウザ ベンダーはサポートがいかに重要であるかを示します。

media-dev@chromium.org にメールを送信するか、ハッシュタグ #WebCodecs を使用して @ChromiumDev 宛てにツイートを送信し、使用場所と使用方法をお知らせください。

ヒーロー画像(作成者: Denise JansUnsplash