現有的前端視訊幀提取主要是基于 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);
})
}
}
複制代碼