基於 ffmpeg+Webassembly 實現視頻幀提取

作者:jordiwang

轉發鏈接:https://juejin.im/post/6854573219454844935

前言

有的前端視頻幀提取主要是基於浪canvas浪+ video一標籤的方式,在用戶本地選取視頻文件後,將本地文件轉為 ObjectUrl 後設置到 video 標籤的 src 屬性中,再通過 canvas 的 drawImage 接口提取出當前時刻的視頻幀。

受限於瀏覽器支持的視頻編碼格式,即使是支持最全的的 Chrome 瀏覽器也只能解析 MP4/WebM 的視頻文件和 H.264/VP8 的視頻編碼。在遇到用戶自己壓制和封裝的一些視頻格式的時候,由於瀏覽器的限制,就無法截取到正常的視頻幀了。如圖1所示,一個mpeg4 編碼的視頻,在QQ影音中可以正常播放,但是在瀏覽器中完全無法解析出畫面。

圖1

通常遇到這種情況只能將視頻上傳後由後端解碼後提取視頻圖片,而 Webassembly 的出現為前端完全實現視頻幀截取提供了可能。於是我們的總體設計思路為:將 ffmpeg編譯為 Webassembly 庫,然後通過 js 調用相關的接口截取視頻幀,再將截取到的圖像信息通過 canvas 繪製出來,如圖2。

圖2

一、wasm 模塊

1. ffmpeg 編譯

首先在 ubuntu 系統中,按照 emscripten 官網 的文檔安裝 emsdk(其他類型的linux 系統也可以安裝,不過要複雜一些,還是推薦使用 ubuntu 系統進行安裝)。安裝過程中可能會需要訪問 googlesource.com 下載依賴,所以最好找一臺能夠直接訪問外網的機器,否則需要手動下載鏡像進行安裝。安裝完成後可以通過emcc -v 查看版本,本文基於1.39.18版本,如圖3。

圖3

接著在 ffmpeg 官網 中下載 ffmpeg 源碼 release 包。在嘗試了多個版本編譯之後,發現基於 3.3.9 版本編譯時禁用掉 swresample 之類的庫後能夠成功編譯,而一些較新的版本禁用之後依然會有編譯內存不足的問題。所以本文基於 ffmpeg 3.3.9 版本進行開發。

下載完成後使用 emcc 進行編譯得到編寫解碼器所需要的c依賴庫和相關頭文件,這裡先初步禁用掉一些不需要用到的功能,後續對 wasm 再進行編譯優化是作詳細配置和介紹

具體編譯配置如下:

<code>emconfigure ./configure \     --prefix=/data/web-catch-picture/lib/ffmpeg-emcc \     --cc="emcc" \     --cxx="em++" \     --ar="emar" \     --enable-cross-compile \     --target-os=none \     --arch=x86_32 \     --cpu=generic \     --disable-ffplay \     --disable-ffprobe \     --disable-asm \     --disable-doc \     --disable-devices \     --disable-pthreads \     --disable-w32threads \     --disable-network \     --disable-hwaccels \     --disable-parsers \     --disable-bsfs \     --disable-debug \     --disable-protocols \     --disable-indevs \     --disable-outdevs \     --disable-swresample make make install /<code>

編譯結果如圖4

圖4

2. 基於 ffmpeg 的解碼器編碼

對視頻進行解碼和提取圖像主要用到 ffmpeg 的解封裝、解碼和圖像縮放轉換相關的接口,主要依賴以下的庫

<code>libavcodec - 音視頻編解碼  libavformat - 音視頻解封裝 libavutil - 工具函數 libswscale - 圖像縮放&色彩轉換 /<code>

在引入依賴庫後調用相關接口對視頻幀進行解碼和提取,主要流程如圖5

圖5

3. wasm 編譯

在編寫完相關解碼器代碼後,就需要通過 emcc 來將解碼器和依賴的相關庫編譯為wasm 供 js 進行調用。emcc 的編譯選項可以通過 emcc --help 來獲取詳細的說明,具體的編譯配置如下:

<code>export TOTAL_MEMORY=33554432 export FFMPEG_PATH=/data/web-catch-picture/lib/ffmpeg-emcc emcc capture.c ${FFMPEG_PATH}/lib/libavformat.a ${FFMPEG_PATH}/lib/libavcodec.a ${FFMPEG_PATH}/lib/libswscale.a ${FFMPEG_PATH}/lib/libavutil.a \     -O3 \     -I "${FFMPEG_PATH}/include" \     -s WASM=1 \     -s TOTAL_MEMORY=${TOTAL_MEMORY} \     -s EXPORTED_FUNCTIONS='["_main", "_free", "_capture"]' \     -s ASSERTIONS=1 \     -s ALLOW_MEMORY_GROWTH=1 \     -o /capture.js /<code>

主要通過 -O3 進行壓縮,EXPORTED_FUNCTIONS 導出供 js 調用的函數,並 ALLOW_MEMORY_GROWTH=1 允許內存增長。

二、js 模塊

1. wasm 內存傳遞

在提取到視頻幀後,需要通過內存傳遞的方式將視頻幀的RGB數據傳遞給js進行繪製圖像。這裡 wasm 要做的主要有以下操作

將原始視頻幀的數據轉換為 RGB 數據

將 RGB 數據保存為方便 js 調用的內存數據供 js 調用

原始的視頻幀數據一般是以 YUV 格式保存的,在解碼出指定時間的視頻幀後需要轉換為 RGB 格式才能在 canvas 上通過 js 來繪製。上文提到的 ffmpeg 的 libswscale 就提供了這樣的功能,通過 sws 將解碼出的視頻幀輸出為 AV_PIX_FMT_RGB24 格式(即 8 位 RGB 格式)的數據,具體代碼如下

<code>sws_ctx = sws_getContext(pCodecCtx->width, pCodecCtx->height, pCodecCtx->pix_fmt, pCodecCtx->width, pCodecCtx->height, AV_PIX_FMT_RGB24, SWS_BILINEAR, NULL, NULL, NULL); /<code>

在解碼並轉換視頻幀數據後,還要將 RGB 數據保存在內存中,並傳遞給 js 進行讀取。這裡定義一個結構體用來保存圖像信息

<code>typedef struct {     uint32_t width;     uint32_t height;     uint8_t *data; } ImageData; /<code>

結構體使用 uint32_t 來保存圖像的寬、高信息,使用 uint8_t 來保存圖像數據信息。由於 canvas 上讀取和繪製需要的數據均為 Uint8ClampedArray 即 8位無符號數組,在此結構體中也將圖像數據使用 uint8_t 格式進行存儲,方便後續 js 調用讀取。

2. js 與 wasm 交互

js 與 wasm 交互主要是對 wasm 內存的寫入和結果讀取。在從 input 中拿到文件後,將文件讀取並保存為 Unit8Array 並寫入 wasm 內存供代碼進行調用,需要先使用 Module._malloc 申請內存,然後通過 Module.HEAP8.set 寫入內存,最後將內存指針和大小作為參數傳入並調用導出的方法。具體代碼如下

<code>// 將 fileReader 保存為 Uint8Array let fileBuffer = new Uint8Array(fileReader.result); // 申請文件大小的內存空間 let fileBufferPtr = Module._malloc(fileBuffer.length); // 將文件內容寫入 wasm 內存 Module.HEAP8.set(fileBuffer, fileBufferPtr); // 執行導出的 _capture 函數,分別傳入內存指針,內存大小,時間點 let imgDataPtr = Module._capture(fileBufferPtr, fileBuffer.length, (timeInput.value) * 1000) /<code>

在得到提取到的圖像數據後,同樣需要對內存進行操作,來獲取 wasm 傳遞過來的圖像數據,也就是上文定義的 ImageData 結構體。

在 ImageData 結構體中,寬度和高度都是 uint32_t 類型,即可以很方便的得到返回內存的指針的前4個字節表示寬度,緊接著的4個字節表示高度,在後面則是 uint8_t的圖像 RGB 數據。

由於 wasm 返回的指針為一個字節一個單位,所以在 js 中讀取 ImageData 結構體只需要 imgDataPtr /4 即可得到ImageData 中的 width 地址,以此類推可以分別得到 height 和 data,具體代碼如下

<code>// Module.HEAPU32 讀取 width、height、data 的起始位置 let width = Module.HEAPU32[imgDataPtr / 4],     height = Module.HEAPU32[imgDataPtr / 4 + 1],     imageBufferPtr = Module.HEAPU32[imgDataPtr / 4 + 2]; // Module.HEAPU8 讀取 uint8 類型的 data let imageBuffer = Module.HEAPU8.subarray(imageBufferPtr, imageBufferPtr + width * height * 3); /<code>

至此,我們分別獲取到了圖像的寬、高、RGB 數據

3. 圖像數據繪製

獲取了圖像的寬、高和 RGB 數據以後,即可通過 canvas 來繪製對應的圖像。這裡還需要注意的是,從 wasm 中拿到的數據只有 RGB 三個通道,繪製在 canvas 前需要補上 A 通道,然後通過 canvas 的 ImageData 類繪製在 canvas 上,具體代碼如下

<code>function drawImage(width, height, imageBuffer) {     let canvas = document.createElement('canvas');     let ctx = canvas.getContext('2d');     canvas.width = width;     canvas.height = height;     let imageData = ctx.createImageData(width, height);     let j = 0;     for (let i = 0; i /<code>

再加上 Module._free 來手動釋放用過的內存空間,至此即可完成上面流程圖所展示的全部流程。

三、wasm 優化

在實現了功能之後,需要關注整體的性能表現。包括體積、內存、CPU消耗等方面,首先看下初始的性能表現,由於CPU佔用和耗時在不同的機型上有不同的表現,所以我們先主要關注體積和內存佔用方面,如圖6。

wasm 的原始文件大小為11.6M,gzip 後大小為4M,初始化內存為220M,在線上使用的話會需要加載很長的時間,並且佔用不小的內存空間。

![圖6](data:image/svg+xml;utf8,)

接下來我們著手對 wasm 進行優化。

對上文中 wasm 的編譯命令進行分析可以看到,我們編譯出來的 wasm 文件主要由 capture.c 與 ffmpeg 的諸多庫文件編譯而成,所以我們的優化思路也就主要包括ffmpeg 編譯優化和 wasm 構建優化。

1. ffmpeg 編譯優化

上文的 ffmpeg 編譯配置只是進行了一些簡單的配置,並對一些不常用到的功能進行了禁用處理。實際上在進行視頻幀提取的過程中,我們只用到了 libavcodec、libavformat、libavutil、libswscale 這四個庫的一部分功能,於是在 ffmpeg 編譯優化這裡,可以再通過詳細的編譯配置進行優化,從而降低編譯出的原始文件的大小。

運行 ./configure --help 後可以看到 ffmpeg 的編譯選項十分豐富,可以根據我們的業務場景,選擇常見的編碼和封裝格式,並基於此做詳細的編譯優化配置,具體優化後的編譯配置如下。

<code>emconfigure ./configure \     --prefix=/data/web-catch-picture/lib/ffmpeg-emcc \     --cc="emcc" \     --cxx="em++" \     --ar="emar" \     --cpu=generic \     --target-os=none \     --arch=x86_32 \     --enable-gpl \     --enable-version3 \     --enable-cross-compile \     --disable-logging \     --disable-programs \     --disable-ffmpeg \     --disable-ffplay \     --disable-ffprobe \     --disable-ffserver \     --disable-doc \     --disable-swresample \     --disable-postproc  \     --disable-avfilter \     --disable-pthreads \     --disable-w32threads \     --disable-os2threads \     --disable-network \     --disable-everything \     --enable-demuxer=mov \     --enable-decoder=h264 \     --enable-decoder=hevc \     --enable-decoder=mpeg4 \     --disable-asm \     --disable-debug \ make make install /<code>

基於此做 ffmpeg 的編譯優化之後,文件大小和內存佔用如圖7。

wasm 的原始文件大小為2.8M,gzip 後大小為0.72M,初始化內存為112M,大致相當於同環境下打開的QQ音樂首頁佔用內存的2倍,相當於打開了2個QQ音樂首頁,可以說優化後的 wasm 文件已經比較符合線上使用的標準。

圖7

2. wasm 構建優化

ffmpeg 編譯優化之後,還可以對 wasm 的構建和加載進行進一步的優化。如圖8所示,直接使用構建出的 capture.js 加載 wasm 文件時會出現重複請求兩次 wasm 文件的情況,並在控制檯中打印對應的告警信息

圖8

我們可以將 emcc 構建命令中的壓縮等級改為 O0 後,重新編譯進行分析。

最終找到問題的原因在於,capture.js 會默認先使用 WebAssembly.instantiateStreaming 的方式進行初始化,失敗後再重新使用 ArrayBuffer 的方式進行初始化。而因為很多 CDN 或代理返回的響應頭並不是 WebAssembly.instantiateStreaming 能夠識別的 application/wasm ,而是將 wasm 文件當做普通的二進制流進行處理,響應頭的 Content-Type 大多為 application/octet-stream,所以會重新用 ArrayBuffer 的方式再初始化一次,如圖9

圖9

再對源碼進行分析後,可以找出解決此問題的辦法,即通過 Module.instantiateWasm方法來自定義 wasm 初始化函數,直接使用 ArrayBuffer 的方式進行初始化,具體代碼如下。

<code>Module = {     instantiateWasm(info, receiveInstance) {         fetch('/wasm/capture.wasm')             .then(response => {                 return response.arrayBuffer()             }             ).then(bytes => {                 return WebAssembly.instantiate(bytes, info)             }).then(result => {                 receiveInstance(result.instance);             })     } } /<code>

通過這種方式,可以自定義 wasm 文件的加載和讀取。而 Module 中還有很多可以調用和重寫的接口,就有待後續研究了。

四、小結

Webassembly 極大的擴展了瀏覽器的應用場景,一些原本 js 無法實現或有性能問題的場景都可以考慮這一方案。而 ffmpeg 作為一個功能強大的音視頻庫,提取視頻幀只是其功能的一小部分,後續還有更多 ffmpeg + Webassembly 的應用場景可以去探索。

五、項目地址

https://github.com/jordiwang/web-capture