1、背景
Web 端實(shí)時(shí)預(yù)覽 H.265 需求一直存在,但由于之前 Chrome 本身不支持 H.265 硬解,軟解性能消耗大,僅能支持一路播放,該需求被擱置。
去年 9 月份,Chrome 發(fā)布 M106 版本,默認(rèn)開(kāi)啟 H.265 硬解,使得實(shí)時(shí)預(yù)覽支持 H.265 硬解具備可行性。
然而 WebRTC 本身支持的視頻編碼格式僅包括 VP8、VP9、H.264、AV1,并不包含 H.265。根據(jù) w3c 發(fā)布的 2023 WebRTC Next Version Use Cases 來(lái)看,近期也沒(méi)有打算支持 H.265 的跡象,因而決定自研實(shí)現(xiàn) WebRTC 對(duì) H.265 的支持。
2、DataChannel
背景說(shuō)到 chrome 支持了 h265 的硬解,但 WebRTC 并不支持直接傳輸 h265 視頻流。但可以通過(guò) datachannel 來(lái)繞過(guò)這個(gè)限制。
WebRTC 的數(shù)據(jù)通道 DataChannel 是專(zhuān)門(mén)用來(lái)傳輸除音視頻數(shù)據(jù)之外的任何數(shù)據(jù)的(但并不意味著不可以傳輸音視頻數(shù)據(jù),本質(zhì)上它就是一條 socket 通道),如短消息、實(shí)時(shí)文字聊天、文件傳輸、遠(yuǎn)程桌面、游戲控制、P2P加速等。
1)SCTP協(xié)議
DataChannel 使用的協(xié)議是 SCTP(Stream Control Transport Protocol) (是一種與TCP、UDP同級(jí)的傳輸協(xié)議),可以直接在 IP 協(xié)議之上運(yùn)行。
但在 WebRTC 的情況下,SCTP 通過(guò)安全的 DTLS 隧道進(jìn)行隧道傳輸,該隧道本身在 UDP 之上運(yùn)行,同時(shí)支持流控、擁塞控制、按消息傳輸、傳輸模式可配置等特性。需注意單次發(fā)送消息大小不能超過(guò) maxMessageSize(只讀, 默認(rèn)65535字節(jié))。
2)可配置傳輸模式
DataChannel 可以配置在不同模式中,一種是使用重傳機(jī)制的可靠傳輸模式(默認(rèn)模式),可以確保數(shù)據(jù)成功傳輸?shù)綄?duì)等端;另一種是不可靠傳輸模式,該模式下可以通過(guò)設(shè)置 maxRetransmits 指定最大傳輸次數(shù),或通過(guò) maxPacketLife 設(shè)置傳輸間隔時(shí)間實(shí)現(xiàn);
這兩種配置項(xiàng)是互斥的,不可同時(shí)設(shè)置,當(dāng)同為null 時(shí)使用可靠傳輸模式,有一個(gè)值不為 null 時(shí)開(kāi)啟不可靠傳輸模式。
3)支持?jǐn)?shù)據(jù)類(lèi)型
數(shù)據(jù)通道支持 string 類(lèi)型或 ArrayBuffer 類(lèi)型,即二進(jìn)制流或字符串?dāng)?shù)據(jù)。
后續(xù)兩種方案,都是基于 datachannel 來(lái)做。
3、方案一 WebCodecs
官方文檔: github.com/w3c/webcode…
思路: DataChannel 傳輸 H.265 裸流 + Webcodecs 解碼 + Canvas 渲染。即 WebRTC 的音視頻傳輸通道(PeerConnection) 不支持 H.265 編碼格式,但可采用其數(shù)據(jù)通道(DataChannel)來(lái)傳輸 H.265數(shù)據(jù),前端收到后使用 Wecodecs 解碼、Canvas 渲染。
優(yōu)點(diǎn):
-
直接傳輸 H.265 裸碼流,無(wú)需額外封裝,實(shí)現(xiàn)簡(jiǎn)單方便;無(wú)冗余數(shù)據(jù),傳輸效率高
-
Wecodecs 解碼延遲低,實(shí)時(shí)性很高
缺點(diǎn):
-
音頻需額外單獨(dú)傳輸、解碼和播放,需處理音視頻同步問(wèn)題
-
既有 sdk 基于 video 封裝,webcodes 方案依賴(lài) canvas,既有 video 相關(guān)操作,需要全部重寫(xiě),比如截圖,錄像等操作
-
由于線上各項(xiàng)目等歷史原因,既有 sdk 改動(dòng)大,時(shí)間上不允許
4、方案二 MSE
官方例子: github.com/bitmovin/ms…
思路:Fmp4封裝 + DataChannel 傳輸 + MSE 解碼播放。即先將 H.265 視頻數(shù)據(jù)封裝成 Fmp4 格式,再通過(guò) WebRTC DataChannel 通道進(jìn)行傳輸,前端收到后采用 MSE 解碼, video 進(jìn)行播放。
優(yōu)點(diǎn):
-
復(fù)用 video 標(biāo)簽播放,無(wú)需單獨(dú)實(shí)現(xiàn)渲染
-
音視頻已封裝到 Fmp4 中,web 端無(wú)需考慮音視頻同步問(wèn)題
-
整體工作量相比 Wecodecs 小,可快速上線
缺點(diǎn):
-
設(shè)備端實(shí)現(xiàn) Fmp4 封裝可能存在性能問(wèn)題,因此需要云端轉(zhuǎn)發(fā)實(shí)時(shí)進(jìn)行解封裝,或者前端解封裝
-
MSE 解碼實(shí)時(shí)性不好(云端首次切片會(huì)有 1~2 秒延遲)
5、方案抉擇
第一版本先以 MSE 上線。云端,前端開(kāi)發(fā)量相對(duì)少,roi 高。
計(jì)劃第二版上 wecodecs,不僅低延遲,而且可以避免云端耗流量的問(wèn)題,節(jié)省成本。假設(shè)在第二版期間,WebRTC 官方支持了 H.265,那么直接兼容官方方案即可。
5.1 細(xì)說(shuō) Mse 及第一版 sdk 改造
Media Source Extensions, 媒體源擴(kuò)展。官方文檔: developer.mozilla.org/zh-CN/docs/…
引入 MSE 之后,支持 HTML5 的 Web 瀏覽器就變成了能夠解析流協(xié)議的播放器了。
從另一個(gè)角度來(lái)說(shuō),通過(guò)引入 MSE,HTML5 標(biāo)簽不僅可以直接播放其默認(rèn)支持的 mp4、m3u8、webm、ogg 等格式,還可以支持能夠被 (具備MSE功能的)JS 處理的視頻流格式。如此一來(lái),我們就可以通過(guò) (具備MSE功能的)JS,把一些原本不支持的視頻流格式,轉(zhuǎn)化為其支持的格式(如 H.264 的 mp4,H.265 的 fmp4)。
比如 B站開(kāi)源的 flv.js 就是一個(gè)典型應(yīng)用場(chǎng)景。B站的 HTML5 播放器,通過(guò)使用 MSE 技術(shù),將 FLV源用 JS(flv.js) 實(shí)時(shí)轉(zhuǎn)碼成 HTML5 支持的視頻流編碼格式,提供給 HTML5 播放器播放。
// 此 demo 來(lái)自下面鏈接的官方示例, 可以直接跑起來(lái),比較直觀
// https://github.com/bitmovin/mse-demo/blob/main/index.html
?
<!DOCTYPE html>
<html lang="en">
<head>
?<meta charset="UTF-8">
?<title>MSE Demo</title>
</head>
<body>
?<h1>MSE Demo</h1>
?<div>
? ?<video controls width="80%"></video>
?</div>
?
?<script type="text/javascript">
? (function() {
? ? ?var baseUrl = 'https://bitdash-a.akamaihd.net/content/MI201109210084_1/video/720_2400000/dash/';
? ? ?var initUrl = baseUrl + 'init.mp4';
? ? ?var templateUrl = baseUrl + 'segment_$Number$.m4s';
? ? ?var sourceBuffer;
? ? ?var index = 0;
? ? ?var numberOfChunks = 52;
? ? ?var video = document.querySelector('video');
?
? ? ?if (!window.MediaSource) {
? ? ? ?console.error('No Media Source API available');
? ? ? ?return;
? ? }
? ? ?// 初始化 mse
? ? ?var ms = new MediaSource();
? ? ?video.src = window.URL.createObjectURL(ms);
? ? ?ms.addEventListener('sourceopen', onMediaSourceOpen);
?
? ? ?function onMediaSourceOpen() {
? ? ? ?// codecs,初始化 sourceBuffer
? ? ? ?sourceBuffer = ms.addSourceBuffer('video/mp4; codecs="avc1.4d401f"');
? ? ? ?sourceBuffer.addEventListener('updateend', nextSegment);
?
? ? ? ?GET(initUrl, appendToBuffer);
? ? ? ?// 播放
? ? ? ?video.play();
? ? }
?
? ? ?function nextSegment() {
? ? ? ?var url = templateUrl.replace('$Number$', index);
? ? ? ?GET(url, appendToBuffer);
? ? ? ?index++;
? ? ? ?if (index > numberOfChunks) {
? ? ? ? ?sourceBuffer.removeEventListener('updateend', nextSegment);
? ? ? }
? ? }
?
? ? ?function appendToBuffer(videoChunk) {
? ? ? ?if (videoChunk) {
? ? ? ? ?// 二進(jìn)制流轉(zhuǎn)換為 Uint8Array,sourceBuffer 進(jìn)行消費(fèi)
? ? ? ? ?sourceBuffer.appendBuffer(new Uint8Array(videoChunk));
? ? ? }
? ? }
?
? ? ?function GET(url, callback) {
? ? ? ?var xhr = new XMLHttpRequest();
? ? ? ?xhr.open('GET', url);
? ? ? ?xhr.responseType = 'arraybuffer';
?
? ? ? ?xhr.onload = function(e) {
? ? ? ? ?if (xhr.status != 200) {
? ? ? ? ? ?console.warn('Unexpected status code ' + xhr.status + ' for ' + url);
? ? ? ? ? ?return false;
? ? ? ? }
? ? ? ? ?// 獲取 mp4 二進(jìn)制流
? ? ? ? ?callback(xhr.response);
? ? ? };
?
? ? ? ?xhr.send();
? ? }
? })();
?</script>
</body>
</html>
通過(guò)上面的 demo,以及測(cè)試(將 dmeo 中的 fmp4 片段換成我們自己的 IPC 設(shè)備(攝像頭),H.265 類(lèi)型的)得知,chrome 可以硬解 H.265 類(lèi)型的 fmp4 片段。So,事情變得明朗了起來(lái)。大方向有了,無(wú)非就是 H.265 裸流,轉(zhuǎn)換成 fmp4 片段,chrome 底層硬解。
5.2 fmp4 前端實(shí)時(shí)解封裝
H.265 裸流解封裝 fmp4,調(diào)研下來(lái),如果純 js 進(jìn)行封裝,工作量挺大。嘗試用 wasm 調(diào) c++ 的庫(kù),發(fā)現(xiàn)即使解封裝性能也不大好。所以放在前端被 pass 掉了。
【學(xué)習(xí)地址】:FFmpeg/WebRTC/RTMP/NDK/Android音視頻流媒體高級(jí)開(kāi)發(fā)
【文章福利】:免費(fèi)領(lǐng)取更多音視頻學(xué)習(xí)資料包、大廠面試題、技術(shù)視頻和學(xué)習(xí)路線圖,資料包括(C/C++,Linux,F(xiàn)Fmpeg webRTC rtmp hls rtsp ffplay srs 等等)有需要的可以點(diǎn)擊1079654574加群領(lǐng)取哦~
5.3 fmp4 云端實(shí)時(shí)解封裝
性能好,對(duì)前端 0 侵入。確定了云端解封裝,接下來(lái)講講這段時(shí)間開(kāi)發(fā)遇到的核心鏈路演變,及最終的流程方案。
6、階段一
云端實(shí)時(shí)解封裝 Fmp4,寫(xiě)死 codecs(音視頻編碼類(lèi)型) -> 前端 MSE 解碼播放 -> 播放幾秒后,失敗,MSE 會(huì)拋異常,大概意思就是你的數(shù)據(jù)不對(duì)了,前后銜接不上。
排查下來(lái),是 MSE 處于 updating 的時(shí)候,不能進(jìn)行消費(fèi),數(shù)據(jù)直接被丟掉,導(dǎo)致后續(xù)數(shù)據(jù)銜接不上。那既然不能丟,我們就緩存下來(lái)。具體可以看下面的代碼注釋。
具體可以看代碼注釋:
const updating = this.sourceBuffer?.updating === true;
const bufferQueueEmpty = this.bufferQueue.length === 0;
?
?if (!updating) {
? ?if (bufferQueueEmpty) {
? ? ?// 緩存隊(duì)列為空: 僅消費(fèi)本次 buffer
? ? ?this.appendBuffer(curBuffer);
? } else {
? ? ?// 緩存隊(duì)列不為空: 消費(fèi)隊(duì)列 + 本次 buffer
? ? ?this.bufferQueue.push(curBuffer);
?
? ? ?// 隊(duì)列中多個(gè) buffer 的合并
? ? ?const totalBufferByteLen = this.bufferQueue.reduce(
? ? ? (pre, cur) => pre + cur.byteLength,
? ? ? ?0
? ? );
? ? ?const combinedBuffer = new Uint8Array(totalBufferByteLen);
? ? ?let offset = 0;
? ? ?this.bufferQueue.forEach((array, index) => {
? ? ? ?offset += index > 0 ? this.bufferQueue[index - 1].length : 0;
? ? ? ?combinedBuffer.set(array, offset);
? ? });
?
? ? ?this.appendBuffer(combinedBuffer);
? ? ?this.bufferQueue = [];
? }
} else {
? ?// mse 還在消費(fèi)上一次 buffer(處于 updating 中), 緩存本次 buffer, 否則會(huì)有丟幀問(wèn)題
? ?this.bufferQueue.push(curBuffer);
}
考慮到 Fmp4 數(shù)據(jù)每一幀都不可丟失,因此 datachannel 走的是可靠傳輸。
但是測(cè)試下來(lái),發(fā)現(xiàn)了新的問(wèn)題。隨著時(shí)間的增長(zhǎng),延遲會(huì)累積增大。因?yàn)閬G包后,網(wǎng)絡(luò)層會(huì)進(jìn)行重試,重試的時(shí)間會(huì)累積進(jìn)延時(shí)。我們測(cè)試下來(lái),網(wǎng)絡(luò)情況不好的時(shí)候,延遲會(huì)高達(dá) 30 秒及以上,理論上會(huì)一直增加,如果你拉流時(shí)間足夠久的話。
7、階段二
ok,換個(gè)思路,既然不丟幀 + 可靠傳輸帶來(lái)的延時(shí)問(wèn)題完全不能接受,那么如果換用不可靠傳輸呢?
不可靠傳輸,意味著會(huì)丟幀。調(diào)研下來(lái),F(xiàn)mp4 可以丟掉一整個(gè)切片(一個(gè)切片包含多幀),既然如此,我們可以設(shè)計(jì)一套丟幀算法,只要判斷到一個(gè)切片是不完整的,我們就把整個(gè)切片丟掉。
這樣的話,理論上來(lái)講,最多只會(huì)有一個(gè)切片的延遲,大概在2秒左右,業(yè)務(wù)層可以接受。
丟幀算法設(shè)計(jì)思路:在每一幀數(shù)據(jù)頭部增加 4 個(gè)字節(jié)的數(shù)據(jù),用來(lái)標(biāo)識(shí)每一幀的具體信息。
-
segNum: 2個(gè)字節(jié),大端模式,F(xiàn)mp4片段序列號(hào),從1開(kāi)始,每次加1
-
fragCount: 1個(gè)字節(jié),F(xiàn)mp4片段分片總數(shù),最小為1
-
fragSeq: 1個(gè)字節(jié),F(xiàn)mp4片段分片序列號(hào),從1開(kāi)始
前端拿到每幀數(shù)據(jù)后,對(duì)前 4 個(gè)字節(jié)進(jìn)行解析,就能獲取到每幀數(shù)據(jù)的詳細(xì)信息。舉個(gè)例子,假如我要判斷當(dāng)前幀是否為最后一幀,只需要判斷 fragCount 是否等于 fragSeq 即可。
算法大致流程圖:
具體解釋一下:
-
frameQueue, 用來(lái)緩存每一幀的數(shù)據(jù),用來(lái)跟后面一幀數(shù)據(jù)進(jìn)行對(duì)比,判斷是否為完整幀
-
bufferQueue, 此隊(duì)列中的數(shù)據(jù),都是完整的切片數(shù)據(jù),保證 MSE 進(jìn)行消費(fèi)時(shí),數(shù)據(jù)沒(méi)有缺失
?/**
? * fmp4 切片隊(duì)列 frameQueue,處理丟幀,生產(chǎn) bufferQueue 內(nèi)容
? *
? * @param frameObj 每一幀的相關(guān)數(shù)據(jù)
? * ? ? 每來(lái)一幀進(jìn)行判斷
? * ? ? buffer中加上當(dāng)前幀是否為連續(xù)幀(從第一幀開(kāi)始的連續(xù)幀)
? * ? ? ? 是
? * ? ? ? ? 當(dāng)前幀是否為最后一幀
? * ? ? ? ? ? 是 拼接buffer幀以及當(dāng)前幀,組成完整幀,放入另外一個(gè)待消費(fèi) buffer
? * ? ? ? ? ? 否 當(dāng)前幀入 buffer
? * ? ? ? 否 清空 buffer,當(dāng)前幀入 buffer
? */
?
const frameQueueLen = this.frameQueue.length;
const frameQueueEmpty = frameQueueLen === 0;
?
?// 單一完整分片幀單獨(dú)處理,直接進(jìn)行消費(fèi)
?if (frameObj.fragCount === 1) {
? ?if (!frameQueueEmpty) {
? ? ?this.frameQueue = [];
? }
? ?this.bufferQueue.push(frameObj.value);
? ?return;
}
?
?if (frameQueueEmpty) {
? ?this.frameQueue.push(frameObj);
? ?return;
}
?
?// 是否為首幀
?let isFirstFragSeq = this.frameQueue[0].fragSeq === 1;
?// 當(dāng)前幀加上queue幀是否為連續(xù)幀
?let isContinuousFragSeq = true;
?for (let i = 0; i < frameQueueLen; i++) {
? ?const isLast = i === frameQueueLen - 1;
?
? ?const curFragSeq = this.frameQueue[i].fragSeq;
? ?const nextFragSeq = isLast
? ? ?? frameObj.fragSeq
? ? : this.frameQueue[i + 1].fragSeq;
?
? ?const curSegNum = this.frameQueue[i].segNum;
? ?const nextSeqNum = isLast
? ? ?? frameObj.segNum
? ? : this.frameQueue[i + 1].segNum;
?
? ?if (curFragSeq + 1 !== nextFragSeq || curSegNum !== nextSeqNum) {
? ? ?isContinuousFragSeq = false;
? ? ?break;
? }
}
?
?if (isFirstFragSeq && isContinuousFragSeq) {
? ?// 是否為最后一幀
? ?const isLastFrame = frameObj.fragCount === frameObj.fragSeq;
? ?if (isLastFrame) {
? ? ?this.frameQueue.forEach((item) => {
? ? ? ?this.bufferQueue.push(item.value);
? ? });
? ? ?this.frameQueue = [];
? ? ?this.bufferQueue.push(frameObj.value);
? } else {
? ? ?this.frameQueue.push(frameObj);
? }
} else {
? ?// 丟幀則清空 frameQueue,則代表直接丟棄整個(gè) segment 切片
? ?this.emit(EVENTS_ERROR.frameDropError);
? ?this.frameQueue = [];
? ?this.frameQueue.push(frameObj);
}
原本以為大功告成,結(jié)果意想不到的事情發(fā)生了。
當(dāng)出現(xiàn)丟幀時(shí),通過(guò)上面的算法,確實(shí)是把整個(gè)切片的數(shù)據(jù)丟棄掉了,但是 MSE 此時(shí)居然再次異常了,意思也是說(shuō)數(shù)據(jù)序列不對(duì),導(dǎo)致解析失敗。
可是用 ffplay 在本地測(cè)試(丟掉一整個(gè)切片后,是可以繼續(xù)播放的),陷入僵局,繼續(xù)排查。
8、階段三
話說(shuō)最近 chatgpt 不是挺火,嘗試著用了下,確實(shí)找到了原因。MSE 在消費(fèi) fmp4 數(shù)據(jù)時(shí),需要根據(jù)內(nèi)部序列號(hào)進(jìn)行索引標(biāo)識(shí),因此即使是丟掉整個(gè)切片數(shù)據(jù),還是會(huì)播放失敗。怎么辦?難道要回到不可靠傳輸?
經(jīng)過(guò)一番權(quán)衡,最終決定,當(dāng)出現(xiàn)丟幀時(shí),前端通知云端,重新進(jìn)行切片,并且此時(shí)前端重新初始化 MSE。
改造下來(lái)發(fā)現(xiàn),效果還不錯(cuò),我們把不可靠傳輸,datachannel 重傳次數(shù)設(shè)置為 5。
出現(xiàn)丟幀的概率大大減小,就算出現(xiàn)丟幀,也只會(huì)有不到 2 秒的 loading,然后繼續(xù)出畫(huà)面,業(yè)務(wù)層可以接受。
最終,經(jīng)過(guò)上面 3 個(gè)階段的改造,就有了整個(gè)鏈路圖。當(dāng)然其實(shí)還有很多細(xì)節(jié),沒(méi)有講到,比如利用 mp4box 獲取 codec, 前端定時(shí)檢查 datachannel 狀態(tài)等,就不展開(kāi)細(xì)說(shuō)了。有興趣的可以留言討論。
完整的鏈路圖,簡(jiǎn)單畫(huà)了下。
9、總結(jié)
目前 datachannel + MSE 的方案已經(jīng)上線,測(cè)試下來(lái),線上同時(shí)硬解 16 路沒(méi)有性能問(wèn)題。
后續(xù)會(huì)嘗試用 webcodes 來(lái)進(jìn)行 H.265 的解析,并處理音視頻同步等問(wèn)題。徹底解決掉延時(shí)的問(wèn)題。
下一篇準(zhǔn)備寫(xiě)日常排查 WebRTC 問(wèn)題的一些思路,也歡迎評(píng)論區(qū)聊一下日常遇到的一些問(wèn)題,下篇一起匯總。文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-470253.html
原文鏈接:《WebRTC系列》實(shí)戰(zhàn) Web 端支持 h265 硬解 - 掘金文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-470253.html
到了這里,關(guān)于《WebRTC系列》實(shí)戰(zhàn) Web 端支持 h265 硬解的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!