有沒有那麼一種可能,在前端頁面處理音視頻?例如用戶選擇一個視頻,而後支持他設置視頻的任意一幀做爲封面,就不用把整一個視頻上傳到後端處理了。通過筆者的一番摸索,基本實現了這個功能,一個完整的demo:ffmpeg wasm截取視頻幀功能:javascript
支持mp4/mov/mkv/avi等文件。基本的思想是這樣的:html
使用一個file input讓用戶選擇一個視頻文件,而後讀取爲ArrayBuffer,傳給ffmpeg.wasm處理,處理完以後,輸出rgb數據畫到canvas上或者是轉成base64當作img標籤的src屬性就造成圖片了。(Canvas能夠直接把video dom看成drawImage的對象進而獲得視頻幀,不過video能播放的格式比較少,本文重點討論ffmpeg方案的實現,由於ffmpeg還可作其它的事情,這只是一個例子。)前端
這裏有一個問題,爲何要藉助ffmpeg呢,而不直接用JS寫?由於多媒體處理的C庫比較成熟,ffmpeg就是其中一個,仍是開源的,而wasm恰好能夠把它轉化格式,在網頁上使用,多媒體處理相關的JS庫比較少,本身寫一個多路解複用(demux)和解碼視頻的複雜度可想而知,JS直接編解碼也會比較耗時。因此有現成的先用現成的。java
第1步是編譯(若是你對編譯過程不感興趣的話,能夠直接跳到第2步)c++
我一開始覺得難度會很大,後來發現並無那麼大,由於有一個videoconverter.js已經轉過了(它是一個藉助ffmpeg在網頁實現音視頻轉碼的),關鍵在於把一些沒用的特性在configure的時候給disable掉,否則編譯的時候會報語法錯誤。這裏使用的是emsdk轉的wasm,emsdk的安裝方法在它的安裝教程已經說得很明白,主要是使用腳本斷定系統下載不一樣編譯好的文件。下載好以後就會有幾個可執行文件,包括emcc、emc++、emar等命令,emcc是C的編譯器,emc++是C++的編譯器,而emar是用於把不一樣的.o庫文件打包成一個.a文件的。git
先要在ffmpeg的官網下載源碼。github
解壓進入目錄,而後執行如下命令:web
emconfigure ./configure --cc="emcc" --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 --enable-protocol=file
複製代碼
一般configure的做用是生成Makefile——configure階段確認一些編譯的環境和參數,而後生成編譯命令放到Makefile裏面。canvas
而前面的emconfigure的主要做用是把編譯器指定爲emcc,但只是這樣是不夠的,由於ffmpeg裏面有一些子模塊,並不能完全地把全部的編譯器都指定爲emcc,好在ffmpeg的configure能夠經過--cc的參數指定自定義的編譯器,在Mac上C編譯器通常是使用/usr/bin/clang,這裏指定爲emcc。後端
後面的disable是把一些不支持wasm的特性給禁掉了,例如--disable-asm是把使用匯編代碼的部分給禁掉了,由於那些彙編語法emcc不兼容,沒有禁掉的話編譯會報錯語法錯誤。另一個--disable-hwaccels是把硬解碼禁用了,有些顯卡支持直接解碼,不須要應用程序解碼(軟解碼),硬解碼性能明顯會比軟解碼的高,這個禁了以後,會致使後面使用的時候報了一個warning:
[swscaler @ 0x105c480] No accelerated colorspace conversion found from yuv420p to rgb24.
可是不影響使用。
(執行configure的過程會報一個segment fault,但後續的過程當中發現沒有影響。)
等待configure命令執行完了,就會生成Makefile和相關的一些配置文件。
make是開始編譯的階段,執行如下命令進行編譯:
emmake make複製代碼
在Mac上執行,你會發現最後把多個.o文件組裝成.a文件的時候會報錯:
AR libavdevice/libavdevice.a
fatal error: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ar: fatal error in /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ranlib
解決這個問題須要把打包的命令從ar改爲emar,而後再把一個ranlib的過程去掉就行,修改ffbuild/config.mak文件:
# 修改ar爲emar
- AR=ar
+ AR=emar
# 去掉ranlib
- RANLIB=ranlib
+ #RANLIB=ranlib複製代碼
而後再從新make就能夠了。
編譯完成以後,會在ffmpeg目錄生成一個總的ffmpeg文件,在ffmpeg的libavcodec等目錄會生成libavcodec.a等文件,這些文件是後面咱們要使用的bitcode文件,bitcode是一種已編譯程序的中間代碼。
(最後在執行strip -o ffmpeg ffmpeg_g命令會掛掉,可是沒關係,strip改爲cp ffmpeg_g ffmpeg就行了)
ffmpeg主要是由幾個lib目錄組成的:
以一個mp4文件爲例,mp4是一種容器格式,首先使用libavformat的API把mp4進行多路解複用,獲得音視頻在這個文件存放的位置等信息,視頻通常是使用h264等進行編碼的,因此須要再使用libavcodec進行解碼獲得圖像的yuv格式,最後再借助libswscale轉成rgb格式。
這裏有兩個使用ffmpeg的方式,第一種是直接把第一步獲得的ffmpeg文件編譯成wasm:
# 須要拷貝一個.bc後綴,由於emcc是根據後綴區分文件格式的
cp ffmpeg_g ffmpeg.bc
emcc ffmpeg.bc -o ffmpeg.html複製代碼
而後就會生成一個ffmpeg.js和ffpmeg.wasm,ffmpeg.js是用來加載和編譯wasm文件以及提供一個全局的Module對象用來操控wasm裏面ffmpeg API的功能的。有了這個以後,在JS裏面經過Module調用ffmpeg的API。
可是我感受這個方式比較麻煩,JS的數據類型和C的數據類型差別比較多,在JS裏面頻繁地調C的API,須要讓數據傳來傳去比較麻煩,由於要實現一個截取功能要調不少ffmpeg的API。
因此我用的是第二種方式,先寫C代碼,在C裏面把功能實現了,最後再暴露一個接口給JS使用,這樣JS和WASM只須要經過一個接口API進行通訊就行了,不用像第一種方式同樣頻繁地調用。
因此問題就轉化成兩步:
第一步是使用C語言寫一個ffmpeg保存視頻幀圖像的功能
第二步是編譯成wasm和js進行數據的交互
第一步的實現主要參考了一個ffmpeg的教程:ffmpeg tutorial。裏面的代碼都是現成的直接拷過來就好,有一些小問題是他用的ffmpeg版本稍老,部分API的參數須要修改一下。代碼已上傳到github,可見:cfile/simple.c。
使用方法已在readme裏面進行介紹,經過如下命令編譯成一個可執行文件simple:
gcc simple.c -lavutil -lavformat -lavcodec `pkg-config --libs --cflags libavutil` `pkg-config --libs --cflags libavformat` `pkg-config --libs --cflags libavcodec` `pkg-config --libs --cflags libswscale` -o simple複製代碼
而後使用的時候傳一個視頻文件的位置就能夠了:
./simple mountain.mp4複製代碼
就會在當前目錄生成一張pcm格式的圖片。
這個simple.c是調用的ffmpeg自動讀取硬盤文件的api,須要改爲從內存讀取文件內容,即咱們本身讀到內存的buffer而後傳給ffmpeg,後面才能把數據傳輸改爲從JS的buffer獲取,這個的實現可見:simple-from-memory.c. 具體的C代碼這裏就不分析了,就是調調API,相對來講仍是比較簡單,就是要知道怎麼用,ffmpeg網上的開發文檔相對較少。
這樣第一步就算完成了,接着第二步,把數據的輸入改爲從JS獲取,輸出改爲返回給JS.
wasm版的具體實現是在web.c(還有一個proccess.c是把simple.c的一些功能拆了出去),在web.c裏面有一個暴露給JS調用的函數,姑且起名叫setFile,這個setFile就是給JS調的:
EMSCRIPTEN_KEEPALIVE // 這個宏表示這個函數要做爲導出的函數
ImageData *setFile(uint8_t *buff, const int buffLength, int timestamp) {
// process ...
return result;
}複製代碼
須要傳遞三個參數:
最後處理完了返回一個ImageData的數據結構:
typedef struct {
uint32_t width;
uint32_t height;
uint8_t *data;
} ImageData;複製代碼
裏面有三個字段:圖片的寬高和rgb數據。
寫好這些C文件後進行編譯:
emcc web.c process.c ../lib/libavformat.bc ../lib/libavcodec.bc ../lib/libswscale.bc ../lib/libswresample.bc ../lib/libavutil.bc \
-Os -s WASM=1 -o index.html -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' -s ALLOW_MEMORY_GROWTH=1 -s TOTAL_MEMORY=16777216
複製代碼
使用第1步編譯生成的那些libavcode.bc等文件,這些文件有依賴順序,先後不能顛倒,被依賴的要放在後面。這裏面有些參數說明一下:
-o index.html
表示導出hmtl文件,同時會導出index.js和index.wasm,主要使用這兩個,生成的index.html是沒用的;
-s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]
表示要導出ccall和cwrap這兩個函數,這兩個函數的功能是爲了調用上面C裏面寫的setFile函數;
-s TOTAL_MEMORY=16777216
表示wasm總內存大小爲約16MB,這個也是默認值,這個須要是64的倍數;
-s ALLOW_MEMORY_GROWTH=1
當內存超出總大小時自動擴容。
編譯好以後寫一個main.html,加入input[type=file]等控件,並引入上面生成的index.js,它會去加載index.wasm,並提供一個全局的Module對象操控wasm的API,包括上面在編譯的時候指定導出的函數,以下代碼所示:
<!DOCType html> <html> <head> <meta charset="utf-8"> <title>ffmpeg wasm截取視頻幀功能</title> </head> <body> <form> <p>請選擇一個視頻(本地操做不會上傳)</p> <input type="file" required name="file"> <label>時間(秒)</label><input type="number" step="1" value="0" required name="time"> <input type="submit" value="獲取圖像" style="font-size:16px;"> </form> <!--這個canvas用來畫導出的圖像--> <canvas width="600" height="400" id="canvas"></canvas> <!--引入index.js--> <script src="index.js"></script> <script></script>複製代碼
須要在wasm下載並解析完成以後才能開始操做,它提供了一個onRuntimeInitialized的回調。
爲了可以使用C文件裏面導出的函數,可使用Module.cwrap,第一個參數是函數名,第二個參數是返回類型,因爲返回的是一個指針地址,這裏是一個32位的數字,因此用js的number類型,第三個參數是傳參類型。
接着讀取input的文件內容到放到一個buffer裏面:
let form = document.querySelector('form');
// 監聽onchange事件
form.file.onchange = function () {
if (!setFile) {
console.warn('WASM未加載解析完畢,請稍候');
return;
}
let fileReader = new FileReader();
fileReader.onload = function () {
// 獲得文件的原始二進制數據ArrayBuffer
// 並放在buffer的Unit8Array裏面
let buffer = new Uint8Array(this.result);
// ...
};
// 讀取文件
fileReader.readAsArrayBuffer(form.file.files[0]);
};複製代碼
讀取獲得的buffer放在了一個Uint8Array,它是一個數組,數組裏面每一個元素都是unit8類型的即無符號8位整型,就是一個字節的0101的數字大小。
接下來的關鍵問題是:怎麼把這個buffer傳給wasm的setFile函數?這個須要理解wasm的內存堆模型。
上面在編譯的時候指定的wasm使用的總內存大小,內存裏面的內容能夠經過Module.buffer和Module.HEAP8查看:
這個東西就是JS和WASM數據交互的關鍵,在JS裏面把數據放到這個HEAP8的數組裏面,而後告訴WASM數據的指針地址在哪裏和佔用的內存大小,即在這個HEAP8數組的index和佔用長度,反過來WASM想要返回數據給JS也是被放到這個HEA8裏面,而後返回指針地址和和長度。
可是咱們不能隨便指定一個位置,須要用它提供的API進行分配和擴容。在JS裏面經過Module._molloc或者Module.dynamicMalloc申請內存,以下代碼所示:
// 獲得文件的原始二進制數據,放在buffer裏面
let buffer = new Uint8Array(this.result);
// 在HEAP裏面申請一塊指定大小的內存空間
// 返回起始指針地址
let offset = Module._malloc(buffer.length);
// 填充數據
Module.HEAP8.set(buffer, offset);
// 最後調WASM的函數
let ptr = setFile(offset, buffer.length, +form.time.value * 1000);複製代碼
調用malloc,傳須要的內存空間大小,而後會返回分配好的內存起始地址offset,這個offset其實就是HEAP8數組裏的index,而後調用Uint8Array的set方法填充數據。接着把這個offset的指針地址傳給setFile,並告知內存大小。這樣就實現了JS向WASM傳數據。
調用setFile以後返回值是一個指針地址,指向一個struct的數據結構:
typedef struct {
uint32_t width;
uint32_t height;
uint8_t *data;
} ImageData;複製代碼
它的前4個字節,用來表示寬度,緊接着的4個字節是高度,後面的是圖片的rgb數據的指針,指針的大小也是4個字節,這個省略了數據長度,由於能夠經過width * height * 3獲得。
因此[ptr, ptr + 4)存的內容是寬度,[ptr + 4, ptr + 8)存的內容是長度,[ptr + 8, ptr + 12)存的內容是指向圖像數據的指針,以下代碼所示:
let ptr = setFile(offset, buffer.length, +form.time.value * 1000);
let width = Module.HEAPU32[ptr / 4]
height = Module.HEAPU32[ptr / 4 + 1],
imgBufferPtr = Module.HEAPU32[ptr / 4 + 2],
imageBuffer = Module.HEAPU8.subarray(imgBufferPtr,
imgBufferPtr + width * height * 3);複製代碼
HEAPU32和上面的HEAP8是相似的,只不過它是每一個32位就讀一個數,因爲咱們上面都是32位的數字,因此用這個剛恰好,它是4個字節一個單位,而ptr是一個字節一個單位,因此ptr / 4就獲得index。這裏不用擔憂不可以被4整除,由於它是64位對齊的。
這樣咱們就拿到圖片的rgb數據內容了,而後用canvas畫一下。
利用Canvas的ImageData類,以下代碼所示:
function drawImage(width, height, buffer) {
let imageData = ctx.createImageData(width, height);
let k = 0;
// 把buffer內存放到ImageData
for (let i = 0; i < buffer.length; i++) {
// 注意buffer數據是rgb的,而ImageData是rgba的
if (i && i % 3 === 0) {
imageData.data[k++] = 255;
}
imageData.data[k++] = buffer[i];
}
imageData.data[k] = 255;
memCanvas.width = width;
memCanvas.height = height;
canvas.height = canvas.width * height / width;
memContext.putImageData(imageData, 0, 0, 0, 0, width, height);
ctx.drawImage(memCanvas, 0, 0, width, height, 0, 0, canvas.width, canvas.height);
}
drawImage(width, height, imageBuffer);複製代碼
這樣基本就完工了,可是還有一個很重要的事情要作,就是把申請的內存給釋放,否則反覆操做幾回以後,網頁的內存就飆到一兩個G,而後就拋內存不夠用異常了,因此在drawImage後以後把申請的內存釋放了:
drawImage(width, height, imageBuffer);
// 釋放內存
Module._free(offset);
Module._free(ptr);
Module._free(imgBufferPtr);複製代碼
在C裏面寫的代碼也要釋放掉中間過程申請的內存,否則這個內存泄露仍是挺厲害的。若是正確free以後,每次執行malloc的地址都是16358200,沒有free的話,每次都會從新擴容,返回遞增的offset地址。
可是這個東西總體消耗的內存仍是比較大。
初始化ffmpeg以後,網頁使用的內存就飆到500MB,若是選了一個300MB的文件處理,內存就會飆到1.3GB,由於在調setFile的時候須要malloc一個300MB大小的內存,而後在C代碼的setFile執行過程當中又會malloc一個300MB大小的context變量,由於要處理mov/m4v格式的話爲了獲取moov信息須要這麼大的,暫時沒優化,這幾個加起來就超過1GB了,而且WebAssembly.Memory只能grow,不能shrink,即只能往大擴,不能往小縮,擴充後的內存就一直在那裏了。而對於普通的mp4文件,context變量只須要1MB,這個能夠把內存控制在1GB之內。
第二個問題是生成的wasm的文件比較大,原始有12.6MB,gzip以後還有5MB,以下圖所示:
由於ffmpeg自己比較大,若是可以深刻研究源碼,而後把一些沒用的功能disable掉或者不要include進來應該就能夠給它瘦身,或者是隻提取有用的代碼,這個難度可能略高。
第三個問題是代碼的穩健性,除了想辦法把內存降下來,還須要考慮一些內存訪問越界的問題,由於有時候跑着跑着就拋了這個異常:
Uncaught RuntimeError: memory access out of bounds
雖然存在一些問題,可是起碼已經跑起來,可能暫時還不具有部署生產環境的價值,後面能夠慢慢優化。
除了本文這個例子外,還能夠利用ffmpeg實現其它一些功能,讓網頁也可以直接處理多媒體。基本上只要ffmpeg能作的,在網頁也是能跑,而且wasm的性能要比直接跑JS的高。