WebAssembly 可讓我們透過新功能擴充瀏覽器。本文將說明如何移植 AV1 影片解碼器,並在任何新式瀏覽器中播放 AV1 影片。
WebAssembly 最棒的優點之一,就是在瀏覽器原生提供這些功能 (如果有提供的話) 之前,您可以嘗試使用新功能並實作新概念。您可以將這種使用 WebAssembly 的方式視為高效能 polyfill 機制,在這種機制中,您可以使用 C/C++ 或 Rust 編寫功能,而非 JavaScript。
由於現有程式碼可用於移植,因此在瀏覽器中執行的操作可行,這在 WebAssembly 推出前是不可能的。
本文將透過範例說明如何使用現有的 AV1 影片編碼器原始碼、建構包裝函式,並在瀏覽器中試用,以及建構測試裝置的提示,以便對包裝函式進行偵錯。如需參考本範例的完整原始碼,請前往 github.com/GoogleChromeLabs/wasm-av1。
請下載這兩個 24fps 測試影片檔案,並在我們建構的示範影片中試用。
選擇有興趣的程式碼基底
多年以來,我們發現網路上有很大比例的流量都包含影片資料,Cisco 估計這項比例高達 80%!當然,瀏覽器供應商和影片網站都非常清楚,使用者希望減少所有影片內容所消耗的資料量。當然,這項技術的關鍵在於更優異的壓縮技術,而如您所知,我們也投入許多研究,致力於開發下一代影片壓縮技術,以便減少透過網際網路傳送影片的資料負擔。
事實上,開放媒體聯盟一直在開發名為 AV1 的新一代影片壓縮方案,可大幅縮減影片資料大小。我們預期瀏覽器日後會提供 AV1 的原生支援,但幸好壓縮器和解壓縮器的原始碼是開放原始碼,因此很適合嘗試將其編譯到 WebAssembly,以便透過瀏覽器試用。
調整以便在瀏覽器中使用
為了將這個程式碼放入瀏覽器,我們首先要做的事之一,就是瞭解現有的程式碼,以便瞭解 API 的運作方式。第一次查看這段程式碼時,有兩件事特別:
- 來源樹狀結構是使用名為
cmake
的工具建構; - 許多範例都假設某種檔案型介面。
所有預設建構的範例都可以在指令列上執行,社群中提供的許多其他程式碼庫也可能會如此。因此,我們要建構的介面可讓應用程式在瀏覽器中執行,這對許多其他指令列工具也相當實用。
使用 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 錄製的影片) 上試用示範,我們發現了幾件事:
- 您完全可以建構複雜的程式碼庫,以便使用 WebAssembly 在瀏覽器中執行高效能作業。
- 透過 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 提供寶貴的評論和意見回饋。