WebAssembly によるブラウザの拡張

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

Alex Danilo

WebAssembly の優れた点の一つは、新しい機能を試し、ブラウザがネイティブにこれらの機能を提供する前に新しいアイデアを実装できることです。このように WebAssembly を使用することは、高パフォーマンスのポリフィル メカニズムとして考えることができます。この場合、機能を JavaScript ではなく C/C++ または Rust で記述します。

移植可能な既存のコードが豊富にあるため、WebAssembly が登場するまで実現できなかったことをブラウザで実行できます。

この記事では、既存の AV1 動画コーデックのソースコードを取得し、ラッパーをビルドしてブラウザ内で試す方法の例と、ラッパーをデバッグするためのテストハーネスの構築に役立つヒントを説明します。この例のソースコード全体については、github.com/GoogleChromeLabs/wasm-av1 をご覧ください。

次の 2 つの 24 fps テスト動画ファイルのいずれかをダウンロードし、組み込みのデモで試します。

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

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

これに伴い、Alliance for Open MediaAV1 と呼ばれる次世代の動画圧縮スキームの開発に取り組んでおり、動画データのサイズを大幅に縮小することを約束しています。今後、ブラウザで AV1 のネイティブ サポートが提供される予定ですが、幸い、圧縮ツールと圧縮解除ツールのソースコードはオープンソースです。そのため、WebAssembly にコンパイルしてブラウザでテストするのに適しています。

ウサギの動画画像。

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

このコードをブラウザに導入するために最初に行うべきことの一つは、既存のコードを理解して API がどのようなものかを理解することです。初めてこのコードを見たとき、次の 2 点に気付きました。

  1. ソースツリーは cmake というツールを使用して構築されます。
  2. いくつかの例では、なんらかのファイルベースのインターフェースを前提としています。

デフォルトでビルドされる例はすべて、コマンドラインで実行できます。これは、コミュニティで利用可能な他の多くのコードベースでも同様です。そのため、ブラウザで実行できるように作成するインターフェースは、他の多くのコマンドライン ツールでも役立ちます。

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

AV1 の作成者は、WebAssembly バージョンをビルドするために使用する SDK である Emscripten をテストしてきました。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 ツールチェーンは、2 つの形式で出力を生成できます。1 つは asm.js で、もう 1 つは WebAssembly です。WebAssembly は出力が小さく、実行速度が速いため、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) に実装します。

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

コマンドラインでは、ファイル 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 モジュールをビルドできます。テストハーネスのコードは、サンプル ソースファイル test.c にあります。

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

動画を再生する際は、スムーズな再生を実現するために、数フレームをバッファリングするのが一般的です。ここでは、10 フレームの動画バッファを実装します。つまり、再生を開始する前に 10 フレームをバッファリングします。フレームが表示されるたびに、別のフレームのデコードを試みて、バッファを常に満杯に保ちます。このアプローチでは、動画の途切れを防ぐために、事前にフレームを用意します。

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

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

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 の世界では、すべてが三角形で構成されています。この場合は、gl.TRIANGLE_FAN という WebGL の便利な組み込み機能を使用できます。

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

そのために、ソースファイル yuv-to-rgb.c にある関数 AVX_YUV_to_RGB() を実装します。この関数は、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. 高度な動画デコードのように CPU 使用率の高い処理は、WebAssembly で実行できます。

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

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

いずれにしても、サンプルコードは、既存のコマンドライン ユーティリティを WebAssembly モジュールとして実行できるように移植する際のガイドとして役立ちます。また、現在ウェブで可能なことを示しています。

クレジット

貴重なレビューとフィードバックをいただいた Jeff Posnick、Eric Bidelman、Thomas Steiner の皆様に感謝いたします。