運用 WebAssembly 擴充瀏覽器

WebAssembly 可讓我們透過新功能擴充瀏覽器。本文將說明如何移植 AV1 影片解碼器,並在任何新式瀏覽器中播放 AV1 影片。

Alex Danilo

WebAssembly 最棒的優點之一,就是在瀏覽器原生提供這些功能 (如果有提供的話) 之前,您可以嘗試使用新功能並實作新概念。您可以將這種使用 WebAssembly 的方式視為高效能 polyfill 機制,在這種機制中,您可以使用 C/C++ 或 Rust 編寫功能,而非 JavaScript。

由於現有程式碼可用於移植,因此在瀏覽器中執行的操作可行,這在 WebAssembly 推出前是不可能的。

本文將透過範例說明如何使用現有的 AV1 影片編碼器原始碼、建構包裝函式,並在瀏覽器中試用,以及建構測試裝置的提示,以便對包裝函式進行偵錯。如需參考本範例的完整原始碼,請前往 github.com/GoogleChromeLabs/wasm-av1

請下載這兩個 24fps 測試影片檔案,並在我們建構的示範影片中試用。

選擇有興趣的程式碼基底

多年以來,我們發現網路上有很大比例的流量都包含影片資料,Cisco 估計這項比例高達 80%!當然,瀏覽器供應商和影片網站都非常清楚,使用者希望減少所有影片內容所消耗的資料量。當然,這項技術的關鍵在於更優異的壓縮技術,而如您所知,我們也投入許多研究,致力於開發下一代影片壓縮技術,以便減少透過網際網路傳送影片的資料負擔。

事實上,開放媒體聯盟一直在開發名為 AV1 的新一代影片壓縮方案,可大幅縮減影片資料大小。我們預期瀏覽器日後會提供 AV1 的原生支援,但幸好壓縮器和解壓縮器的原始碼是開放原始碼,因此很適合嘗試將其編譯到 WebAssembly,以便透過瀏覽器試用。

兔寶寶電影圖片。

調整以便在瀏覽器中使用

為了將這個程式碼放入瀏覽器,我們首先要做的事之一,就是瞭解現有的程式碼,以便瞭解 API 的運作方式。第一次查看這段程式碼時,有兩件事特別:

  1. 來源樹狀結構是使用名為 cmake 的工具建構;
  2. 許多範例都假設某種檔案型介面。

所有預設建構的範例都可以在指令列上執行,社群中提供的許多其他程式碼庫也可能會如此。因此,我們要建構的介面可讓應用程式在瀏覽器中執行,這對許多其他指令列工具也相當實用。

使用 cmake 建構原始碼

幸運的是,AV1 作者一直在實驗 Emscripten,也就是我們用來建構 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。我們將以 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.cmake (屬於 Emscripten SDK),並將其路徑做為參數傳遞至 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 工具鏈說明檔案的路徑。

為方便起見,我們使用 Shell 指令碼找出該檔案:

#!/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 等) 使用。

我們也需要定義名為 DS_set_blob 的輔助函式,將原始二進位資料繫結至串流 I/O 函式。這樣一來,系統就能「讀取」 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 進行建構時,建議您為所用程式碼的介面建立某種單元測試,這樣就能在瀏覽器外進行偵錯,並測試已建構的介面。

在本範例中,我們模擬了以串流為基礎的 API,做為 AV1 程式庫的介面。因此,在邏輯上,我們可以建立測試輔助程式,用於建構在指令列上執行的 API 版本,並在 DATA_Source API 底下實作檔案 I/O,以便在幕後執行實際的檔案 I/O 作業。

測試輔助程式的串流 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 個影格。然後每次顯示影格時,我們會嘗試解碼另一個影格,以便保持緩衝區已滿。這種做法可確保預先提供影格,以便停止影片的卡頓情形。

在這個簡單的示例中,您可以讀取整個壓縮影片,因此不需要進行緩衝處理。不過,如果要擴充來源資料介面以支援來自伺服器的串流輸入內容,就必須採用緩衝機制。

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 環境中,所有項目都是由三角形組成。因此,我們可以使用 WebGL 內建的便利功能 gl.TRIANGLE_FAN。

不過,這裡有個小問題。WebGL 紋理應為 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);
    }
}

您可以在來源檔案 draw-image.js 中找到實作 WebGL 繪製的 drawImageToCanvas() 函式。

後續作業和重點

在兩個測試影片檔案 (以 24 fps 錄製的影片) 上試用示範,我們發現了幾件事:

  1. 您完全可以建構複雜的程式碼庫,以便使用 WebAssembly 在瀏覽器中執行高效能作業。
  2. 透過 WebAssembly,您可以執行 CPU 密集的進階影片解碼作業。

不過,這項做法也有某些限制:實作項目全都會在主執行緒上執行,且我們會在該單一執行緒上交錯繪圖和影片解碼作業。將解碼卸載至網路工作站將能提供更順暢的播放體驗,因為解碼影格的時間高度取決於影格的內容,有時花費的時間可能會超過預算。

編譯至 WebAssembly 時,會使用一般 CPU 類型的 AV1 設定。如果我們在指令列上為通用 CPU 原生編譯,會發現解碼影片的 CPU 負載與 WebAssembly 版本相似,但 AV1 解碼器程式庫也包含SIMD 實作,執行速度最高可快上 5 倍。WebAssembly Community Group 目前正在努力擴展標準,以納入 SIMD 基元,而如果達成此標準,就能大幅加快解碼速度。屆時,透過 WebAssembly 影片解碼器即時解碼 4K HD 影片將完全可行。

無論如何,範例程式碼可做為指南,協助您將任何現有的指令列公用程式移植為 WebAssembly 模組,並顯示目前網站上可行的功能。

抵免額

感謝 Jeff Posnick、Eric Bidelman 和 Thomas Steiner 提供寶貴的評論和意見回饋。