wasm + ffmpeg實現前端截取視頻幀功能

有沒有那麼一種可能,在前端頁面處理音視頻?例如用戶選擇一個視頻,然後支持他設置視頻的任意一幀作爲封面,就不用把整一個視頻上傳到後端處理了。經過筆者的一番摸索,基本實現了這個功能,一個完整的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的高。

編輯於 2018-07

來源: 影音視頻技術空間
文章作者: YUV420.COM
更多文章: https://www.yuv420.com/
 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章