1、背景

Web 端实时预览 H.265 需求一直存在,但由于之前 Chrome 本身不支持 H.265 硬解,软解性能消耗大,仅能支持一路播放,该需求被搁置。

去年 9 月份,Chrome 发布 M106 版本,默认开启 H.265 硬解,使得实时预览支持 H.265 硬解具备可行性。

然而 WebRTC 本身支持的视频编码格式仅包括 VP8、VP9、H.264、AV1,并不包含 H.265。根据 w3c 发布的 2023 WebRTC Next Version Use Cases 来看,近期也没有打算支持 H.265 的迹象,因而决定自研实现 WebRTC 对 H.265 的支持。

2、DataChannel

背景说到 chrome 支持了 h265 的硬解,但 WebRTC 并不支持直接传输 h265 视频流。但可以通过 datachannel 来绕过这个限制

WebRTC 的数据通道 DataChannel 是专门用来传输除音视频数据之外的任何数据的(但并不意味着不可以传输音视频数据,本质上它就是一条 socket 通道),如短消息、实时文字聊天、文件传输、远程桌面、游戏控制、P2P加速等。

1)SCTP协议

DataChannel 使用的协议是 SCTP(Stream Control Transport Protocol) (是一种与TCP、UDP同级的传输协议),可以直接在 IP 协议之上运行。

但在 WebRTC 的情况下,SCTP 通过安全的 DTLS 隧道进行隧道传输,该隧道本身在 UDP 之上运行,同时支持流控、拥塞控制、按消息传输、传输模式可配置等特性。需注意单次发送消息大小不能超过 maxMessageSize(只读, 默认65535字节)。

2)可配置传输模式

DataChannel 可以配置在不同模式中,一种是使用重传机制的可靠传输模式(默认模式),可以确保数据成功传输到对等端;另一种是不可靠传输模式,该模式下可以通过设置 maxRetransmits 指定最大传输次数,或通过 maxPacketLife 设置传输间隔时间实现;

这两种配置项是互斥的,不可同时设置,当同为null 时使用可靠传输模式,有一个值不为 null 时开启不可靠传输模式。

3)支持数据类型

数据通道支持 string 类型或 ArrayBuffer 类型,即二进制流或字符串数据。

后续两种方案,都是基于 datachannel 来做

3、方案一 WebCodecs

官方文档: github.com/w3c/webcode…

思路: DataChannel 传输 H.265 裸流 + Webcodecs 解码 + Canvas 渲染。即 WebRTC 的音视频传输通道(PeerConnection) 不支持 H.265 编码格式,但可采用其数据通道(DataChannel)来传输 H.265数据,前端收到后使用 Wecodecs 解码、Canvas 渲染。

优点:

  • 直接传输 H.265 裸码流,无需额外封装,实现简单方便;无冗余数据,传输效率高

  • Wecodecs 解码延迟低,实时性很高

缺点:

  • 音频需额外单独传输、解码和播放,需处理音视频同步问题

  • 既有 sdk 基于 video 封装,webcodes 方案依赖 canvas,既有 video 相关操作,需要全部重写,比如截图,录像等操作

  • 由于线上各项目等历史原因,既有 sdk 改动大,时间上不允许

4、方案二 MSE

官方例子: github.com/bitmovin/ms…

思路:Fmp4封装 + DataChannel 传输 + MSE 解码播放。即先将 H.265 视频数据封装成 Fmp4 格式,再通过 WebRTC DataChannel 通道进行传输,前端收到后采用 MSE 解码, video 进行播放。

优点:

  • 复用 video 标签播放,无需单独实现渲染

  • 音视频已封装到 Fmp4 中,web 端无需考虑音视频同步问题

  • 整体工作量相比 Wecodecs 小,可快速上线

缺点:

  • 设备端实现 Fmp4 封装可能存在性能问题,因此需要云端转发实时进行解封装,或者前端解封装

  • MSE 解码实时性不好(云端首次切片会有 1~2 秒延迟)

5、方案抉择

第一版本先以 MSE 上线。云端,前端开发量相对少,roi 高。

计划第二版上 wecodecs,不仅低延迟,而且可以避免云端耗流量的问题,节省成本。假设在第二版期间,WebRTC 官方支持了 H.265,那么直接兼容官方方案即可。

5.1 细说 Mse 及第一版 sdk 改造

Media Source Extensions, 媒体源扩展。官方文档: developer.mozilla.org/zh-CN/docs/…

引入 MSE 之后,支持 HTML5 的 Web 浏览器就变成了能够解析流协议的播放器了。

从另一个角度来说,通过引入 MSE,HTML5 标签不仅可以直接播放其默认支持的 mp4、m3u8、webm、ogg 等格式,还可以支持能够被 (具备MSE功能的)JS 处理的视频流格式。如此一来,我们就可以通过 (具备MSE功能的)JS,把一些原本不支持的视频流格式,转化为其支持的格式(如 H.264 的 mp4,H.265 的 fmp4)。

比如 B站开源的 flv.js 就是一个典型应用场景。B站的 HTML5 播放器,通过使用 MSE 技术,将 FLV源用 JS(flv.js) 实时转码成 HTML5 支持的视频流编码格式,提供给 HTML5 播放器播放。

// 此 demo 来自下面链接的官方示例, 可以直接跑起来,比较直观// https://github.com/bitmovin/mse-demo/blob/main/index.html​  MSE Demo 

MSE Demo

​ (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,初始化 sourceBuffersourceBuffer = 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) { // 二进制流转换为 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();} })();

通过上面的 demo,以及测试(将 dmeo 中的 fmp4 片段换成我们自己的 IPC 设备(摄像头),H.265 类型的)得知,chrome 可以硬解 H.265 类型的 fmp4 片段。So,事情变得明朗了起来。大方向有了,无非就是 H.265 裸流,转换成 fmp4 片段,chrome 底层硬解。

5.2 fmp4 前端实时解封装

H.265 裸流解封装 fmp4,调研下来,如果纯 js 进行封装,工作量挺大。尝试用 wasm 调 c++ 的库,发现即使解封装性能也不大好。所以放在前端被 pass 掉了。

【学习地址】:FFmpeg/WebRTC/RTMP/NDK/Android音视频流媒体高级开发

【文章福利】:免费领取更多音视频学习资料包、大厂面试题、技术视频和学习路线图,资料包括(C/C++,Linux,FFmpeg webRTC rtmp hls rtsp ffplay srs 等等)有需要的可以点击1079654574加群领取哦~

5.3 fmp4 云端实时解封装

性能好,对前端 0 侵入。确定了云端解封装,接下来讲讲这段时间开发遇到的核心链路演变,及最终的流程方案。

6、阶段一

云端实时解封装 Fmp4,写死 codecs(音视频编码类型) -> 前端 MSE 解码播放 -> 播放几秒后,失败,MSE 会抛异常,大概意思就是你的数据不对了,前后衔接不上。

排查下来,是 MSE 处于 updating 的时候,不能进行消费,数据直接被丢掉,导致后续数据衔接不上。那既然不能丢,我们就缓存下来。具体可以看下面的代码注释。

具体可以看代码注释:

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 数据每一帧都不可丢失,因此 datachannel 走的是可靠传输。

但是测试下来,发现了新的问题。随着时间的增长,延迟会累积增大。因为丢包后,网络层会进行重试,重试的时间会累积进延时。我们测试下来,网络情况不好的时候,延迟会高达 30 秒及以上,理论上会一直增加,如果你拉流时间足够久的话

7、阶段二

ok,换个思路,既然不丢帧 + 可靠传输带来的延时问题完全不能接受,那么如果换用不可靠传输呢?

不可靠传输,意味着会丢帧。调研下来,Fmp4 可以丢掉一整个切片(一个切片包含多帧),既然如此,我们可以设计一套丢帧算法,只要判断到一个切片是不完整的,我们就把整个切片丢掉。

这样的话,理论上来讲,最多只会有一个切片的延迟,大概在2秒左右,业务层可以接受。

丢帧算法设计思路:在每一帧数据头部增加 4 个字节的数据,用来标识每一帧的具体信息。

  • segNum: 2个字节,大端模式,Fmp4片段序列号,从1开始,每次加1

  • fragCount: 1个字节,Fmp4片段分片总数,最小为1

  • fragSeq: 1个字节,Fmp4片段分片序列号,从1开始

前端拿到每帧数据后,对前 4 个字节进行解析,就能获取到每帧数据的详细信息。举个例子,假如我要判断当前帧是否为最后一帧,只需要判断 fragCount 是否等于 fragSeq 即可。

算法大致流程图:

具体解释一下:

  • frameQueue, 用来缓存每一帧的数据,用来跟后面一帧数据进行对比,判断是否为完整帧

  • bufferQueue, 此队列中的数据,都是完整的切片数据,保证 MSE 进行消费时,数据没有缺失

 /*** fmp4 切片队列 frameQueue,处理丢帧,生产 bufferQueue 内容** @param frameObj 每一帧的相关数据*每来一帧进行判断*buffer中加上当前帧是否为连续帧(从第一帧开始的连续帧)* 是*当前帧是否为最后一帧* 是 拼接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帧是否为连续帧 let isContinuousFragSeq = true; for (let i = 0; i  {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);}

原本以为大功告成,结果意想不到的事情发生了。

当出现丢帧时,通过上面的算法,确实是把整个切片的数据丢弃掉了,但是 MSE 此时居然再次异常了,意思也是说数据序列不对,导致解析失败。

可是用 ffplay 在本地测试(丢掉一整个切片后,是可以继续播放的),陷入僵局,继续排查。

8、阶段三

话说最近 chatgpt 不是挺火,尝试着用了下,确实找到了原因。MSE 在消费 fmp4 数据时,需要根据内部序列号进行索引标识,因此即使是丢掉整个切片数据,还是会播放失败。怎么办?难道要回到不可靠传输?

经过一番权衡,最终决定,当出现丢帧时,前端通知云端,重新进行切片,并且此时前端重新初始化 MSE。

改造下来发现,效果还不错,我们把不可靠传输,datachannel 重传次数设置为 5。

出现丢帧的概率大大减小,就算出现丢帧,也只会有不到 2 秒的 loading,然后继续出画面,业务层可以接受。

最终,经过上面 3 个阶段的改造,就有了整个链路图。当然其实还有很多细节,没有讲到,比如利用 mp4box 获取 codec, 前端定时检查 datachannel 状态等,就不展开细说了。有兴趣的可以留言讨论

完整的链路图,简单画了下。

9、总结

目前 datachannel + MSE 的方案已经上线,测试下来,线上同时硬解 16 路没有性能问题。

后续会尝试用 webcodes 来进行 H.265 的解析,并处理音视频同步等问题。彻底解决掉延时的问题。

下一篇准备写日常排查 WebRTC 问题的一些思路,也欢迎评论区聊一下日常遇到的一些问题,下篇一起汇总。

原文链接:《WebRTC系列》实战 Web 端支持 h265 硬解 – 掘金