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

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

作者:jordiwang

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

前言

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

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

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

圖1

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

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

圖2

一、wasm 模塊

1. ffmpeg 編譯

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

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

圖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

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

圖4

2. 基於 ffmpeg 的解碼器編碼

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

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

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

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

圖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 文件已經比較符合線上使用的標準。

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

圖7

2. wasm 構建優化

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

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

圖8

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

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

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

圖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


分享到:


相關文章: