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 は、WebCodec をメディア ストリーム トラックに接続する 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() を呼び出して、構成がサポートされているかどうかを事前に確認し、そのプロミスを待機することをおすすめします。

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() を呼び出してプロミスを待機します。

await encoder.flush();

デコード

ネットワークまたはストレージからキャンバスまたは 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: チャンクを前のチャンクから独立してデコードできる場合
    • delta: チャンクをデコードできるのは、前の 1 つ以上のチャンクがデコードされた後の場合

また、エンコーダによって出力されたチャンクは、そのままデコーダで使用できます。エラー レポートとエンコーダのメソッドの非同期性について上記で説明した内容は、デコーダにも同様に当てはまります。

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

WebCodecs のデバッグ用メディアパネルのスクリーンショット
WebCodecs のデバッグ用 Chrome DevTools のメディアパネル。

デモ

次のデモは、キャンバスのアニメーション フレームを示しています。

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

その他のデモ

他にも、以下のデモもご覧ください。

WebCodecs API の使用

特徴検出

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

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 を入力します。Glitch は、簡単な再現手順をすばやく共有するのに適しています。

API のサポートを表示する

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

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

ヒーロー画像: UnsplashDenise Jans による