天天看點

前端視訊幀提取 ffmpeg + Webassembly

現有的前端視訊幀提取主要是基于 ​

​canvas​

​ + ​

​video​

​ 标簽的方式,在使用者本地選取視訊檔案後,将本地檔案轉為 ​

​ObjectUrl​

​ 後設定到 ​

​video​

​ 标簽的 ​

​src​

​ 屬性中,再通過 ​

​canvas​

​ 的 ​

​drawImage​

​ 接口提取出目前時刻的視訊幀。

受限于浏覽器支援的視訊編碼格式,即使是支援最全的的 Chrome 浏覽器也隻能解析 ​

​MP4​

​/​

​WebM​

​ 的視訊檔案和 ​

​H.264​

​VP8​

​ 的視訊編碼。在遇到使用者自己壓制和封裝的一些視訊格式的時候,由于浏覽器的限制,就無法截取到正常的視訊幀了。如圖1所示,一個 ​

​mpeg4​

​ 編碼的視訊,在QQ影音中可以正常播放,但是在浏覽器中完全無法解析出畫面。
前端視訊幀提取 ffmpeg + Webassembly
通常遇到這種情況隻能将視訊上傳後由後端解碼後提取視訊圖檔,而 ​

​Webassembly​

​ 的出現為前端完全實作視訊幀截取提供了可能。于是我們的總體設計思路為:将 ​

​ffmpeg​

​ 編譯為 ​

​Webassembly​

​ 庫,然後通過 ​

​js​

​ 調用相關的接口截取視訊幀,再将截取到的圖像資訊通過 ​

​canvas​

​ 繪制出來,如圖2。
前端視訊幀提取 ffmpeg + Webassembly

一、wasm 子產品

1. ffmpeg 編譯

首先在 ​

​ubuntu​

​ 系統中,按照 ​​emscripten 官網​​ 的文檔安裝 ​

​emsdk​

​(其他類型的 ​

​linux​

​ 系統也可以安裝,不過要複雜一些,還是推薦使用 ​

​ubuntu​

​ 系統進行安裝)。安裝過程中可能會需要通路 ​

​googlesource.com​

​ 下載下傳依賴,是以最好找一台能夠直接通路外網的機器,否則需要手動下載下傳鏡像進行安裝。安裝完成後可以通過​

​emcc -v​

​ 檢視版本,本文基于1.39.18版本,如圖3。

前端視訊幀提取 ffmpeg + Webassembly

接着在 ​​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

前端視訊幀提取 ffmpeg + Webassembly

2. 基于 ffmpeg 的解碼器編碼

對視訊進行解碼和提取圖像主要用到 ​

​ffmpeg​

​ 的解封裝、解碼和圖像縮放轉換相關的接口,主要依賴以下的庫

libavcodec - 音視訊編解碼 
libavformat - 音視訊解封裝
libavutil - 工具函數
libswscale - 圖像縮放&色彩轉換
複制代碼      

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

前端視訊幀提取 ffmpeg + Webassembly

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 要做的主要有以下操作

  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 && 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,線上上使用的話會需要加載很長的時間,并且占用不小的記憶體空間。

前端視訊幀提取 ffmpeg + Webassembly

接下來我們着手對 ​

​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​

​ 檔案已經比較符合線上使用的标準。

前端視訊幀提取 ffmpeg + Webassembly

2. wasm 建構優化

​ffmpeg​

​ 編譯優化之後,還可以對 ​

​wasm​

​ 的建構和加載進行進一步的優化。如圖8所示,直接使用建構出的 ​

​capture.js​

​ 加載 ​

​wasm​

​ 檔案時會出現重複請求兩次 ​

​wasm​

​ 檔案的情況,并在控制台中列印對應的告警資訊

前端視訊幀提取 ffmpeg + Webassembly

我們可以将 ​

​emcc​

​ 建構指令中的壓縮等級改為 ​

​O0​

​ 後,重新編譯進行分析。

最終找到問題的原因在于,​

​capture.js​

​ 會預設先使用 ​

​WebAssembly.instantiateStreaming​

​ 的方式進行初始化,失敗後再重新使用 ​

​ArrayBuffer​

​ 的方式進行初始化。而因為很多 CDN 或代理傳回的響應頭并不是 ​

​WebAssembly.instantiateStreaming​

​ 能夠識别的 ​

​application/wasm​

​ ,而是将 ​

​wasm​

​ 檔案當做普通的二進制流進行處理,響應頭的 ​

​Content-Type​

​ 大多為 ​

​application/octet-stream​

​,是以會重新用 ​

​ArrayBuffer​

​ 的方式再初始化一次,如圖9

前端視訊幀提取 ffmpeg + Webassembly

再對源碼進行分析後,可以找出解決此問題的辦法,即通過 ​

​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);
            })
    }
}
複制代碼      

四、小結