基於 FFmpeg 和 Wasm 的 Web 端視頻截幀方案

作者 | 小萱

導讀 

基於實際業務需求,介紹了自定義 Wasm 截幀方案的實現原理和實現方案。解決傳統的基於 canvas 的截幀方案所存在的問題,更高效靈活的實現截幀能力。

01 項目背景

在視頻編輯器裏常見這樣的功能,在用戶上傳完視頻後抽取關鍵幀 ,提供給用戶以便快捷選取封面,如下圖:

在本文中,我們將探討一種使用 FFmpeg 和 WebAssembly(Wasm)的 Web 端視頻截幀方案,以解決傳統的基於 canvas 的截幀方案所存在的問題。通過採用這種新方法,我們可以克服 video 標籤的限制,實現更高效、更靈活的視頻截幀功能。

首先,我們需要了解一下傳統的 Web 截幀方案的侷限性。雖然該方案在處理一些常見的視頻格式(如 MP4、WebM 和 OGG)時表現良好,但其存在以下缺陷:

爲解決上述問題,選取 FFmpeg+Wasm 的方案,通過自定義編譯 FFmpeg,在 web-worker 裏執行 rgb24 格式數據到 ImageData 的運算,再傳遞結果給主線程,實現。

02 Wasm 核心原理

2.1 Wasm 是什麼

用官網的話說,WebAssembly(縮寫爲 Wasm) 是一種用於基於堆棧的虛擬機的二進制指令格式。

WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.  

--- https://webassembly.org/

Wasm 可以看作一種容器技術,它定義了一種獨立的、可移植的虛擬機,可以在各種平臺上執行,類比於 docker,但更爲輕量。WebAssembly 於 2017 年粉墨登場,2019 年 12 月正式認證爲 Web 標準之一併被推薦,擁有高性能、跨平臺、安全性、多語言高可移植等優勢。

業界有很多 Wasm 虛擬機的實現,包含解釋器,單層 / 多層 AOT、JIT 模式。

2.2 chrome 如何運行 Wasm

瀏覽器內置 JIT 引擎,V8 使用了分層編譯模式(Tiered)來編譯和優化 WASM 代碼。分層編譯模式包括兩個主要的編譯器:

  1. 基線編譯器(Baseline compiler) Liftoff 編譯器

  2. 優化編譯器(Optimizing compiler) TurboFun 編譯器

2.2.1 Liftoff 編譯器

當 WASM 代碼首次加載時,V8 使用 Liftoff 編譯器進行快速編譯。Liftoff 是一個線性時間編譯器,它可以在極短的時間內爲每個 WASM 指令生成機器代碼。這意味着,它可以儘快地生成可執行代碼,從而縮短代碼加載時間。

然而,Liftoff 編譯器的優化空間有限。它採用一種簡單的一對一映射策略,將 WASM 指令獨立地轉換爲機器代碼,而不進行任何高級優化。這使得生成的代碼性能較低。

2.2.2 TurboFan 編譯器

對於那些被頻繁調用的熱函數(Hot Functions),V8 會使用 TurboFan 編譯器進行優化編譯。TurboFan 是一個更高級的編譯器,能夠執行各種複雜的優化技術,如內聯緩存(Inline Caching)、死代碼消除(Dead Code Elimination)、循環展開(Loop Unrolling)和常量摺疊(Constant Folding)等,從而顯著提高代碼的運行效率。

V8 會監控 WASM 函數的調用頻率。一旦一個函數達到特定的閾值,它就會被認爲是 Hot,並在後臺線程中觸發重新編譯。在優化編譯完成後,新生成的 TurboFan 代碼會替換原有的 Liftoff 代碼。之後對該函數的任何新調用都將使用 TurboFan 生成的新的優化代碼,而不是 Liftoff 代碼。

2.2.3 流式編譯與代碼緩存

V8 引擎支持流式編譯(Streaming Compilation),這意味着 WASM 代碼可以在下載的同時進行編譯。這大大縮短了從加載到可執行的總時間。流式編譯在基線編譯階段(Liftoff 編譯器)尤爲重要,因爲它可以確保 WASM 代碼在最短的時間內變得可運行。

爲了進一步提高性能和加載速度,V8 引擎支持代碼緩存(Code Caching)機制。代碼緩存可以將編譯後的 WASM 代碼存儲在緩存中,以便在將來需要時直接從緩存中加載,而無需重新編譯。這大大縮短了頁面加載時間,提高了用戶體驗。目前 WebAssembly 緩存僅針對流式 API 調用, compileStreaming 和 instantiateStreaming 這兩個 API,使用流式 API 擁有更好的性能。對於緩存的工作原理:

  1. 當 TurboFan 完成編譯後,如果. wasm 資源足夠大(128 kb),Chrome 會將編譯後的代碼寫入 WebAssembly 代碼緩存。

  2. 當. wasm 第二次請求資源時(hot run),Chrome.wasm 從資源緩存中加載資源,同時查詢代碼緩存。如果緩存命中,編譯後的 module bytes 將發送到渲染器進程並傳遞給 V8,V8 將其進行反序列化,與編譯相比,反序列化速度更快,佔用的 CPU 更少。

  3. 如果. wasm 資源發生了變化或是 V8 發生了變化,緩存會失效,緩存的本地代碼會從緩存中清除,編譯會像步驟 1 一樣繼續進行。

2.2.6 編譯管道(Compilation Pipeline)

△頻效果 V8 編譯 Wasm 的流程圖

V8 編譯 WASM 代碼的整個過程可以概括爲以下幾個步驟:

  1. 解碼(Decoding):首先,將 WASM 模塊解碼爲二進制可執行代碼,並驗證其是否符合 WASM 標準。

  2. 基線編譯(Baseline Compilation):接下來,使用 Liftoff 編譯器進行快速編譯。這一階段生成的代碼性能較低,但編譯速度快。流式編譯在這個階段發揮作用,使得代碼在下載過程中就能進行編譯。

  3. 熱點分析(Hotspot Analysis):V8 引擎會持續監控 WASM 函數的調用頻率,以識別 Hot Function。

  4. 優化編譯(Optimizing Compilation):對於被標記爲熱門函數的代碼,使用 TurboFan 編譯器進行優化編譯。編譯完成後,優化後的代碼會替換原有的 Liftoff 代碼。這一過程稱爲分層升級(Tier-up)。

  5. 執行(Execution):在優化編譯完成後,代碼將在 V8 引擎中運行。

對比 V8 執行 js 的流程,省去了Parser生成 ast,Ignition生成字節碼的的過程,因此有更高的性能和執行效率。

03 FFmpeg 的介紹

FFmpeg 作爲一個開源的強大的音視頻處理工具,實現視頻和音頻的錄製、轉換、編輯等多種功能。FFmpeg 包含了衆多的編碼庫和工具,可以處理各種格式的音視頻文件,例如 MPEG、AVI、FLV、WMV、MP4 等等。

FFmpeg 最初是由 Fabrice Bellard 於 2000 年創立的,現在它是由一個龐大的社區維護的開源軟件項目。FFmpeg 支持各種操作系統,包括 Windows、macOS、Linux 等,也支持各種硬件平臺,例如 x86、ARM 等。

FFmpeg 的功能非常強大,可以進行很多複雜的音視頻處理操作,例如視頻轉碼、視頻合併、音頻剪輯、音頻混合等等。FFmpeg 支持衆多編碼格式和協議,包括 H.264、HEVC、VP9、AAC、MP3 等等。同時,它還可以進行流媒體的處理,例如將視頻流推送到 RTMP 服務器、從 RTSP 服務器拉取視頻流等等。

04 截幀策略的制定

4.1 I、B、P 幀是什麼

這個概念來源於視頻編碼,爲描述視頻壓縮編碼中的幀類型。

I 幀(Intra-coded frame),也叫關鍵幀(keyframe),它是視頻序列中的一種獨立幀,也就是說,它不需要參考其它幀進行解碼。I 幀通常用來作爲視頻序列的參考點,後續的 B 幀和 P 幀都會參考它進行編碼。I 幀通常具有較高的壓縮比和較大的文件大小,但是它也提供了最高的圖像質量。

P 幀(Predictive-coded frame) 是通過對前面的 I 幀或 P 幀進行運動預測得到的幀,也就是說,P 幀需要參考前面的一個或多個幀進行解碼。P 幀通常比 I 幀小一些,但是它的壓縮比比 I 幀高。

B 幀(Bidirectionally-predictive-coded frame) 是通過對前面和後面的幀進行運動預測得到的幀,也就是說,B 幀需要參考前面和後面的幀進行解碼。B 幀通常比 P 幀更小,因爲它可以更充分地利用前後兩個參考幀之間的冗餘信息進行編碼。

因此,視頻編碼中通常會使用一種叫做 “三合一” 編碼的方式,即將一個 I 幀和它前面的若干個 P 幀以及後面的若干個 B 幀組成一個 GOP(Group of Pictures)。這樣的編碼方式既可以提高編碼的效率,也可以提供高質量的圖像。

△I、B、P 幀關係示例圖

4.2 關鍵幀生成策略

視頻編輯器抽幀的目的是爲用戶提供有效的封面圖選取,因此我們希望抽出來包含較大信息量質量較高的圖作爲抽幀產物,從上面的介紹可知,一般情況下關鍵幀是包含信息量較大的幀,因此理想狀態是隻產出關鍵幀。

按照需求場景,我們需要對每個視頻提取 12 張圖片。若使用 canvas 抽幀方案,就意味着這 12 張圖片只能根據時間間隔進行抽取,無法使用視頻本身的關鍵幀信息,圖片可能是關鍵幀,也可能是 BP 幀。非關鍵幀的圖片往往質量較差不適合作爲封面圖。且瀏覽器也需要基於 I 幀進行逐幀的解碼,這會耗費較長的時間。因此我們決定藉助 FFmpeg 庫的能力,生成關鍵幀。

爲什麼不直接使用 FFmpeg 的命令生成關鍵幀呢,一個視頻具體有多少張關鍵幀這是不一定的,可能多於 12 張也可能少於 12 張,因此只用 FFmpeg 的命令生成關鍵幀一把梭生成全部關鍵幀這是不夠的。

對於少於 12 張關鍵幀的視頻,採取補齊的策略,在兩關鍵幀之間,以 2s 爲時間間隔進行補齊。如果兩幀間隔時間不足 2s 間隔分配,那就按照兩關鍵幀間隔時間 / 在此間隔需要補的幀數,計算出需要補齊的幀的所在時間。

FFmpeg 在獲取關鍵幀是很快的,因爲關鍵幀的時間信息是可以直接從視頻裏獲取到的,可以直接調用av_seek_frame 跳到關鍵幀位置,然後解一幀即可,對於指定時間的非關鍵幀的尋找,需要跳到最近的關鍵幀,再一幀幀的解包尋找,知道尋找的指定的時間,進行輸出。

對於超出 12 幀關鍵幀的視頻,按照相等的間隔進行選取,比如有 24 張,那麼選取 0、2、...23 索引的幀爲輸出幀。

其他的優化點,第一幀一定是 I 幀,因此在第一時間讀取第一幀並返回,讓用戶瞬間看到一幀,減少視覺等待時間,其他幀每確定一幀是符合輸出幀就立即輸出,用戶看到的是一幀幀輸出的,而不是等到全部抽幀任務完成再輸出。

△百家號 wasm 抽幀效果圖

05 自定義編譯 FFmpeg

5.1 環境準備

Emscripten、LLVM、Clang 都可以將 c、cpp 代碼編譯成 Wasm,我們使用Emscripten編譯。Emscripten 會幫你生成膠水代碼 (.js 文件) 和 Wasm 文件。

首先下載 emsdk,執行以下命令配置並激活已安裝的 Emscripten。

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
  git pull
  ./emsdk install latest
  ./emsdk activate latest
   source ./emsdk_env.sh

最後 source 環境變量,配置 Emscripten 各個組件的 PATH 等環境變量。

5.2 編譯 FFmpeg

爲了產出能在以在瀏覽器中運行的 WebAssembly 版本的 FFmpeg,我們禁用了大部分針對特定平臺或體系結構的優化,以便生成儘可能兼容的 WebAssembly 代碼。

使用 Emscripten 的emconfigure命令運行 FFmpeg 的configure腳本,傳入自定義參數以便完成兼容。下面是自定義參數:

CFLAGS="-s USE_PTHREADS"
LDFLAGS="$CFLAGS -s INITIAL_MEMORY=33554432" # 33554432 bytes = 32 MB
CONFIG_ARGS=(
  --prefix=$WEB_CAPTURE_PATH/lib2/ffmpeg-emcc \
  --target-os=none        # use none to prevent any os specific configurations
  --arch=x86_32           # use x86_32 to achieve minimal architectural optimization
  --enable-cross-compile  # enable cross compile
  --disable-x86asm        # disable x86 asm
  --disable-inline-asm    # disable inline asm
  --disable-stripping     # disable stripping
  --disable-programs      # disable programs build (incl. ffplay, ffprobe & ffmpeg)
  --disable-doc           # disable doc
  --extra-cflags="$CFLAGS"
  --extra-cxxflags="$CFLAGS"
  --extra-ldflags="$LDFLAGS"
  --nm="llvm-nm-12"
  --ar=emar
  --ranlib=emranlib
  --cc=emcc
  --cxx=em++
  --objcc=emcc
  --dep-cc=emcc
)
cd $FFMPEG_PATH
emconfigure ./configure "${CONFIG_ARGS[@]}"

PS:上面我們允許了 C++ 使用 pthread,但因爲在瀏覽器使用 pthread 多線程需要SharedArrayBuffer 允許多個 Web Workers 或 WebAssembly 線程訪問和操作相同的內存區域,而SharedArrayBuffer的兼容性較差,並且要求 https,因此我們在接下來產出 wasm 時禁用 pthread。

FFmpeg 包含了很多庫,若直接使用 @ffmpeg/ffmpeg @ffmpeg/core 便是全量的庫的 wasm 版本。

  1. libavformat:負責多媒體文件和流的格式處理。這個庫可以幫助你讀取和寫入多種音頻和視頻文件格式,以及網絡流。

  2. libavcodec:負責音視頻編解碼。這個庫包含了衆多的音頻和視頻編解碼器,可以處理多種格式的音頻和視頻。

  3. libavutil:提供一些實用功能,例如內存管理、數學運算、時間處理等。這個庫被 libavformat 和 libavcodec 等其他庫所使用,用於輔助處理各種任務。

  4. libswscale:負責圖像的縮放和顏色空間轉換。這個庫可以幫助你將視頻幀從一種像素格式轉換爲另一種,或者對圖像進行縮放。

  5. libswresample:負責音頻重採樣、混合和格式轉換。這個庫用於處理音頻數據,例如改變採樣率、改變聲道數等。

  6. libavfilter:負責音視頻濾鏡處理。這個庫提供了一系列音視頻濾鏡,用於處理音頻和視頻,例如調整色彩、裁剪、添加水印等。

  7. libavdevice:負責獲取和輸出設備相關的操作。這個庫提供了對各種設備的支持,例如攝像頭、麥克風、屏幕捕捉等。

而我們抽幀只需要讀取視頻文件或流、解碼、對產生的像素格式轉換以及通用工具函數,也就是 libavformat、libavcodec、libswscale 和 libavutil 這幾個庫, 在接下來產出 wasm 我們便選取這幾個庫作爲編譯的輸入文件,可以大幅減少產出的 wasm 資源體積。

5.3 編譯產出. wasm、.js

Emscripten 支持產出多種格式文件,我們這裏使用他爲我們準備的膠水代碼,故生成. wasm 和. js 文件,

使用 emcc 命令編譯 cpp 代碼,首先通過Clang編譯爲LLVM 字節碼,然後根據不同的目標編譯爲asm.js或Wasm。由於內部調用Clang,因此emcc支持絕大多數的Clang編譯選項,比如-s OPTIONS=VALUE、-O、-g等。除此之外,爲了適應 Web 環境,emcc增加了一些特有的選項,如--pre-js 、--post-js 等。

emcc $WEB_CAPTURE_PATH/src/capture.c $FFMPEG_PATH/lib/libavformat.a $FFMPEG_PATH/lib/libavcodec.a $FFMPEG_PATH/lib/libswscale.a $FFMPEG_PATH/lib/libavutil.a \
    -O0 \
    # 使用workerfs文件系統
    -lworkerfs.js \
    # 講這個文件內連到膠水js裏面 共享上下文
    --pre-js $WEB_CAPTURE_PATH/dist/capture.worker.js \
    # 指定編譯入口路徑
    -I "$FFMPEG_PATH/include" \
    # 聲明編譯目標是wasm
    -s WASM=1 \
    -s TOTAL_MEMORY=$TOTAL_MEMORY \
    # 告訴編譯器我們希望從編譯後的代碼中訪問哪些內容(如果不使用,內容可能會被刪除)
    -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' \
    # 告訴編譯器需要塞到Module裏的方法
    -s EXPORTED_FUNCTIONS='["_main", "_free", "_captureByMs", "_captureByCount"]' \
    -s ASSERTIONS=0 \
    # 允許wasm的內存增長
    -s ALLOW_MEMORY_GROWTH=1 \
    # 產出路徑
    -o $WEB_CAPTURE_PATH/dist/capture.worker.js

Emscripten 提供了四種文件系統,默認是MEMFS(memory fs),其他都需要在編譯時候添加進來,-lnodefs.js (NODEFS), -lidbfs.js (IDBFS), -lworkerfs.js (WORKERFS), or -lproxyfs.js (PROXYFS)。我們在 worker 中運行 wasm,選取workerfs文件系統,它提供了在 worker 中的 file 和 Blob 對象的只讀訪問,而不需要將整個數據複製到內存中,可能用於巨大的文件,防止了文件過大導致的瀏覽器 crash。

生成的 js 裏面,Module 是全局 JavaScript 對象,Module 裏固有的方法,可以參考文檔Module object documentation,同時,你也可以通過 --pre-js 往 Module 裏添加方法,沒有塞入 Module 的方法可以通過EXPORTED_FUNCTIONS添加。

△Module 內方法的定義

5.4 Js 和 C 的通信

5.4.1 Js 調用 C

JavaScript 調用 C 只能使用Number作爲參數,因此如果參數是數組、對象等非Number類型,就麻煩了,使用Module._malloc()分配內存,拿到棧指針地址,將數組拷貝到棧空間,將指針作爲參數調用 c 的方法。Emscripten 的cwrap方法可以輕鬆解決。

crap(函數名,返回值,傳入 c 的參數類型數組)

// example ts:captureByMs(info: 'string', path:'string', id:'number'):number
this.cCaptureByMs = Module.cwrap('captureByMs', 'number', ['string', 'string', 'number']);

5.4.2 C 調用 Js

可以通過emscripten_run_scriptapi 在 c 裏調用 js,接受參數是拼接成字符串的要執行的 js 內容,用起來很像 eval。

emscripten_run_script("console.log('hi')");

如果傳參是指針,js 的方法裏接受到的是 c 的指針地址,在當前版本的 Emscripten 中,指針地址類型爲 int32,Wasm 中 js 的內存空間均爲ArrayBuffer,Emscripten 提供的訪問對象是Module.buffer, 但是 js 中的ArrayBuffer無法直接訪問,Emscripten 提供TypedArray對象進行訪問。

比如需要傳遞給 js 是結構體指針,是這樣定義的。

typedef struct
{
    uint32_t width;
    uint32_t height;
    uint32_t duration;
    uint8_t *data;
} ImageData;

結構體的內存對齊,所以選取最長的就是uint32_t,uint32_t對應的TypedArray數組是Module.HEAPU32,由於是 4 字節無符號整數,因此 js 拿到的 ptr 需除以 4(既右移 2 位)獲得正確的索引。按此類比,8 字節無符號整數就需要右移 3 位。

雖然看起來 c 調用 js 很簡單,但你不應該做頻繁的調用,這會導致較大的開銷抵消掉 Wasm 本身的物理優勢。這也是爲什麼 dom 操作相關的框架不會選用 Wasm 進行優化,Wasm 還無法直接操作 dom,頻繁的 js 和 Wasm 的上下文的開銷也帶來不可忽視的性能缺失,他的目的從不是替代 js, 類比 react,reconciler 部分是可以用 rust/go 重寫,社區也有人做過此嘗試,但是並沒有帶來顯著性能優勢,社區也有用 go/rust 編寫 web 應用的框架,比如 (yew),他們爲跨端帶來更多的可能。

5.5 FFmpeg api 介紹

對整體抽幀流程使用到的關鍵 api 做簡單的介紹,包含對視頻的解碼、編碼以及處理等操作。

△抽幀的關鍵代碼及解釋

5.6 編譯後產物體積對比

自定義編譯

使用 npm 包 @ffmpeg/ffmpeg @ffmpeg/core

對比全量引入 24.5M,我們只需要 4M,體積上的收益還是非常明顯的。

06 總結

使用 FFmepg+Wasm 方案進行視頻抽幀,通過自定義編譯 FFmpeg 減少編譯產物的體積;定義關鍵幀優先策略,第一時間給到用戶抽幀結果,儘可能減少用戶等待時間。在 Emscripten 工具鏈的加持下,可以方便地將 C/C++ 代碼編譯成 Wasm,並配合產出完整的與 web 的交互 js。在速度和體驗以及視頻兼容性方面都取得了較爲明顯的收益,請大膽擁抱 WebAssembly 爲 web 賦能吧!

目前這套方案已在百家號視頻場景落地數月,收益明顯。

項目地址:https://github.com/wanwu/cheetah-capture,歡迎 star。

封裝好 api 支持按照幀數目和秒數抽取。你也選擇自定義編譯,通過更改 FFmpeg 的編譯參數讓他支持更多的視頻類型,通過更改 capture.c 文件增加更多 api 能力,期待你來豐富更多場景。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/eWGlasycL9OXJLLt2xbxIg