基於 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=\
    -s TOTAL_MEMORY=${TOTAL_MEMORY} \
    -s EXPORTED_FUNCTIONS='["_main", "_free", "_capture"]' \
    -s ASSERTIONS=\
    -s ALLOW_MEMORY_GROWTH=\
    -o /capture.js

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

二、js 模塊

1. wasm 內存傳遞

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

  1. 將原始視頻幀的數據轉換爲 RGB 數據

  2. 將 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 % 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 編譯配置只是進行了一些簡單的配置,並對一些不常用到的功能進行了禁用處理。實際上在進行視頻幀提取的過程中,我們只用到了 libavcodeclibavformatlibavutillibswscale 這四個庫的一部分功能,於是在 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

參考文章

  1. Supported Media for Google Cast developers.google.com/cast/docs/m…

  2. emscripten emscripten.org/docs/gettin…

  3. ffmpeg

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