1、背景
Web 端實時預(yù)覽 H.265 需求一直存在,但由于之前 Chrome 本身不支持 H.265 硬解,軟解性能消耗大,僅能支持一路播放,該需求被擱置。
去年 9 月份,Chrome 發(fā)布 M106 版本,默認開啟 H.265 硬解,使得實時預(yù)覽支持 H.265 硬解具備可行性。
然而 WebRTC 本身支持的視頻編碼格式僅包括 VP8、VP9、H.264、AV1,并不包含 H.265。根據(jù) w3c 發(fā)布的 2023 WebRTC Next Version Use Cases 來看,近期也沒有打算支持 H.265 的跡象,因而決定自研實現(xiàn) WebRTC 對 H.265 的支持。
2、DataChannel
背景說到 chrome 支持了 h265 的硬解,但 WebRTC 并不支持直接傳輸 h265 視頻流。但可以通過 datachannel 來繞過這個限制。
WebRTC 的數(shù)據(jù)通道 DataChannel 是專門用來傳輸除音視頻數(shù)據(jù)之外的任何數(shù)據(jù)的(但并不意味著不可以傳輸音視頻數(shù)據(jù),本質(zhì)上它就是一條 socket 通道),如短消息、實時文字聊天、文件傳輸、遠程桌面、游戲控制、P2P加速等。
1)SCTP協(xié)議
DataChannel 使用的協(xié)議是 SCTP(Stream Control Transport Protocol) (是一種與TCP、UDP同級的傳輸協(xié)議),可以直接在 IP 協(xié)議之上運行。
但在 WebRTC 的情況下,SCTP 通過安全的 DTLS 隧道進行隧道傳輸,該隧道本身在 UDP 之上運行,同時支持流控、擁塞控制、按消息傳輸、傳輸模式可配置等特性。需注意單次發(fā)送消息大小不能超過 maxMessageSize(只讀, 默認65535字節(jié))。
2)可配置傳輸模式
DataChannel 可以配置在不同模式中,一種是使用重傳機制的可靠傳輸模式(默認模式),可以確保數(shù)據(jù)成功傳輸?shù)綄Φ榷?;另一種是不可靠傳輸模式,該模式下可以通過設(shè)置 maxRetransmits 指定最大傳輸次數(shù),或通過 maxPacketLife 設(shè)置傳輸間隔時間實現(xiàn);
這兩種配置項是互斥的,不可同時設(shè)置,當同為null 時使用可靠傳輸模式,有一個值不為 null 時開啟不可靠傳輸模式。
3)支持數(shù)據(jù)類型
數(shù)據(jù)通道支持 string 類型或 ArrayBuffer 類型,即二進制流或字符串數(shù)據(jù)。
后續(xù)兩種方案,都是基于 datachannel 來做。
3、方案一 WebCodecs
官方文檔: github.com/w3c/webcode…
思路: DataChannel 傳輸 H.265 裸流 + Webcodecs 解碼 + Canvas 渲染。即 WebRTC 的音視頻傳輸通道(PeerConnection) 不支持 H.265 編碼格式,但可采用其數(shù)據(jù)通道(DataChannel)來傳輸 H.265數(shù)據(jù),前端收到后使用 Wecodecs 解碼、Canvas 渲染。
優(yōu)點:
直接傳輸 H.265 裸碼流,無需額外封裝,實現(xiàn)簡單方便;無冗余數(shù)據(jù),傳輸效率高
Wecodecs 解碼延遲低,實時性很高
缺點:
音頻需額外單獨傳輸、解碼和播放,需處理音視頻同步問題
既有 sdk 基于 video 封裝,webcodes 方案依賴 canvas,既有 video 相關(guān)操作,需要全部重寫,比如截圖,錄像等操作
由于線上各項目等歷史原因,既有 sdk 改動大,時間上不允許
4、方案二 MSE
官方例子: github.com/bitmovin/ms…
思路:Fmp4封裝 + DataChannel 傳輸 + MSE 解碼播放。即先將 H.265 視頻數(shù)據(jù)封裝成 Fmp4 格式,再通過 WebRTC DataChannel 通道進行傳輸,前端收到后采用 MSE 解碼, video 進行播放。
優(yōu)點:
復(fù)用 video 標簽播放,無需單獨實現(xiàn)渲染
音視頻已封裝到 Fmp4 中,web 端無需考慮音視頻同步問題
整體工作量相比 Wecodecs 小,可快速上線
缺點:
設(shè)備端實現(xiàn) Fmp4 封裝可能存在性能問題,因此需要云端轉(zhuǎn)發(fā)實時進行解封裝,或者前端解封裝
MSE 解碼實時性不好(云端首次切片會有 1~2 秒延遲)
5、方案抉擇
第一版本先以 MSE 上線。云端,前端開發(fā)量相對少,roi 高。
計劃第二版上 wecodecs,不僅低延遲,而且可以避免云端耗流量的問題,節(jié)省成本。假設(shè)在第二版期間,WebRTC 官方支持了 H.265,那么直接兼容官方方案即可。
5.1 細說 Mse 及第一版 sdk 改造
Media Source Extensions, 媒體源擴展。官方文檔: developer.mozilla.org/zh-CN/docs/…
引入 MSE 之后,支持 HTML5 的 Web 瀏覽器就變成了能夠解析流協(xié)議的播放器了。
從另一個角度來說,通過引入 MSE,HTML5 標簽不僅可以直接播放其默認支持的 mp4、m3u8、webm、ogg 等格式,還可以支持能夠被 (具備MSE功能的)JS 處理的視頻流格式。如此一來,我們就可以通過 (具備MSE功能的)JS,把一些原本不支持的視頻流格式,轉(zhuǎn)化為其支持的格式(如 H.264 的 mp4,H.265 的 fmp4)。
比如 B站開源的 flv.js 就是一個典型應(yīng)用場景。B站的 HTML5 播放器,通過使用 MSE 技術(shù),將 FLV源用 JS(flv.js) 實時轉(zhuǎn)碼成 HTML5 支持的視頻流編碼格式,提供給 HTML5 播放器播放。
?
// 此 demo 來自下面鏈接的官方示例, 可以直接跑起來,比較直觀
// 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) {
// 二進制流轉(zhuǎn)換為 Uint8Array,sourceBuffer 進行消費
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 二進制流
callback(xhr.response);
};
?
xhr.send();
}
})();
</script>
</body>
</html>
?文章來源地址http://www.zghlxwxcb.cn/news/detail-786090.html
通過上面的 demo,以及測試(將 dmeo 中的 fmp4 片段換成我們自己的 IPC 設(shè)備(攝像頭),H.265 類型的)得知,chrome 可以硬解 H.265 類型的 fmp4 片段。So,事情變得明朗了起來。大方向有了,無非就是 H.265 裸流,轉(zhuǎn)換成 fmp4 片段,chrome 底層硬解。
5.2 fmp4 前端實時解封裝
H.265 裸流解封裝 fmp4,調(diào)研下來,如果純 js 進行封裝,工作量挺大。嘗試用 wasm 調(diào) c++ 的庫,發(fā)現(xiàn)即使解封裝性能也不大好。所以放在前端被 pass 掉了。
5.3 fmp4 云端實時解封裝
性能好,對前端 0 侵入。確定了云端解封裝,接下來講講這段時間開發(fā)遇到的核心鏈路演變,及最終的流程方案。
6、階段一
云端實時解封裝 Fmp4,寫死 codecs(音視頻編碼類型) -> 前端 MSE 解碼播放 -> 播放幾秒后,失敗,MSE 會拋異常,大概意思就是你的數(shù)據(jù)不對了,前后銜接不上。
排查下來,是 MSE 處于 updating 的時候,不能進行消費,數(shù)據(jù)直接被丟掉,導(dǎo)致后續(xù)數(shù)據(jù)銜接不上。那既然不能丟,我們就緩存下來。具體可以看下面的代碼注釋。
具體可以看代碼注釋:
const updating = this.sourceBuffer?.updating === true;
const bufferQueueEmpty = this.bufferQueue.length === 0;
?
if (!updating) {
if (bufferQueueEmpty) {
// 緩存隊列為空: 僅消費本次 buffer
this.appendBuffer(curBuffer);
} else {
// 緩存隊列不為空: 消費隊列 + 本次 buffer
this.bufferQueue.push(curBuffer);
?
// 隊列中多個 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 還在消費上一次 buffer(處于 updating 中), 緩存本次 buffer, 否則會有丟幀問題
this.bufferQueue.push(curBuffer);
}
考慮到 Fmp4 數(shù)據(jù)每一幀都不可丟失,因此 datachannel 走的是可靠傳輸。
但是測試下來,發(fā)現(xiàn)了新的問題。隨著時間的增長,延遲會累積增大。因為丟包后,網(wǎng)絡(luò)層會進行重試,重試的時間會累積進延時。我們測試下來,網(wǎng)絡(luò)情況不好的時候,延遲會高達 30 秒及以上,理論上會一直增加,如果你拉流時間足夠久的話。
7、階段二
ok,換個思路,既然不丟幀 + 可靠傳輸帶來的延時問題完全不能接受,那么如果換用不可靠傳輸呢?
不可靠傳輸,意味著會丟幀。調(diào)研下來,F(xiàn)mp4 可以丟掉一整個切片(一個切片包含多幀),既然如此,我們可以設(shè)計一套丟幀算法,只要判斷到一個切片是不完整的,我們就把整個切片丟掉。
這樣的話,理論上來講,最多只會有一個切片的延遲,大概在2秒左右,業(yè)務(wù)層可以接受。
丟幀算法設(shè)計思路:在每一幀數(shù)據(jù)頭部增加 4 個字節(jié)的數(shù)據(jù),用來標識每一幀的具體信息。
segNum: 2個字節(jié),大端模式,F(xiàn)mp4片段序列號,從1開始,每次加1
fragCount: 1個字節(jié),F(xiàn)mp4片段分片總數(shù),最小為1
fragSeq: 1個字節(jié),F(xiàn)mp4片段分片序列號,從1開始
前端拿到每幀數(shù)據(jù)后,對前 4 個字節(jié)進行解析,就能獲取到每幀數(shù)據(jù)的詳細信息。舉個例子,假如我要判斷當前幀是否為最后一幀,只需要判斷 fragCount 是否等于 fragSeq 即可。
算法大致流程圖:
具體解釋一下:
-
frameQueue, 用來緩存每一幀的數(shù)據(jù),用來跟后面一幀數(shù)據(jù)進行對比,判斷是否為完整幀
-
bufferQueue, 此隊列中的數(shù)據(jù),都是完整的切片數(shù)據(jù),保證 MSE 進行消費時,數(shù)據(jù)沒有缺失
-
/** * fmp4 切片隊列 frameQueue,處理丟幀,生產(chǎn) bufferQueue 內(nèi)容 * * @param frameObj 每一幀的相關(guān)數(shù)據(jù) * 每來一幀進行判斷 * buffer中加上當前幀是否為連續(xù)幀(從第一幀開始的連續(xù)幀) * 是 * 當前幀是否為最后一幀 * 是 拼接buffer幀以及當前幀,組成完整幀,放入另外一個待消費 buffer * 否 當前幀入 buffer * 否 清空 buffer,當前幀入 buffer */ ? const frameQueueLen = this.frameQueue.length; const frameQueueEmpty = frameQueueLen === 0; ? // 單一完整分片幀單獨處理,直接進行消費 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; // 當前幀加上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,則代表直接丟棄整個 segment 切片 this.emit(EVENTS_ERROR.frameDropError); this.frameQueue = []; this.frameQueue.push(frameObj); }
原本以為大功告成,結(jié)果意想不到的事情發(fā)生了。
當出現(xiàn)丟幀時,通過上面的算法,確實是把整個切片的數(shù)據(jù)丟棄掉了,但是 MSE 此時居然再次異常了,意思也是說數(shù)據(jù)序列不對,導(dǎo)致解析失敗。
可是用 ffplay 在本地測試(丟掉一整個切片后,是可以繼續(xù)播放的),陷入僵局,繼續(xù)排查。
8、階段三
話說最近 chatgpt 不是挺火,嘗試著用了下,確實找到了原因。MSE 在消費 fmp4 數(shù)據(jù)時,需要根據(jù)內(nèi)部序列號進行索引標識,因此即使是丟掉整個切片數(shù)據(jù),還是會播放失敗。怎么辦?難道要回到不可靠傳輸?經(jīng)過一番權(quán)衡,最終決定,當出現(xiàn)丟幀時,前端通知云端,重新進行切片,并且此時前端重新初始化 MSE。
改造下來發(fā)現(xiàn),效果還不錯,我們把不可靠傳輸,datachannel 重傳次數(shù)設(shè)置為 5。
出現(xiàn)丟幀的概率大大減小,就算出現(xiàn)丟幀,也只會有不到 2 秒的 loading,然后繼續(xù)出畫面,業(yè)務(wù)層可以接受。
最終,經(jīng)過上面 3 個階段的改造,就有了整個鏈路圖。當然其實還有很多細節(jié),沒有講到,比如利用 mp4box 獲取 codec, 前端定時檢查 datachannel 狀態(tài)等,就不展開細說了。有興趣的可以留言討論。
完整的鏈路圖,簡單畫了下。
9、liveweb
目前l(fā)iveweb的方案已經(jīng)上線,測試下來,線上同時硬解 16 路沒有性能問題,超低延時(150—200毫秒左右),秒啟動?h264???h265???web播放器,h5播放器,支持協(xié)議:RTSP、RTMP、HLS、HTTP-FLV、WebSocket-FLV、GB28181、HTTP-TS、WebSocket-TS、HTTP-fMP4、WebSocket-fMP4、MP4、WebRTC
Liveweb是好游科技自主開發(fā)的網(wǎng)頁播放器,支持 RTSP、RTMP、HTTP、HLS、UDP、RTP、File 等多種流媒體協(xié)議播放,同時也有多種顯示方式 (GDI,D3D) 及格式 (RGB24,YV12,YUY2,RGB565),經(jīng)過 7x24 小時連續(xù)拷機測試,能夠很好的處理斷連.
iveweb是可支持H.264/H.265視頻播放的流媒體播放器,性能穩(wěn)定、播放流暢,可支持的視頻流格式有RTSP、RTMP、HLS、FLV、WebRTC等,具備較高的可用性。liveweb還擁有Windows、Android、iOS版本,其靈活的視頻能力,極大滿足了用戶的多樣化場景需求。
liveweb具備較強的靈活性,在視頻直播過程中l(wèi)iveweb可通過H5進行視頻解碼,只要客戶端支持H5,就能完美進行視頻的無插件直播,同時還支持大碼率視頻直播,并可支持H.264、H.265兩種編碼格式。如果大家正在找尋一款供能強大的流媒體播放器,那么liveweb將會是一個不錯的選擇,我們也歡迎大家的了解和試用
?文章來源:http://www.zghlxwxcb.cn/news/detail-786090.html
?
?
?
到了這里,關(guān)于Web 端支持 h265 硬解 web播放H.265流媒體 網(wǎng)頁播放H.265的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!