現有的前端視頻幀提取主要是基於 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
的應用場景可以去探索。
五、項目地址
參考文章
from:https://juejin.cn/post/6854573219454844935