運用 WebAssembly 擴充瀏覽器

WebAssembly 讓我們利用新功能來擴充瀏覽器。本文說明如何移植 AV1 影片解碼器,以在任何新型瀏覽器中播放 AV1 影片。

Alex Danilo

WebAssembly 最出色的特色之一,就是能測試新功能,並在瀏覽器原生推送這些功能之前實作新概念。您可以將 WebAssembly 視為高效能 polyfill 機制,也就是使用 C/C++ 或 Rust 編寫功能,而非 JavaScript。

由於有許多現有程式碼可供移植,因此您或許可以在瀏覽器中執行某些作業,直到 WebAssembly 也在採用 WebAssembly 時才行。

本文將逐步舉例說明如何擷取現有的 AV1 影片轉碼器原始碼、為包裝函式建構包裝函式,並在瀏覽器中試用這個包裝函式,以及用於建構測試控管工具對包裝函式偵錯的提示。此處範例的完整原始碼可在 github.com/GoogleChromeLabs/wasm-av1 取得。

請下載這兩個 24 FPS 測試影片 檔案的其中一個,並在建構的示範上試用。

選擇有趣的程式碼集

多年來,我們發現大部分網路流量都含有影片資料,Cisco 也已估算高達 80% 的流量!當然,瀏覽器廠商和影片網站也非常瞭解 想要減少所有影片內容所使用的資料當然,壓縮效能的關鍵在於壓縮效果較佳,正如您預期,有許多研究要針對新一代影片壓縮功能進行大量研究,目標是降低網際網路上傳輸影片的資料負擔。

發生這種情況時,Open Media Alliance 持續開發名為 AV1 的新一代影片壓縮配置,承諾可以大幅縮減影片資料大小。未來我們預期瀏覽器會提供 AV1 的原生支援,但幸好壓縮程式和解壓縮器的原始碼是開放原始碼,因此是嘗試將其編譯為 WebAssemb 的理想人選,以便在瀏覽器中試用此工具。

兔子電影圖片。

進行調整,以在瀏覽器中使用

首先,要將這段程式碼傳入瀏覽器,是您需要瞭解現有的程式碼,瞭解 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,因為 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 本身的參數傳遞。我們用來產生 Makefiles 的指令列如下:

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 等)。

我們也必須定義名為 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 圖片,每個顏色管道各 1 個位元組。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 f.p.s. 影片),讓我們瞭解幾件事:

  1. 只要使用 WebAssembly,就能打造出複雜的程式碼集,在瀏覽器中運作順暢。
  2. 不過,您可以使用 WebAssembly 進行進階影片解碼,而耗用大量 CPU 資源。

但有一些限制:實作項目都會在主執行緒上執行,而我們會在該執行緒上交錯繪製和影片解碼。將解碼作業卸載至網路工作站可為我們帶來更流暢的播放體驗,因為解碼影格的時間會大幅取決於該影格的內容,有時可能會超過我們預算。

編譯到 WebAssembly 時,會將 AV1 設定用於一般 CPU 類型。如果我們在指令列上對一般 CPU 進行原生編譯,就會發現 CPU 負載與 WebAssembly 版本相同,因此會像使用 WebAssembly 版本一樣將影片解碼。不過,AV1 解碼器程式庫也包含可加快執行 5 倍的 SIMD 實作項目。WebAssembly Community Group 目前正在擴展此標準,納入 SIMD 基本功能,而在那之後,我們會保證能夠加快解碼速度。在這種情況下,您完全可以透過 WebAssembly 影片解碼器即時解碼 4K HD 影片。

在任何情況下,範例程式碼都能做為指南,協助您攜碼轉移任何現有的指令列公用程式,使其以 WebAssembly 模組的形式執行,並展示目前的網路可能實現的內容。

抵免額

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