博客 / 詳情

返回

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

作者 | 小萱

導讀 

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

全文10103字,預計閲讀時間26分鐘。

01 項目背景

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

圖片

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

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

  • 類型有限:video標籤支持的視頻格式十分有限,無法處理一些其他常見的視頻格式,如FLV、MKV和AVI等。
  • DOM依賴:該方案依賴於DOM,只能在主線程中完成。這意味着在處理大量截幀任務時,可能會對頁面性能產生負面影響。
  • 抽幀策略侷限:傳統方案無法精確控制抽幀策只能傳遞時間交給瀏覽器,設置currentTime時會解碼尋找最接近的幀,而非關鍵幀。

為解決上述問題,選取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 <file>、--post-js <file>等。

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做簡單的介紹,包含對視頻的解碼、編碼以及處理等操作。

  • av\_register\_all 註冊全部解碼器,在使用FFmpeg的其他函數之前調用,以確保Ffmpeg可以正確地加載和初始化。

  • avformat\_open\_input 根據路徑讀取文件,並將其解析為一個AVFormatContext結構體,其中包含了文件的格式信息和媒體流的信息。

  • avformat\_find\_stream\_info 獲取視頻的媒體信息 類比ffplay file獲取的信息,包含編碼格式、視頻長度、fps、分辨率等。

  • avcodec\_find\_decoder 尋找視頻對應的解碼器。

  • av\_read\_frame 大量耗時在解碼環節,在解碼前,可以通過讀取壓縮的幀信息,獲取關鍵幀隊列,AVPacket結構體裏的flag等於1,標誌該幀是關鍵幀。

  • av\_seek\_frame 快速定位到某個時間戳的視頻幀,在這裏使用它定位到關鍵幀。

  • 基於關鍵幀進行解包,先調用av\_read\_frame讀取壓縮幀,avcodec\_send\_packet發送壓縮包到FFmpeg的解碼隊列(如果成功,則返回0),avcodec\_receive\_frame從解碼隊列裏成功取出,判斷pts(位於的時間),符合條件的frame信息被存儲。

圖片

△抽幀的關鍵代碼及解釋

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能力,期待你來豐富更多場景。

——END——

推薦閲讀

百度研發效能從度量到數字化蜕變之路

百度內容理解推理服務FaaS實戰——Punica系統

精準水位在流批一體數據倉庫的探索和實踐

視頻編輯場景下的文字模版技術方案

淺談活動場景下的圖算法在反作弊應用

Serverless:基於個性化服務畫像的彈性伸縮實踐

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.