看前端如何通過(guò)WebAssembly實(shí)現(xiàn)播放器預(yù)覽能力
最近,團(tuán)隊(duì)小組內(nèi)部體驗(yàn)Web瀏覽器上課的音視頻播放功能,除了對(duì)比同行產(chǎn)品,也對(duì)比了主流視頻內(nèi)容的網(wǎng)站平臺(tái)。計(jì)劃補(bǔ)齊和增強(qiáng)與播放體驗(yàn)相關(guān)的能力。
其中有一項(xiàng)能力在主流媒體視頻網(wǎng)站都支持的,那就是進(jìn)度條幀預(yù)覽:在鼠標(biāo)進(jìn)度條停留,不必跳轉(zhuǎn)進(jìn)度,即可展示所指畫(huà)面。

在簡(jiǎn)單分析了B站、騰訊視頻后,發(fā)現(xiàn)都是采取在上架視頻時(shí),由后臺(tái)生成專門(mén)用來(lái)幀預(yù)覽的組合sprite圖,然后前端拉取后再計(jì)算進(jìn)度進(jìn)行展示。

由于目前的我們后臺(tái)云點(diǎn)播錄制沒(méi)有生成幀預(yù)覽圖功能。另一方面,即便升級(jí)可能大量的存量存儲(chǔ)視頻無(wú)法幀預(yù)覽。于是我們決定嘗試前端實(shí)現(xiàn)動(dòng)態(tài)幀預(yù)覽的方案。

瀏覽器獲取視頻畫(huà)面的方法:
目前瀏覽器視頻幀提取的方案主要有:
canvas + video方案:主要video在播通過(guò)canvas的drawImage提取視頻幀。但注意瀏覽器一般只能解析MP4/WebM的格式, H264/VP8編解碼的視頻。如果不是指定格式,要先解復(fù)用在利用MSE來(lái)實(shí)現(xiàn)。 webassembly + ffmpeg方案:webassembly的出現(xiàn)為前端解碼視頻數(shù)據(jù)提供了可能,將ffmpeg編譯為wasm庫(kù),通過(guò)js調(diào)用并提取視頻幀數(shù)據(jù),再給到canvas繪制。
第一種方案對(duì)于單個(gè)MP4文件還是合適的,但hls資源不是完整加載,并且瀏覽器不能直接復(fù)用ts格式,所以行不通。
HLS動(dòng)態(tài)解密ts分片wasm ffmpeg獲取幀畫(huà)面的技術(shù)方案

整體技術(shù)方案:?
①通過(guò)解析HLS masterPlayList 和 levelPlayList,拿到低清晰度的ts文件索引數(shù)組。
②支持區(qū)分判斷HLS加密,獲取解密秘鑰,AES 解密ts文件數(shù)據(jù)。
③ts文件arraybuffer數(shù)據(jù),申請(qǐng)內(nèi)存并寫(xiě)入wasm,調(diào)用wasm封裝截圖方法,返回RGB數(shù)據(jù)。
④將RGB數(shù)據(jù)轉(zhuǎn)為canvas imagedata,更新展示幀畫(huà)面,并緩存。監(jiān)聽(tīng)鼠標(biāo)事件定位幀緩存畫(huà)面,或加載新數(shù)據(jù)。
FFmpeg編譯至WebAssembly
前置準(zhǔn)備
安裝emscripten的emsdk,實(shí)際上會(huì)遇到不少困難。按照emscripten官網(wǎng)的指示一步一步,遇到阻礙及時(shí)谷歌變更解決。安裝完成后執(zhí)行emcc -v 能查看版本,代表安裝成功。
#?Get?the?emsdk?repo
git?clone?https://github.com/emscripten-core/emsdk.git
#?Enter?that?directory
cd?emsdk
#?Download?and?install?the?latest?SDK?tools.
./emsdk?install?latest
#?Make?the?"latest"?SDK?"active"?for?the?current?user.?(writes?.emscripten?file)
./emsdk?activate?latest
#?Activate?PATH?and?other?environment?variables?in?the?current?terminal
source?./emsdk_env.sh
FFmpeg編譯
FFmpeg是個(gè)優(yōu)秀的音視頻處理庫(kù),包含了采集、格式轉(zhuǎn)化、編解碼、截圖、濾鏡等能力。我們需要禁用掉大部分能力,只編譯我們需要的部分,最后編譯產(chǎn)物是c依賴庫(kù)和相關(guān)頭文件。
emconfigure?./configure?\
????--prefix=$WEB_CAPTURE_PATH/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-ffmpeg?\
????--disable-ffplay?\
????--disable-ffprobe?\
????--disable-doc?\
????--disable-ffserver?\
????--disable-swresample?\
????--disable-postproc??\
????--disable-programs?\
????--disable-avfilter?\
????--disable-pthreads?\
????--disable-w32threads?\
????--disable-os2threads?\
????--disable-network?\
????--disable-logging?\
????--disable-everything?\
????--enable-protocol=file?\
????--enable-demuxer=mpegts?\
????--enable-decoder=h264?\
????--disable-asm?\
????--disable-debug?\
分析ffmpeg提取幀流程
視頻文件數(shù)據(jù)到幀的圖像數(shù)據(jù),按照流程:解格式封裝、視頻解碼,圖像數(shù)據(jù)轉(zhuǎn)換(YUV=>RGB)。則按照HLS分片提取圖像數(shù)據(jù)流程,需要涉及到以下ffmpeg中的庫(kù)。
libavcodec:提供編解碼功能。這里我只是需要H264的視頻編解碼。 libavformat:多路解復(fù)用(demux)和多路復(fù)用(mux)。這里我3需要解復(fù)用ts文件的格式、即mpegts。 libswscale:圖像伸縮和像素格式轉(zhuǎn)化。 libavutil:工具函數(shù)。
編譯至Wasm
最后需要通過(guò) emcc 來(lái)將demuxer和decoder和依賴的相關(guān)庫(kù)編譯為 wasm 然后提供瀏覽器使用javascript進(jìn)行調(diào)用。編譯選項(xiàng)如下:
emcc?./getframe.c?./ffmpeg/lib/libavformat.a?./ffmpeg/lib/libavcodec.a?./ffmpeg/lib/libswscale.a?./ffmpeg/lib/libavutil.a?\
????-O3?\
????-I?"./ffmpeg/include"?\
????-s?WASM=1?\
????-s?TOTAL_MEMORY=33554432?\
????-s?EXPORTED_FUNCTIONS='["_main",?"_free",?"_getFrame",?"_setFile"]'?\
????-s?ASSERTIONS=1?\
????-s?ALLOW_MEMORY_GROWTH=1?\
????-s?MAXIMUM_MEMORY=4GB?\
????-o?getframe.js
emcc編譯選項(xiàng),請(qǐng)參考: https://emscripten.org/docs/tools_reference/emcc.html?
最后編譯wasm成功是一個(gè)wasm的二進(jìn)制文件,和一個(gè)膠水代碼js文件。
Blockquote EXPORTED_FUNCTIONS: 參數(shù)告訴編譯器,代碼里面需要輸出的函數(shù)名。函數(shù)名前面要加下劃線.
ASSERTIONS: ASSERTIONS=1 用于為內(nèi)存分配錯(cuò)誤啟用運(yùn)行時(shí)檢查(例如,寫(xiě)入比分配更多的內(nèi)存)。它還定義了Emscripten如何處理程序流中的錯(cuò)誤。可以將值設(shè)置為ASSERTIONS=2,以便運(yùn)行額外的測(cè)試。
ALLOW_MEMORY_GROWTH: Emscripten堆一經(jīng)初始化,容量就固定了,無(wú)法再擴(kuò)容。而某些程序在運(yùn)行時(shí)需要的內(nèi)存容量在不同工況下可能有很大的波動(dòng)。為了滿足某些極端工況的需求而將TOTAL_MEMORY設(shè)置得非常高無(wú)疑是非常浪費(fèi)的,為此,Emscripten提供了可在運(yùn)行時(shí)擴(kuò)大內(nèi)存容量的模式,欲開(kāi)啟該模式,需要在編譯時(shí)增加-s ALLOW_MEMORY_GROWTH=1參數(shù)。
封裝API
這里參考了網(wǎng)上一些現(xiàn)成的做法,雖然可以生成ffmpeg.js和ffpmeg.wasm,并提供Module對(duì)象來(lái)操控,但是這樣JS的數(shù)據(jù)類型和C的數(shù)據(jù)類型差異比較多,頻繁地調(diào)C的API,讓數(shù)據(jù)傳來(lái)傳去比較麻煩。這里參考網(wǎng)上的教程、前置封裝ffmpeg的API,具體參考這里的實(shí)現(xiàn)和教程:https://github.com/liyincheng/ffmpeg-wasm-video-to-picture http://dranger.com/ffmpeg/tutorial01.html。ffempg可調(diào)用函數(shù):http://dranger.com/ffmpeg/functions.html 。
注冊(cè)所有可用的文件格式和編解碼器,后續(xù)打開(kāi)具有相應(yīng)格式/編解碼器的文件時(shí)就可使用,請(qǐng)注意,我們?cè)趍ain()中只需要調(diào)用一次 av_register_all()即可。
#include?
#include?
#include?
#include?
int?main(int?argc,?char?const?*argv[])?{
????av_register_all();
????return?0;
}
打開(kāi)文件、檢索流信息、找視頻流的解碼器、復(fù)制上下文并打開(kāi)編解碼器。
AVFormatContext?*pFormatCtx?=?NULL;
//?Open?video?file
if(avformat_open_input(&pFormatCtx,?argv[1],?NULL,?0,?NULL)!=0)
return?-1;?//?Couldn't?open?file
??
//?Retrieve?stream?information
if(avformat_find_stream_info(pFormatCtx,?NULL)<0)
??return?-1;?//?Couldn't?find?stream?information
AVCodec?*pCodec?=?NULL;
//?Find?the?decoder?for?the?video?stream
pCodec=avcodec_find_decoder(pCodecCtx->codec_id);
if(pCodec==NULL)?{
??fprintf(stderr,?"Unsupported?codec!\n");
??return?-1;?//?Codec?not?found
}
//?Copy?context
pCodecCtx?=?avcodec_alloc_context3(pCodec);
if(avcodec_copy_context(pCodecCtx,?pCodecCtxOrig)?!=?0)?{
??fprintf(stderr,?"Couldn't?copy?codec?context");
??return?-1;?//?Error?copying?codec?context
}
//?Open?codec
if(avcodec_open2(pCodecCtx,?pCodec)<0)
??return?-1;?//?Could?not?open?codec
其中主要步驟在于,讀取整個(gè)數(shù)據(jù)流,方法是讀取數(shù)據(jù)包,將其解碼為幀,一旦幀完成,我們將對(duì)其進(jìn)行轉(zhuǎn)換RGB(PIX_FMT_RGB24)并保存。
struct?SwsContext?*sws_ctx?=?NULL;
int?frameFinished;
AVPacket?packet;
//?initialize?SWS?context?for?software?scaling
sws_ctx?=?sws_getContext(pCodecCtx->width,
????pCodecCtx->height,
????pCodecCtx->pix_fmt,
????pCodecCtx->width,
????pCodecCtx->height,
????PIX_FMT_RGB24,
????SWS_BILINEAR,
????NULL,
????NULL,
????NULL
????);
i=0;
while(av_read_frame(pFormatCtx,?&packet)>=0)?{
??//?Is?this?a?packet?from?the?video?stream?
??if(packet.stream_index==videoStream)?{
?//?Decode?video?frame
????avcodec_decode_video2(pCodecCtx,?pFrame,?&frameFinished,?&packet);
????
????//?Did?we?get?a?video?frame?
????if(frameFinished)?{
????//?Convert?the?image?from?its?native?format?to?RGB
????????sws_scale(sws_ctx,?(uint8_t?const?*?const?*)pFrame->data,
????pFrame->linesize,?0,?pCodecCtx->height,
????pFrameRGB->data,?pFrameRGB->linesize);
?
????????//?Save?the?frame?to?disk
????????if(++i<=5)
??????????SaveFrame(pFrameRGB,?pCodecCtx->width,?
????????????????????pCodecCtx->height,?i);
????}
??}
????
??//?Free?the?packet?that?was?allocated?by?av_read_frame
??av_free_packet(&packet);
}
javascript 調(diào)用 wasm
https://www.cntofu.com/book/150/zh/ch2-c-js/ch2-01-js-call-c.md?
javascript調(diào)用wasm,簡(jiǎn)單概括是就是內(nèi)存的寫(xiě)入與讀取的過(guò)程。理論上HLS文件拿到ts分片文件,將文件保存Unit8Array,并寫(xiě)入到wasm中。
xhr 請(qǐng)求 tsFile 保存為 Uint8Array
let?tsBuffer?=?new?Uint8Array(tsFileArrayBuffer);
申請(qǐng)內(nèi)存空間
let?tsBufferPtr?=?Module._malloc(tsBuffer.length);
將buffer 寫(xiě)入 wasm 內(nèi)存
Module.HEAP8.set(tsBuffer,?tsBufferPtr);
執(zhí)行封裝 _getFrame 函數(shù),傳入內(nèi)存指針,內(nèi)存大小,時(shí)間點(diǎn)。
let?imgData?=?Module._getFrame(tsBufferPtr,?tsBuffer.length,?time)
去的RGB數(shù)據(jù)在js層轉(zhuǎn)化canvas可用數(shù)據(jù)。
?const?canvas?=?document.createElement('canvas');
?const?ctx?=?canvas.getContext('2d');
canvas.width?=?width;
canvas.height?=?height;
let?imageData?=?ctx.createImageData(width,?height);
let?j?=?0;
for?(let?i?=?0;?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);
const?finalData?=?canvas.toDataURL('image/jpeg');
HLS動(dòng)態(tài)加載ts分片及解密
HLS masterPlayList/ levelPalyList解析

HLS點(diǎn)播資源并非單文件,而是一個(gè)m3u8協(xié)議的索引。要取拿到幀數(shù)據(jù),必須要加載ts分片文件數(shù)據(jù)。必須先HLS解析m3u8文件。由于我們?nèi)瑘D片拿來(lái)做預(yù)覽,并不需要很大的尺寸和清晰度。當(dāng)包含多個(gè)level(清晰度)的情況下,優(yōu)先選取最低清晰度的levelPlayList。
MSE HLS解析:一般MSE HLS使用hls.js加載視頻播放,通過(guò)其創(chuàng)建實(shí)例(client),在onManifestParsed事件后通過(guò)client.levels可以讀取到到不同level的參數(shù)。

Native HLS解析:對(duì)于移動(dòng)端瀏覽器,或者safari等瀏覽器,使用native播放m3u8的模式。我們可以自己解析m3u8的masterPlayList,然后自行解析。比如通過(guò)BANDWIDTH和RESOLUTION,取出最低清晰度,或者可以借助m3u8-parser進(jìn)行解析。
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:PROGRAM-ID=0,BANDWIDTH=2099325,RESOLUTION=1920x1080
v.f124099.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=0,BANDWIDTH=197642,RESOLUTION=1280x720
v.f22239.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=0,BANDWIDTH=142162,RESOLUTION=960x540
v.f22240.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=0,BANDWIDTH=95767,RESOLUTION=480x270
v.f22241.m3u8
獲取playList的segments映射時(shí)間段
我們需要知道定位時(shí)間距離最近的segment,直接維護(hù)最低清晰度playlist數(shù)組,根據(jù)#EXTINF得知每個(gè)segment的duration時(shí)長(zhǎng),計(jì)算出總時(shí)長(zhǎng),及每個(gè)segment的開(kāi)始時(shí)間和結(jié)束時(shí)間。當(dāng)我們定位到指定時(shí)間,即可匹配到最近的ts文件作為被解析的數(shù)據(jù)。
......
#EXTINF:10.000000,
v.f22241.ts?start=260400&end=382047&type=mpegts
#EXT-X-KEY:METHOD=AES-128,URI="http://getkeyurl",IV=0x00000000000000000000000000000000
#EXTINF:10.000000,
v.f22241.ts?start=382048&end=502943&type=mpegts
#EXT-X-KEY:METHOD=AES-128,URI="http://getkeyurl",IV=0x00000000000000000000000000000000
#EXTINF:10.000000,
v.f22241.ts?start=502944&end=623455&type=mpegts
#EXT-X-KEY:METHOD=AES-128,URI="http://getkeyurl",IV=0x00000000000000000000000000000000
#EXTINF:10.000000,
v.f22241.ts?start=623456&end=748303&type=mpegts
.....
AES解密ts文件
獲取解密Key由于點(diǎn)播HLS資源已經(jīng)進(jìn)行了加密,ts文件數(shù)據(jù)無(wú)法直接給到wasm截取幀畫(huà)面。所以要對(duì)ts進(jìn)行解密。當(dāng)解析playlist時(shí)候匹配到#EXT-X-KEY:METHOD=AES-128則需要解密。KEY需要從URI屬性的地址請(qǐng)求獲取,一般具備登錄態(tài)的請(qǐng)求正確返回。IV數(shù)據(jù)直接取playlist上的IV即可。
同樣的,在MSE HLS播放的,hls.js實(shí)例上能讀取到KEY和IV;對(duì)于native hls播放的,需要自己二次請(qǐng)求獲取。
WebCrypto ASE解密參考hls.js源碼,將請(qǐng)求到的ts分片進(jìn)行解密。https://github.com/videojs/aes-decrypter
?let?decrypter?=?new?Decrypter();
?const?{?key,?iv?}?=?levelKey;
?decrypter.decrypt(data,?key,?iv,?(data)?=>?{
?????const?finalSegmentFile?=?new?Blob([data],?{
?????????type:?'video/mp2t',
?????});
?????//?給到wasm寫(xiě)入finalSegmentFile
?});
點(diǎn)播進(jìn)度幀預(yù)覽邏輯及緩存策略
動(dòng)態(tài)節(jié)流加載,并緩存至對(duì)應(yīng)時(shí)間區(qū)間:由于用戶的鼠標(biāo)在進(jìn)度條可能頻繁移動(dòng),這里設(shè)計(jì)應(yīng)該監(jiān)聽(tīng)mousemove但節(jié)流觸發(fā)。從解析playlist開(kāi)始,到ts文件加載與解密,wasm解碼獲取幀數(shù)據(jù)拿到imagedata,設(shè)置500ms觸發(fā)閾值,獲取幀圖像數(shù)據(jù)緩存到對(duì)應(yīng)時(shí)間區(qū)間。
就近讀取緩存幀畫(huà)面:一般來(lái)說(shuō),相鄰進(jìn)度的幀畫(huà)面往往是相似,但加載到解幀的整個(gè)過(guò)程異步且存在一定耗時(shí),優(yōu)先展示相鄰分片區(qū)間的緩存幀圖像數(shù)據(jù),可以讓用戶第一時(shí)間感知,提升體驗(yàn)效果。

問(wèn)題與小結(jié)
用wasm做前端播放幀預(yù)覽的能力,已經(jīng)在業(yè)務(wù)側(cè)灰度上線。由于我們只需要解復(fù)用mpegts和h624decoder,編譯wasm大小2.6MB左右。主要受限于加載分片的網(wǎng)絡(luò)耗時(shí),從hover進(jìn)度條到預(yù)覽圖展示約在1.1秒左右,wasm解幀耗時(shí)60ms以內(nèi)。在支持wasm的PC瀏覽器上chrome、新版firefox和safari也都沒(méi)什么太大問(wèn)題。
目前一個(gè)完整600M左右的高清回放資源,如果加載完整的資源用于幀預(yù)覽的消耗30-50MB流量,但實(shí)際情況下并不會(huì)完整的加載,一般都只在10M以內(nèi)。雖然這部分資源被http緩存,但是如果不是因?yàn)榫W(wǎng)絡(luò)差而走到低清晰度的情況下,這部分資源的流量多少還是有點(diǎn)被浪費(fèi)了。
這幾年wasm從一些demo嘗試,到業(yè)務(wù)真正落地,越來(lái)越多場(chǎng)景中被得到應(yīng)用。以前一些客戶端app才有的功能,現(xiàn)在瀏覽器也并非不可想象。期待可以落地更多有趣和實(shí)用的功能。
愛(ài)心三連擊
1.看到這里了就點(diǎn)個(gè)在看支持下吧,你的在看是我創(chuàng)作的動(dòng)力。
2.關(guān)注公眾號(hào)腦洞前端,獲取更多前端硬核文章!加個(gè)星標(biāo),不錯(cuò)過(guò)每一條成長(zhǎng)的機(jī)會(huì)。
3.如果你覺(jué)得本文的內(nèi)容對(duì)你有幫助,就幫我轉(zhuǎn)發(fā)一下吧。
