wasm + ffmpeg做到前端截取視頻幀功能

尋夢新聞[email protected]每日推播熱門推薦文章,趣聞不漏接❤️

加入LINE好友

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

// 讀取文件

fileReader.readAsArrayBuffer(form.file.files[0]);

};

讀取得到的buffer放在了一個Uint8Array,它是一個數組,數組里面每個元素都是unit8類型的即無符號8位整型,就是一個字節的0101的數字大小。

接下來的關鍵問題是:怎麼把這個buffer傳給wasm的setFile函數?這個需要理解wasm的記憶體堆模型。

4. wasm的記憶體堆模型

上面在編譯的時候指定的wasm使用的總記憶體大小,記憶體里面的內容可以通過Module.buffer和Module.HEAP8查看:

wasm + ffmpeg做到前端截取視頻幀功能 科技 第1張

這個東西就是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數據是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,如下圖所示:

wasm + ffmpeg做到前端截取視頻幀功能 科技 第2張

因為ffmpeg本身比較大,如果能夠深入研究源碼,然後把一些沒用的功能disable掉或者不要include進來應該就可以給它瘦身,或者是只提取有用的代碼,這個難度可能略高。

第三個問題是代碼的穩健性,除了想辦法把記憶體降下來,還需要考慮一些記憶體訪問越界的問題,因為有時候跑著跑著就拋了這個異常:

Uncaught RuntimeError: memory access out of bounds

雖然存在一些問題,但是起碼已經跑起來,可能暫時還不具備部署生產環境的價值,後面可以慢慢優化。

除了本文這個例子外,還可以利用ffmpeg做到其它一些功能,讓網頁也能夠直接處理多媒體。基本上只要ffmpeg能做的,在網頁也是能跑,並且wasm的性能要比直接跑JS的高。

About 尋夢園
尋夢園是台灣最大的聊天室及交友社群網站。 致力於發展能夠讓會員們彼此互動、盡情分享自我的平台。 擁有數百間不同的聊天室 ,讓您隨時隨地都能找到志同道合的好友!