WebAssembly によるブラウザの拡張

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

Alex Danilo

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

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

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

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

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

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

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

ウサギの動画の画像。

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

このコードをブラウザに組み込むために最初に行う必要があるのは、既存のコードを理解して API の概要を把握することです。このコードを最初に見たときは、次の 2 つの点が目立ちます。

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

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

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

幸い、AV1 の作成者は Emscripten をテストしています。この SDK は、WebAssembly バージョンのビルドに使用します。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) に実装します。

ブラウザはファイル システムからファイルを読み取ることができないため、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 モジュールをビルドできます。テストハーネスのコードは、サンプル ソースファイル 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 テクスチャは RGB 画像で、カラー チャンネルごとに 1 バイトです。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 用にコマンドライン上でネイティブにコンパイルすると、WebAssembly バージョンと同様に動画のデコードに同様の CPU 負荷がかかりますが、AV1 デコーダ ライブラリには、最大 5 倍高速に実行される SIMD 実装も含まれています。WebAssembly コミュニティ グループは現在、標準を拡張して SIMD プリミティブを含めるよう取り組んでいます。これが実現すると、デコードが大幅に高速化される見込みです。そうなれば、WebAssembly 動画デコーダから 4K HD 動画をリアルタイムでデコードすることが完全に可能になります。

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

クレジット

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