wasm + ffmpeg實現前端擷取視訊幀功能

人人網FED發表於2018-07-28

有沒有那麼一種可能,在前端頁面處理音視訊?例如使用者選擇一個視訊,然後支援他設定視訊的任意一幀作為封面,就不用把整一個視訊上傳到後端處理了。經過筆者的一番摸索,基本實現了這個功能,一個完整的demo:ffmpeg wasm擷取視訊幀功能

支援mp4/mov/mkv/avi等檔案。基本的思想是這樣的:

使用一個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直接編解碼也會比較耗時。所以有現成的先用現成的。

第1步是編譯(如果你對編譯過程不感興趣的話,可以直接跳到第2步)

1. 編譯ffmpeg為wasm版本

我一開始以為難度會很大,後來發現並沒有那麼大,因為有一個videoconverter.js已經轉過了(它是一個藉助ffmpeg在網頁實現音視訊轉碼的),關鍵在於把一些沒用的特性在configure的時候給disable掉,不然編譯的時候會報語法錯誤。這裡使用的是emsdk轉的wasm,emsdk的安裝方法在它的安裝教程已經說得很明白,主要是使用指令碼判定系統下載不同編譯好的檔案。下載好之後就會有幾個可執行檔案,包括emcc、emc++、emar等命令,emcc是C的編譯器,emc++是C++的編譯器,而emar是用於把不同的.o庫檔案打包成一個.a檔案的。

先要在ffmpeg的官網下載原始碼。

(1)configure

解壓進入目錄,然後執行以下命令:

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裡面。

而前面的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和相關的一些配置檔案。

(2)make

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就好了)

2. 使用ffmpeg

ffmpeg主要是由幾個lib目錄組成的:

  • libavcodec: 提供編解碼功能
  • libavformat:多路解複用(demux)和多路複用(mux)
  • libswscale:影象伸縮和畫素格式轉化

以一個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.

3. js和wasm的互動

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;
}複製程式碼

需要傳遞三個引數:

  • buff:原始的視訊資料(通過JS的ArrayBuffer傳進來)
  • buffLength:視訊buff的總大小(單位位元組)
  • timestamp:是希望擷取第幾秒的視訊幀

最後處理完了返回一個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.jsindex.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>
!function() {
   let setFile = null;
   // WASM下載並解析完畢
   Module.onRuntimeInitialized = function () {
        console.log('WASM initialized done!');
        // 匯出的核心處理函式
        setFile = Module.cwrap('setFile', 'number',
                      ['number', 'number', 'number']);
   };
}();
</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的記憶體堆模型。

4. 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畫一下。

5. 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地址。

但是這個東西整體消耗的記憶體還是比較大。

6. 存在的問題

初始化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的高。


相關文章