運用 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 社群小組目前正致力於擴充標準,納入SIMD 基本元素,屆時將可大幅加快解碼速度。屆時,透過 WebAssembly 影片解碼器即時解碼 4K HD 影片將完全可行。

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

抵免額

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