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 を受け取るコンストラクタを持つため、他の Web API とうまく連携します。そのため、drawImage()texImage2D() などの関数で使用できます。また、キャンバス、ビットマップ、動画要素、その他の動画フレームから構築することもできます。

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

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

WebCodecs とウェブワーカー

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

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

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

WebCodecs の動作

エンコード

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 からフレームをプルする

    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 Panel] を使用して、メディアログを表示し、WebCodecs をデバッグします。

WebCodecs のデバッグ用のメディア パネルのスクリーンショット
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 でバグを報告します。 できる限り詳細な情報、再現手順を記載し、[コンポーネント] ボックスに Blink>Media>WebCodecs と入力してください。

API のサポートを表示する

WebCodecs API を使用する予定はありますか?公開サポートは、Chrome チームが機能の優先順位を決定するのに役立ち、他のブラウザ ベンダーにサポートの重要性を示すことができます。

media-dev@chromium.org にメールを送信するか、ハッシュタグ #WebCodecs を使用して @ChromiumDev にツイートを送信し、どこでどのように使用しているかをお知らせください。

ヒーロー画像: Denise JansUnsplash