基於 ffmpeg - Webassembly 實現前端視頻幀提取
作者:jordiwang https://juejin.im/post/6854573219454844935
現有的前端視頻幀提取主要是基於 canvas
+ video
標籤的方式,在用戶本地選取視頻文件後,將本地文件轉爲 ObjectUrl
後設置到 video
標籤的 src
屬性中,再通過 canvas
的 drawImage
接口提取出當前時刻的視頻幀。
受限於瀏覽器支持的視頻編碼格式,即使是支持最全的的 Chrome 瀏覽器也只能解析 MP4
/WebM
的視頻文件和 H.264
/VP8
的視頻編碼。在遇到用戶自己壓制和封裝的一些視頻格式的時候,由於瀏覽器的限制,就無法截取到正常的視頻幀了。如圖 1 所示,一個 mpeg4
編碼的視頻,在 QQ 影音中可以正常播放,但是在瀏覽器中完全無法解析出畫面。
通常遇到這種情況只能將視頻上傳後由後端解碼後提取視頻圖片,而 Webassembly
的出現爲前端完全實現視頻幀截取提供了可能。於是我們的總體設計思路爲:將 ffmpeg
編譯爲 Webassembly
庫,然後通過 js
調用相關的接口截取視頻幀,再將截取到的圖像信息通過 canvas
繪製出來,如圖 2。
一、wasm 模塊
1. ffmpeg 編譯
首先在 ubuntu
系統中,按照 emscripten 官網 的文檔安裝 emsdk
(其他類型的 linux
系統也可以安裝,不過要複雜一些,還是推薦使用 ubuntu
系統進行安裝)。安裝過程中可能會需要訪問 googlesource.com
下載依賴,所以最好找一臺能夠直接訪問外網的機器,否則需要手動下載鏡像進行安裝。安裝完成後可以通過emcc -v
查看版本,本文基於 1.39.18 版本,如圖 3。
接着在 ffmpeg 官網 中下載 ffmpeg
源碼 release
包。在嘗試了多個版本編譯之後,發現基於 3.3.9
版本編譯時禁用掉 swresample
之類的庫後能夠成功編譯,而一些較新的版本禁用之後依然會有編譯內存不足的問題。所以本文基於 ffmpeg 3.3.9
版本進行開發。
下載完成後使用 emcc
進行編譯得到編寫解碼器所需要的 c 依賴庫和相關頭文件,這裏先初步禁用掉一些不需要用到的功能,後續對 wasm
再進行編譯優化是作詳細配置和介紹
具體編譯配置如下:
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
編譯結果如圖 4
2. 基於 ffmpeg 的解碼器編碼
對視頻進行解碼和提取圖像主要用到 ffmpeg
的解封裝、解碼和圖像縮放轉換相關的接口,主要依賴以下的庫
libavcodec - 音視頻編解碼
libavformat - 音視頻解封裝
libavutil - 工具函數
libswscale - 圖像縮放&色彩轉換
在引入依賴庫後調用相關接口對視頻幀進行解碼和提取,主要流程如圖 5
3. wasm 編譯
在編寫完相關解碼器代碼後,就需要通過 emcc
來將解碼器和依賴的相關庫編譯爲 wasm
供 js 進行調用。emcc
的編譯選項可以通過 emcc --help
來獲取詳細的說明,具體的編譯配置如下:
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
主要通過 -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 格式)的數據,具體代碼如下
sws_ctx = sws_getContext(pCodecCtx->width, pCodecCtx->height, pCodecCtx->pix_fmt, pCodecCtx->width, pCodecCtx->height, AV_PIX_FMT_RGB24, SWS_BILINEAR, NULL, NULL, NULL);
在解碼並轉換視頻幀數據後,還要將 RGB 數據保存在內存中,並傳遞給 js 進行讀取。這裏定義一個結構體用來保存圖像信息
typedef struct {
uint32_t width;
uint32_t height;
uint8_t *data;
} ImageData;
結構體使用 uint32_t
來保存圖像的寬、高信息,使用 uint8_t
來保存圖像數據信息。由於 canvas
上讀取和繪製需要的數據均爲 Uint8ClampedArray
即 8 位無符號數組,在此結構體中也將圖像數據使用 uint8_t
格式進行存儲,方便後續 js 調用讀取。
2. js 與 wasm 交互
js 與 wasm 交互主要是對 wasm
內存的寫入和結果讀取。在從 input
中拿到文件後,將文件讀取並保存爲 Unit8Array
並寫入 wasm
內存供代碼進行調用,需要先使用 Module._malloc
申請內存,然後通過 Module.HEAP8.set
寫入內存,最後將內存指針和大小作爲參數傳入並調用導出的方法。具體代碼如下
// 將 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)
在得到提取到的圖像數據後,同樣需要對內存進行操作,來獲取 wasm
傳遞過來的圖像數據,也就是上文定義的 ImageData
結構體。
在 ImageData
結構體中,寬度和高度都是 uint32_t
類型,即可以很方便的得到返回內存的指針的前 4 個字節表示寬度,緊接着的 4 個字節表示高度,在後面則是 uint8_t
的圖像 RGB 數據。
由於 wasm
返回的指針爲一個字節一個單位,所以在 js 中讀取 ImageData
結構體只需要 imgDataPtr /4
即可得到ImageData
中的 width
地址,以此類推可以分別得到 height
和 data
,具體代碼如下
// 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);
至此,我們分別獲取到了圖像的寬、高、RGB 數據
3. 圖像數據繪製
獲取了圖像的寬、高和 RGB 數據以後,即可通過 canvas
來繪製對應的圖像。這裏還需要注意的是,從 wasm
中拿到的數據只有 RGB 三個通道,繪製在 canvas
前需要補上 A 通道,然後通過 canvas
的 ImageData
類繪製在 canvas
上,具體代碼如下
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 < imageBuffer.length; i++) {
if (i && i % 3 == 0) {
imageData.data[j] = 255;
j += 1;
}
imageData.data[j] = imageBuffer[i];
j += 1;
}
ctx.putImageData(imageData, 0, 0, 0, 0, width, height);
}
在加上 Module._free
來手動釋放用過的內存空間,至此即可完成上面流程圖所展示的全部流程。
三、wasm 優化
在實現了功能之後,需要關注整體的性能表現。包括體積、內存、CPU 消耗等方面,首先看下初始的性能表現,由於 CPU 佔用和耗時在不同的機型上有不同的表現,所以我們先主要關注體積和內存佔用方面,如圖 6。
wasm 的原始文件大小爲 11.6M,gzip 後大小爲 4M,初始化內存爲 220M,在線上使用的話會需要加載很長的時間,並且佔用不小的內存空間。
接下來我們着手對 wasm
進行優化。
對上文中 wasm
的編譯命令進行分析可以看到,我們編譯出來的 wasm
文件主要由 capture.c
與 ffmpeg
的諸多庫文件編譯而成,所以我們的優化思路也就主要包括 ffmpeg
編譯優化和 wasm
構建優化。
1. ffmpeg 編譯優化
上文的 ffmpeg
編譯配置只是進行了一些簡單的配置,並對一些不常用到的功能進行了禁用處理。實際上在進行視頻幀提取的過程中,我們只用到了 libavcodec
、libavformat
、libavutil
、libswscale
這四個庫的一部分功能,於是在 ffmpeg
編譯優化這裏,可以再通過詳細的編譯配置進行優化,從而降低編譯出的原始文件的大小。
運行 ./configure --help
後可以看到 ffmpeg
的編譯選項十分豐富,可以根據我們的業務場景,選擇常見的編碼和封裝格式,並基於此做詳細的編譯優化配置, 具體優化後的編譯配置如下。
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
基於此做 ffmpeg
的編譯優化之後,文件大小和內存佔用如圖 7。
wasm 的原始文件大小爲 2.8M,gzip 後大小爲 0.72M,初始化內存爲 112M,大致相當於同環境下打開的 QQ 音樂首頁佔用內存的 2 倍,相當於打開了 2 個 QQ 音樂首頁,可以說優化後的 wasm
文件已經比較符合線上使用的標準。
2. wasm 構建優化
ffmpeg
編譯優化之後,還可以對 wasm
的構建和加載進行進一步的優化。如圖 8 所示,直接使用構建出的 capture.js
加載 wasm
文件時會出現重複請求兩次 wasm
文件的情況,並在控制檯中打印對應的告警信息
我們可以將 emcc
構建命令中的壓縮等級改爲 O0
後,重新編譯進行分析。
最終找到問題的原因在於,capture.js
會默認先使用 WebAssembly.instantiateStreaming
的方式進行初始化,失敗後再重新使用 ArrayBuffer
的方式進行初始化。而因爲很多 CDN 或代理返回的響應頭並不是 WebAssembly.instantiateStreaming
能夠識別的 application/wasm
,而是將 wasm
文件當做普通的二進制流進行處理,響應頭的 Content-Type
大多爲 application/octet-stream
,所以會重新用 ArrayBuffer
的方式再初始化一次,如圖 9
再對源碼進行分析後,可以找出解決此問題的辦法,即通過 Module.instantiateWasm
方法來自定義 wasm
初始化函數,直接使用 ArrayBuffer
的方式進行初始化,具體代碼如下。
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);
})
}
}
通過這種方式,可以自定義 wasm
文件的加載和讀取。而 Module
中還有很多可以調用和重寫的接口,就有待後續研究了。
四、小結
Webassembly
極大的擴展了瀏覽器的應用場景,一些原本 js 無法實現或有性能問題的場景都可以考慮這一方案。而 ffmpeg
作爲一個功能強大的音視頻庫,提取視頻幀只是其功能的一小部分,後續還有更多 ffmpeg
+ Webassembly
的應用場景可以去探索。
五、項目地址
https://github.com/jordiwang/web-capture
參考文章
-
Supported Media for Google Cast developers.google.com/cast/docs/m…
-
emscripten emscripten.org/docs/gettin…
-
ffmpeg
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/jTYnY50qEHLoGFWo4LhCEA