WebAssembly によるブラウザの拡張

WebAssembly を使用すると、新機能でブラウザを拡張できます。この記事では、AV1 動画デコーダを移植し、最新のブラウザで AV1 動画を再生する方法について説明します。

Alex Danilo

WebAssembly の優れた点の 1 つは、ブラウザが新機能を(もしあれば)ネイティブにリリースする前に新機能を試し、新しいアイデアを実装できることです。このように WebAssembly を使用することは、JavaScript ではなく C/C++ または Rust で機能を記述する高性能なポリフィル メカニズムと考えることができます。

移植可能な既存のコードが多数あるため、WebAssembly が登場するまでは実現できなかった機能をブラウザで実現できます。

この記事では、既存の AV1 動画コーデックのソースコードを取得し、そのラッパーを作成してブラウザ内で試す方法、ラッパーをデバッグするためのテストハーネスをビルドする方法の例について説明します。この例の完全なソースコードは、github.com/GoogleChromeLabs/wasm-av1 で確認できます。

これら 2 つの 24 fps のテスト動画 ファイルのいずれかをダウンロードして、Google が用意したデモで試してください。

興味深いコードベースを選択する

ここ数年、ウェブ上のトラフィックの大部分が動画データで構成されていることがわかってきましたが、Cisco は 80% を推定しています。もちろん、ブラウザのベンダーや動画サイトは、すべての動画コンテンツで消費されるデータを削減したいという要望をよく認識しています。もちろん、そのための鍵は圧縮率の向上です。インターネットに動画を配信する際のデータ負担を軽減することを目的とした、次世代の動画圧縮については多くの研究がなされています。

これに対し、Alliance for Open Media は、動画データサイズを大幅に縮小できる AV1 という次世代の動画圧縮スキームの開発に取り組んでいます。将来的には、ブラウザで AV1 のネイティブ サポートが提供されることが予想されますが、圧縮と解凍のソースコードはオープンソースであるため、WebAssembly にコンパイルしてブラウザでテストする場合に最適です。

ウサギの映画の画像。

ブラウザでの使用に適応する

このコードをブラウザに実装するために最初に行う必要があることの 1 つは、既存のコードについて把握し、API がどのようなものであるかを理解することです。このコードから見てみると、2 つの注目すべき点があります。

  1. ソースツリーは cmake というツールを使用して作成されます。
  2. なんらかのファイルベースのインターフェースを想定している例がいくつかあります。

デフォルトでビルドされる例はすべてコマンドラインで実行できます。これは、コミュニティで利用可能な他の多くのコードベースでも当てはまる可能性があります。そのため、これから構築するインターフェースをブラウザで実行できるようにすると、他の多くのコマンドライン ツールでも役に立つ可能性があります。

cmake を使用してソースコードをビルドする

幸いなことに、AV1 の作成者は Emscripten を使用してテストしてきました。Emscripten は、Google が WebAssembly バージョンの構築に使用する SDK です。AV1 リポジトリのルートにある CMakeLists.txt ファイルには、次のビルドルールが含まれています。

if(EMSCRIPTEN)
add_preproc_definition(_POSIX_SOURCE)
append_link_flag_to_target("inspect" "-s TOTAL_MEMORY=402653184")
append_link_flag_to_target("inspect" "-s MODULARIZE=1")
append_link_flag_to_target("inspect"
                            "-s EXPORT_NAME=\"\'DecoderModule\'\"")
append_link_flag_to_target("inspect" "--memory-init-file 0")

if("${CMAKE_BUILD_TYPE}" STREQUAL "")
    # Default to -O3 when no build type is specified.
    append_compiler_flag("-O3")
endif()
em_link_post_js(inspect "${AOM_ROOT}/tools/inspect-post.js")
endif()

Emscripten ツールチェーンは、asm.js と WebAssembly という 2 つの形式で出力を生成できます。ここでは、出力が小さく、実行速度が速い WebAssembly を対象にします。これらの既存のビルドルールは、動画ファイルの内容を調べるインスペクタ アプリで使用するために asm.js バージョンのライブラリをコンパイルすることを目的としています。ここでは WebAssembly の出力が必要なため、上記のルールの終了 endif() ステートメントの直前にこれらの行を追加します。

# Force generation of Wasm instead of asm.js
append_link_flag_to_target("inspect" "-s WASM=1")
append_compiler_flag("-s WASM=1")

cmake を使用してビルドするには、まず cmake 自体を実行して Makefiles を生成し、次にコンパイル手順を実行する make コマンドを実行します。なお、ここでは Emscripten を使用しているため、デフォルトのホスト コンパイラではなく Emscripten コンパイラ ツールチェーンを使用する必要があります。そのためには、Emscripten SDK の一部である Emscripten.cmake を使用し、そのパスをパラメータとして cmake に渡します。以下のコマンドラインを使用して Makefile を生成します。

cmake path/to/aom \
  -DENABLE_CCACHE=1 -DAOM_TARGET_CPU=generic -DENABLE_DOCS=0 \
  -DCONFIG_ACCOUNTING=1 -DCONFIG_INSPECTION=1 -DCONFIG_MULTITHREAD=0 \
  -DCONFIG_RUNTIME_CPU_DETECT=0 -DCONFIG_UNIT_TESTS=0
  -DCONFIG_WEBM_IO=0 \
  -DCMAKE_TOOLCHAIN_FILE=path/to/emsdk-portable/.../Emscripten.cmake

パラメータ path/to/aom は、AV1 ライブラリのソースファイルの場所のフルパスに設定する必要があります。path/to/emsdk-portable/…/Emscripten.cmake パラメータは、Emscripten.cmake ツールチェーンの説明ファイルのパスに設定する必要があります。

ここでは、便宜上、シェル スクリプトを使用してこのファイルの場所を特定します。

#!/bin/sh
EMCC_LOC=`which emcc`
EMSDK_LOC=`echo $EMCC_LOC | sed 's?/emscripten/[0-9.]*/emcc??'`
EMCMAKE_LOC=`find $EMSDK_LOC -name Emscripten.cmake -print`
echo $EMCMAKE_LOC

このプロジェクトのトップレベルの Makefile を見ると、そのスクリプトがビルドの構成にどのように使用されているかがわかります。

すべての設定が完了したので、あとは make を呼び出します。これはサンプルを含むソースツリー全体をビルドしますが、最も重要な点は、コンパイルされ、プロジェクトに組み込む準備ができた動画デコーダを含む libaom.a を生成することです。

ライブラリとのインターフェースとなる API の設計

ライブラリを作成したら、圧縮動画データをライブラリに送信し、ブラウザで表示できる動画のフレームを読み取るようにライブラリとやり取りする方法を検討する必要があります。

AV1 コードツリーを確認すると、[simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c) ファイルにある動画デコーダの例から始めることをおすすめします。このデコーダは IVF ファイルを読み取り、動画内のフレームを表す一連の画像にデコードします。

インターフェースはソースファイル [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c) に実装しています。

ブラウザはファイル システムからファイルを読み取ることができないため、I/O を抽象化できるなんらかのインターフェースを設計する必要があります。これにより、サンプルのデコーダと同様のものを構築して AV1 ライブラリにデータを取り込むことができます。

コマンドラインでは、ファイル I/O がストリーム インターフェースと呼ばれるものです。したがって、ストリーム I/O のような独自のインターフェースを定義するだけで、基盤となる実装で自由にビルドできます。

このインターフェースは次のように定義します。

DATA_Source *DS_open(const char *what);
size_t      DS_read(DATA_Source *ds,
                    unsigned char *buf, size_t bytes);
int         DS_empty(DATA_Source *ds);
void        DS_close(DATA_Source *ds);
// Helper function for blob support
void        DS_set_blob(DATA_Source *ds, void *buf, size_t len);

open/read/empty/close 関数は通常のファイル I/O オペレーションとよく似ているため、コマンドライン アプリケーションのファイル I/O に簡単にマッピングできます。また、ブラウザ内で実行する場合は別の方法で実装できます。DATA_Source 型は JavaScript 側からは不透明で、インターフェースをカプセル化するだけです。ファイル セマンティクスに厳密に従った API を作成すると、コマンドラインからの使用を意図した他の多くのコードベース(diff、sed など)で再利用しやすくなります。

また、未加工のバイナリデータをストリーム I/O 関数にバインドする DS_set_blob というヘルパー関数を定義する必要があります。これにより、blob はストリームであるかのように(つまり、順次読み取られるファイルのように)「読み取り」を行うことができます。

実装例では、渡された blob を、シーケンシャルに読み取られるデータソースであるかのように読み取ることができます。リファレンス コードは blob-api.c ファイルで確認できます。実装全体は次のようになります。

struct DATA_Source {
    void        *ds_Buf;
    size_t      ds_Len;
    size_t      ds_Pos;
};

DATA_Source *
DS_open(const char *what) {
    DATA_Source     *ds;

    ds = malloc(sizeof *ds);
    if (ds != NULL) {
        memset(ds, 0, sizeof *ds);
    }
    return ds;
}

size_t
DS_read(DATA_Source *ds, unsigned char *buf, size_t bytes) {
    if (DS_empty(ds) || buf == NULL) {
        return 0;
    }
    if (bytes > (ds->ds_Len - ds->ds_Pos)) {
        bytes = ds->ds_Len - ds->ds_Pos;
    }
    memcpy(buf, &ds->ds_Buf[ds->ds_Pos], bytes);
    ds->ds_Pos += bytes;

    return bytes;
}

int
DS_empty(DATA_Source *ds) {
    return ds->ds_Pos >= ds->ds_Len;
}

void
DS_close(DATA_Source *ds) {
    free(ds);
}

void
DS_set_blob(DATA_Source *ds, void *buf, size_t len) {
    ds->ds_Buf = buf;
    ds->ds_Len = len;
    ds->ds_Pos = 0;
}

ブラウザの外部でテストするテストハーネスの構築

ソフトウェア エンジニアリングのベスト プラクティスの一つは、統合テストとともにコードの単体テストを作成することです。

ブラウザで WebAssembly を使用してビルドする場合は、ブラウザの外部でデバッグしたり、ビルドしたインターフェースをテストしたりできるように、作業中のコードに対するインターフェースに対してなんらかの形の単体テストをビルドするのが合理的です。

この例では、AV1 ライブラリへのインターフェースとしてストリーム ベースの API をエミュレートしています。したがって、コマンドラインで実行され、DATA_Source API の下にファイル I/O 自体を実装することで、内部で実際のファイル I/O を行うバージョンの API をビルドするために使用できるテストハーネスをビルドすることは、論理的には理にかなっています。

テストハーネスのストリーム I/O コードは簡単で、次のようになります。

DATA_Source *
DS_open(const char *what) {
    return (DATA_Source *)fopen(what, "rb");
}

size_t
DS_read(DATA_Source *ds, unsigned char *buf, size_t bytes) {
    return fread(buf, 1, bytes, (FILE *)ds);
}

int
DS_empty(DATA_Source *ds) {
    return feof((FILE *)ds);
}

void
DS_close(DATA_Source *ds) {
    fclose((FILE *)ds);
}

ストリーム インターフェースを抽象化することで、WebAssembly モジュールを構築して、ブラウザ内ではバイナリデータ blob を使用し、コマンドラインからテストするコードをビルドする際に実際のファイルとのインターフェースを使用できます。テストハーネスのコードは、サンプルのソースファイル test.c にあります。

複数の動画フレームのバッファリング メカニズムを実装する

動画を再生するときは、スムーズに再生できるように数フレームをバッファリングするのが一般的です。ここでは動画の 10 フレーム分のバッファを実装するだけで、再生を開始する前に 10 フレームをバッファリングします。次に、フレームが表示されるたびに、バッファがいっぱいになるように別のフレームのデコードを試みます。このアプローチにより、事前にフレームが利用可能であることを確認して、動画スタッタリングを防止できます。

この簡単な例では、圧縮された動画全体を読み取ることができるため、バッファリングは実際には必要ありません。ただし、サーバーからのストリーミング入力をサポートするようにソースデータ インターフェースを拡張する場合は、バッファリング メカニズムを実装する必要があります。

AV1 ライブラリから動画データのフレームを読み取り、次のようにバッファに格納するための decode-av1.c のコード。

void
AVX_Decoder_run(AVX_Decoder *ad) {
    ...
    // Try to decode an image from the compressed stream, and buffer
    while (ad->ad_NumBuffered < NUM_FRAMES_BUFFERED) {
        ad->ad_Image = aom_codec_get_frame(&ad->ad_Codec,
                                           &ad->ad_Iterator);
        if (ad->ad_Image == NULL) {
            break;
        }
        else {
            buffer_frame(ad);
        }
    }


バッファには 10 フレームの動画を含めることにしましたが、これは任意の選択です。バッファリングするフレームが多いほど、動画の再生開始までの待機時間が長くなります。一方、バッファリングするフレームが少なすぎると、再生中にストールする可能性があります。ネイティブ ブラウザの実装では、フレームのバッファリングはこの実装よりもはるかに複雑です。

WebGL を使用して動画フレームをページに表示する

バッファした動画のフレームがページに表示される必要があります。これは動的動画コンテンツであるため、できる限り迅速に処理できるようにしたいと考えています。そのために、WebGL を使用します。

WebGL により、動画フレームなどの画像を取得し、それをテクスチャとして使用し、ジオメトリにペイントできます。WebGL では すべてが三角形で構成されますここでは、WebGL の組み込み機能である gl.TRIANGLE_FAN を使用できます。

ただし、小さな問題があります。WebGL テクスチャは、カラーチャネルごとに 1 バイトの RGB 画像を想定しています。AV1 デコーダからの出力は、いわゆる YUV 形式の画像です。デフォルトの出力はチャンネルあたり 16 ビットで、各 U 値または V 値は実際の出力画像の 4 ピクセルに対応しています。つまり、画像を WebGL に渡して表示できるようにするには、画像の色変換を行う必要があります。

そのために関数 AVX_YUV_to_RGB() を実装します。この関数はソースファイル yuv-to-rgb.c にあります。この関数は、AV1 デコーダからの出力を WebGL に渡すことができる値に変換します。この関数を JavaScript から呼び出す場合、変換後の画像を書き込むメモリが WebAssembly モジュールのメモリ内に割り当てられていることを確認する必要があります。そうしないと、アクセスできません。WebAssembly モジュールから画像を取得して画面に描画する関数は次のとおりです。

function show_frame(af) {
    if (rgb_image != 0) {
        // Convert The 16-bit YUV to 8-bit RGB
        let buf = Module._AVX_Video_Frame_get_buffer(af);
        Module._AVX_YUV_to_RGB(rgb_image, buf, WIDTH, HEIGHT);
        // Paint the image onto the canvas
        drawImageToCanvas(new Uint8Array(Module.HEAPU8.buffer,
                rgb_image, 3 * WIDTH * HEIGHT), WIDTH, HEIGHT);
    }
}

WebGL ペイントを実装する drawImageToCanvas() 関数は、参考までにソースファイル draw-image.js で確認できます。

今後の取り組みとまとめ

このデモを 2 つのテスト動画 ファイル(24 fps の動画として録画)で試すと、いくつかのことがわかります。

  1. WebAssembly を使用してブラウザのパフォーマンスを向上させる複雑なコードベースを構築することは完全に可能です。
  2. WebAssembly を使用すると、高度な動画デコードのように CPU 負荷の高い処理が可能になります。

ただし、いくつかの制限があります。実装はすべてメインスレッドで実行され、その単一のスレッドでペイントと動画のデコードがインターリーブされます。デコードをウェブ ワーカーにオフロードすると、フレームのデコードにかかる時間はフレームの内容に大きく依存し、予算よりも時間がかかることがあるため、よりスムーズに再生できるようになります。

WebAssembly へのコンパイルでは、汎用 CPU タイプの AV1 構成を使用します。一般的な CPU のコマンドラインでネイティブにコンパイルする場合、動画をデコードする際の CPU 負荷は、WebAssembly バージョンの場合と同様ですが、AV1 デコーダ ライブラリには、最大 5 倍高速に動作する SIMD 実装も含まれています。WebAssembly Community Group は現在、この標準を拡張して SIMD プリミティブを含めるよう取り組んでいます。これにより、デコードの大幅な高速化が期待されます。その場合、WebAssembly 動画デコーダから 4K HD 動画をリアルタイムでデコードすることは完全に可能です。

いずれにせよ、このコード例は、既存のコマンドライン ユーティリティを WebAssembly モジュールとして実行できるようにするためのガイドとして有用で、ウェブ上ですでに利用可能な機能を示しています。

クレジット

貴重なレビューとフィードバックを提供してくれた Jeff Posnick、Eric Bidelman、Thomas Steiner に感謝します。