「html
WebAssembly,因其接近機器碼的特性和更小的文件體積,使用wasm文件相較於js會有編譯和加載時間更少的優點,加上利用Emscripten等編譯器工具能夠將c/c++編譯成wasm文件極大的拓展了前端的視野,從2017年推出以來,就一直是前端開發者們關注的熱點。> 如今,咱們已經能夠看到不少基於wasm遊戲/音視頻/web文件處理等方面的web應用。IVWEB團隊負責NOW直播等直播場景的業務開發,在探索wasm技術落地的背景下,實現了一款基於wasm的直播播放器。前端
wasm+h265 播放器,這是一個不太友好的標題,除了播放器三個字,wasm 和 h265 又是什麼,ivweb團隊爲何要作它?c++
WebAssembly(如下簡稱 wasm)已經推出數年,不少優秀的開發者已經開始在他們的項目用用上wasm來提升密集運算下的代碼性能,推薦閱讀這篇文章來了解wasm是什麼。git
h265是什麼?人們在音視頻領域的不斷探索獲得了很好的反饋,兩大視頻標準制定組織ITU-T與ISO/IEC每隔幾年都會制定出新一代視頻編碼標準,H.26x系列和MPEG系列在不斷的更新。目前應用普遍的H.264標準正在被新一代編碼標準HEVC替代。高效率視頻編碼HEVC(High Efficiency Video Coding)也被稱爲 H.265(如下簡稱h265),它在保留了H.264的基礎上對一部分技術(動態宏塊劃分/更多的幀內預測模式/更好的運動補償處理)加以改進,使得它具備更好的質量和更高的壓縮率,相同的圖像質量(PSNR)的視頻,碼率能夠下降30%-50%。github
雖然咱們團隊負責直播業務,但做爲一個 web 開發,在直播視頻內容的工做領域,真的只能作一點微不足道的工做——掌握並使用基於瀏覽器封裝的播放器video。當咱們眼饞於 h265 帶來的如此多的提高後,卻發現當前主流的播放器(除 safari 外)受限於解碼能力並不支持 h265 的播放,包括flv.js在內的web解碼播放方案目前也不支持 h265,咱們的首選 video 標籤方案被排除出 h265 的使用場景範圍。**我不信!我不聽!我無論!**一波三連否認後咱們決定繼續探索新的方案。web
連麥混流直播流編碼類型 | 測試時間 | 流量大小 |
---|---|---|
H265 | 15 (min) | 68.1MB |
H264 | 15 (min) | 137MB |
通過15 分鐘的測試,使用 h265 會比 h264 的直播流節省30%的流量,連麥混流能夠節省50%。(普通直播流目前騰訊云爲NOW直播h265設置碼率轉換因子爲70,理論上節省30%)算法
當 wasm 推出後,熱門的應用領域就包含了音視頻的編碼解碼,github 上開源了視頻裁剪/視頻解碼的 webassembly 方案,淘寶直播和花椒直播也在今年探索了 wasm+h265 播放器方案,在播放器領域已經有前人的成功實踐。基於對播放器各個模塊的分析,幾個核心模塊(數據io/渲染層/控制層)均可以基於 web 的成熟技術完成,而視頻解碼能夠基於成熟編碼技術完成,基於ffmpeg的解碼代碼通過 Emscripten 編譯到 wasm 文件引入 web 中。chrome
視頻數據封裝在流媒體中,當前經常使用的流媒體協議有 RTMP/HLS/HTTP-FLV,其中 HLS 和 HTTP-FLV 經過 HTTP 傳輸,數據能夠直接交給 js 處理,這一節咱們從流媒體出發,抽絲剝繭地分析播放器每一步對數據的處理,整理出 wasm + h265 直播播放器的框架。typescript
第一次接觸到流媒體的是播放器的 io 層,播放器在這裏請求數據,並交給 io controller,用於以後的解碼播放。以NOW直播爲例,騰訊云爲咱們提供了HTTP-FLV 流媒體,音視頻數據被封裝在 FLV 容器中,經過 HTTP 傳輸。要獲取流媒體中的數據,web提供了流式數據的處理方案,利用 XMLHttpRequest 或者 Fetch Api 能夠直接讀取數據流。如下是一個簡單的 fetch 數據流例子:shell
fetch(url, {
method: 'GET',
responseType: 'arraybuffer'
})
.then(res => {
return res.body.getReader();
});
複製代碼
將 url 替換成直播流地址,獲取到是一個可讀流,經過 read 方法能夠讀取數據。
const stream = new ReadableStream({
start(controller) {
const push = () => {
reader.read()
.then((res) => {
if (res.done) {
// 流結束處理
}
// res.value 數據讀取處理,交給controller
this.emit('data', res.value);
push();
});
};
push();
}
});
複製代碼
io controller 在收到 fetch 到視頻數據後,先寫入緩存池,達到播放器設定的閾值後(解碼啓動的閾值不能過小,至少須要 ffmpeg 設置的 probesize 大小,ffmpeg 須要循環讀取多幀數據來肯定視頻幀率/碼率/高寬度等信息 ),纔將數據交給上層控制器。
播放器核心控制器收到 io 層的數據後正式開始解碼處理,以前提到過解碼器是用 Emscripten 編譯成的 .wasm 文件和 .js 膠水代碼,膠水代碼提供了 Module 全局對象來訪問 wasm 的生命週期/內存模型等。播放器 io 層中獲取到的 buffer 數據要怎麼傳遞給解碼器呢,解碼器的解碼後的音視頻幀數據又怎麼回傳給播放器呢?
2.2.1 播放器(js) 到 解碼器(wasm)
數據交互的核心是要理解 wasm 的內存模型。在生成wasm的時候,咱們會設置wasm的內存大小,在如下編譯腳本中,先爲 wasm 設置了一個64M的內存大小。
export TOTAL_MEMORY=67108864
emcc $WASM_SOURCE/decoder_stream.c ... \
...
-s TOTAL_MEMORY=${TOTAL_MEMORY} \
...
-o $LIB_PATH/libffmpeg_live.js
複製代碼
內存模型是一個arraybuferr,經過膠水代碼提供的 Module 全局對象提供的視圖(view)能夠查看,wasm 的視圖與 js 中typedArray 是對應的,視圖類型多樣,有HEAP8(Int8Array)/HEAPU8(Uint8Array)/HEAP16(Int16Array)/HEAPU16(Uint16Array) 等。以下圖所示:
當播放器(js)須要向解碼器(wasm)傳遞數據時,須要三步操做:
將數據寫入wasm數據模型
傳遞數據指針和數據長度
wasm 根據指針和長度讀取數據
const offset = Module._malloc(buffer.length); // _malloc申請一塊內存,並返回指針
Module.HEAPU8.set(buffer, offset); // 數據寫入分配的內存空間
Module._sendData(offset, buffer.length); // 傳遞指針和數據長度
Module._free(offset); // 注意:使用完後須要回收內存
複製代碼
int sendData(uint8_t *buff, const int size) {
...
memcpy(pValid, buff, size); // 寫入數據區
pValid += size; // 更新數據區結尾指針
...
logger("數據大小: %d \n", size);
}
複製代碼
2.2.2 解碼器(wasm) 到 播放器(js)
明白了從播放器到解碼器的數據傳輸,從解碼器(wasm)向播放器(js)傳遞數據其實就是一個反向操做,也須要三步操做:
傳入播放器回調函數
解碼器調用播放器回調函數傳遞數據
js 讀取數據指針和數據長度解析出數據
void initDecoder(VideoCallback videoCallback ...) {
...
if (decoder == NULL)
{
decoder->videoCallback = videoCallback;
...
}
}
int decodeVideoFrame() {
...
decoder->videoCallback(out_buffer, buffer_size); // 經過回調傳遞數據
...
}
複製代碼
function videoCallback() {
const videoBuffer = Module.HEAPU8.subarray(buff, buff + size);
const data = new Uint8Array(videoBuffer);
}
複製代碼
做爲一個即不會寫c語言又不懂音視頻解碼的web開發,在完成解碼器的時候,學習了很是多雷霄驊博士的解碼入門文章,感謝雷博士在音視頻基礎普及上的努力。
對於直播流的解碼,解碼器主要作了件事:
獲取視頻流 metadata 信息
視頻解封裝
解碼視頻,視頻格式化YUV420
解碼音頻,音頻格式化PCM
數據回調
如下是核心解封裝/解碼流程:
首先是獲取從內存讀取視頻流,獲得視頻的基本信息(高/寬/視頻解碼器/音頻解碼器/音頻採樣率/採樣格式等),用於以後渲染層的音頻/視頻參數設置。
解封裝是爲了從flv容器中取出視頻流和音頻流,經過av_read_frame()方法存放到一個叫AVPacket的結構體中,packet中包含數據的顯示時間(pts),解碼時間(dts)和數據流id(stream_id),經過stream_id能夠肯定是視頻流仍是音頻流。
解碼是解壓縮,視頻解碼是從壓縮數據中解出每一幀圖像數據,音頻解碼是從壓縮數據中解出音頻數據,解碼出的數據須要進行格式化處理方便以後渲染器渲染。在視頻解碼中,咱們從h265的視頻流中取出真正的視頻幀,視頻幀進行高寬度轉換和數據格式處理。
// 向解碼器投喂數據
avcodec_send_packet(videoCodecCtx, packet);
// 接收解碼器輸出的數據幀
avcodec_receive_frame(videoCodecCtx, avframe);
// 建立緩衝區空間
uint8_t *out_buffer = (uint8_t *)av_malloc(buffer_size);
// 向緩存填充數據
av_image_fill_arrays(
frame->data, // 幀數據
frame->linesize, // 單行數據大小
out_buffer, // 緩衝區
AV_PIX_FMT_YUV420P, // 像素數據格式
... // 高度寬度
)
// 建立格式轉換上下文
struct SwsContext *sws_ctx = sws_getContext(
width, // 輸入寬度
height, // 輸入高度
videoCodecCtx->pix_fmt, // 輸入數據
videoWidth, // 輸出寬度
videoHeight, // 輸出高度
AV_PIX_FMT_YUV420P, // 輸出數據格式
SWS_BICUBIC, // 格式轉換算法類型
...
);
// 格式轉換
sws_scale(
sws_ctx, // 格式轉換上下文
(uint8_t const *const *)avframe->data // 輸入數據
...
);
複製代碼
注意: 選擇不一樣的格式轉換算法性能會不一樣。
最後計算當前幀的pts(播放時間戳),經過數據回調,將幀數據返回給播放器處理。
// 計算播放時間戳 pts
double timestamp = (double)avFrame->pts * av_q2d(decoder->->streams[videoStreamIdx]->time_base);
// 回調函數返回幀數據
decoder->videoCallback(out_buffer, buffer_size, timestamp);
複製代碼
解碼器須要利用cpu軟解視頻流,越大的碼率的視頻解碼佔用的cpu越高,爲了不解碼阻塞主線程工做,咱們把解碼器做爲一個單獨的web worker運行。
解碼後的數據會交給渲染層完成與用戶的見面,從解碼器獲取的數據格式:
const frame = {
type, // 0 視頻幀 / 1 音頻幀
timestamp, // pts 毫秒
data // 幀數據
}
複製代碼
音頻幀和視頻幀不停的輸入到渲染緩存池中,通過音視頻同步處理(下文涉及),從緩存池中取出數據數據,交給webcl去完成渲染。取出音頻數據交給音頻播放器播放。
解碼後的音頻數據格式是PCM,這是一種對音頻信號的數字化表示,以固定的頻率從音頻模信號上取出數值並轉換成用固定位數表示的數字信號,頻率和位數就是音頻採樣率和採樣位數。例如:44100HZ 16bit 表示1s採樣44100次,音頻採樣數據的振幅被分紅2^16個等級。
音頻數據可使用Web Audio Api播放, 在web audio中,將音頻數據視爲一個流,流通過一個一個的音頻節點(豐富的音頻處理器),最終到達終點 destination 輸出到揚聲器。整套流程相似於 gulp 的 pipe,對於 PCM 音頻數據的播放,咱們只須要增長一個用於 PCM 到 AudioBuffer 的轉換節點。
play(frame) {
...
// 格式化PCM
const data = format(frame);
const audioBuffer = audioCtx.createBuffer(
channels, // 聲道數
length, // 數據長度
sampleRate // 採樣率
);
...
// 數據寫入AudioBuffer
for (let channel = 0; channel < channels; channel++) {
let offset = channel;
const audioData = audioBuffer.getChannelData(channel);
for (let i = 0; i < length; i++) {
audioData[i] = result[offset];
offset += channels;
}
}
...
bufferSource.buffer = audioBuffer;
bufferSource.connect(this.gainNode);
bufferSource.start(播放時間);
...
}
複製代碼
到這裏,咱們已經從數據的流動上了解了播放的各個核心組成部分,這裏總結出播放器的完整框架:
實現直播流的解碼播放還遠遠不夠,咱們的目標是要在生產環境中使用,這樣才能蹭上h265帶來的提高。爲此,針對生產環境應用這一大訴求,咱們作了一些探索。
首幀時長是指打開頁面到出現第一幀畫面的時間,首幀時長受資源加載時間和解碼器緩存閾值設定等多個因素影響,首先咱們看一下這個過程當中資源加載發生了什麼:
除去html解析和以後的解碼渲染,播放器串行加載的資源有四個,先加載播放器資源player.js,在player.js中初始化解碼worker,加載worker資源,worker中加載編譯好的wasm文件,最後拉取視頻流。
資源加載優化是咱們很是熟悉的領域,有經常使用優化三件套:
合併資源請求(這一點暫時用不上)
減小資源大小
player.js 和 worker.js 採用經常使用的代碼壓縮,能最大限度的減小 js 資源大小。
wasm文件包含了播放器的解碼邏輯和ffmpeg庫,大小達到了 2.8M,在禁用掉其餘 ffmpeg 的 demuxers 和 decoders,只開啓 hevc 以及 aac 以後,wasm 文件大小減小到 1.4M,cdn開啓 gzip 後還有400k。
--disable-demuxers --enable-demuxer=hevc --enable-demuxer=acc --enable-demuxer=flv \
--disable-decoders --enable-decoder=hevc --enable-decoder=aac
複製代碼
串行改並行
利用資源預加載能夠在 load player.js 的時候提早加載後面的資源。解碼器 worker 能夠同 player.js 並行起來,推薦閱讀ivweb玩轉wasm系列——Web Worker串行加載優化
同時 flv 流資源也能夠預加載,在播放器準備好以後再注入給播放器的緩存池。頁面直出的場景在,flv 流的加載時間能夠提早到 html 解析階段。
目前並行加載正在試驗階段,以後團隊會有關於加載時間對比的詳細文章。
影響首幀時長的第二個因素是解碼器緩存閾值設定,設定這個閾值是要將視頻流緩存到必定的閾值再送入播放器中。ffmpeg在解封裝的時候,獲取流信息 avformat_find_stream_info 方法會讀取一部分音視頻流來作探測(probe),在晚上探測前進行解碼會出現解碼失敗的狀況,解碼時間會延後到視頻流加載到必定閾值後。
ffmpeg提供 probeSize 和 analyzeduration 來控制探測數據大小和探測數據時長,當二者同時存在時,探測知足一個條件就能夠完成。減小probeSize 或 analyzeduration能夠下降 avformat_find_stream_info 的探測時長,從而減小首幀時長。
播放的畫面和播放的聲音應該是對應起來的,不然會極大的下降用戶的觀看體驗。參考傳統播放器的音畫同步方案(推薦閱讀播放器技術分享(3):音畫同步),咱們採用視頻同步到音頻的方案。
音頻持續播放的同時,每播放完成一段音頻,就以這段音頻的 pts 爲主時鐘,在視頻幀緩存池中取出視頻幀,判斷視頻幀的 pts 在是否在播放閾值內(-50ms - 50ms),視頻幀延後於最小閾值 -50ms 就丟棄該幀,視頻幀超前於最大閾值 50ms,就等下一段音頻播放完成後同步過來。
// 音頻播放器
bufferSource.onended = () => {
onAudioUpdate(frame.timestamp);
};
function onAudioUpdate(timestamp) {
...
const video = videoPool.shift();
const diff = video.timestamp - timestamp;
// 視頻在同步區間,直接播放
if (diff <= this.min && diff > -this.min) {
renderImage(this.videoPool.shift());
}
// 視頻滯後超過閾值,棄幀
if (diff < -this.min) {
emit('discardFrame', diff);
discardFrame();
}
...
}
複製代碼
播放器使用到 wasm/web worker/web audio api/webGl/fetch等多個瀏覽器新api,在兼容性上有較大的挑戰。播放器須要提供 api 來判斷當前環境是否能使用。
/** * 當前播放器是否可以正常播放 * @returns {Boolean} true or false; */
function isSupported() {
const supportWasm = isSupportWasm(); // 是否支持wasm
const supportWorker = isSupportWorker(); // 是否支持web worker
const supportAudio = isSupportAudio(); // 是否支持web audio api
const supportWebGl = isSupportWebGL(); // 是否支持webgl
const supportAbortController = isSupportAbortController(); // 是否支持fetch abort
return supportWasm && supportWorker && supportAudio && supportWebGl && supportAbortController;
}
複製代碼
MacBook Pro (13-inch, 2017, Four Thunderbolt 3 Ports) 處理器 3.1 GHz Intel Core i5 內存 8 GB 2133 MHz LPDDR3 chrome 版本 80.0.3968.0(正式版本)dev (64 位)
視頻分辨率:540 * 960 視頻幀率:24 視頻碼率:1400Kbps 音頻碼率:64Kbps
產品 | 平均內存佔用 | 平均CPU佔用 | CPU波動 | 內存佔用波動 |
---|---|---|---|---|
Ivweb Wasm 播放器 | 276.95MB | 29.97% | 8% - 75% | 198M- 457M |
將來當 chrome 等主流播放器能夠原生支持 h265 的時候,咱們對於wasm播放器的研究是否是就沒有了意義?個人見解是 wasm 仍然能夠做爲 web 前端們對前沿技術的探索戰場,音視頻的編解碼能夠引入到前端領域,那麼其餘豐富的視頻領域的新玩意也能夠經過 wasm 引入到瀏覽器中。