Electron 低延迟视频流播放方案探索
Bobi.ink
2020-04-05
好久不见,接近四个月没更新博客了!
去年最后一篇文章介绍了我们的 Electron 桌面客户端的一些优化措施,这篇文章也跟我们正在开发的 Electron 客户端有一定关系。最近我们正在预研在 Electron 页面中实时播放会议视频流的方案。
视频会议界面是最后一块没有被 Web 取代的页面, 它完全用原生开发的,所以开发效率比较低,比如要做一些动画效果开发很痛苦,难以响应多变的产品需求。所以我们在想: 能不能将 Web 页面端来播放底层库 WebRTC 接收到的视频流? 或者为什么不直接通过浏览器的 WebRTC API 来进行通讯呢?
先回答后者,因为我们视频会议这块的逻辑处理、音视频处理已经被抽取成独立的、跨平台的模块,统一进行维护;另外浏览器的 WebRTC API 提供的接口非常高级,就像一个黑盒一样,无法定制化、扩展,遇到问题也很难诊断和处理, 受限于浏览器。最大的原因还是变动有点大,时间上不允许。
因此目前只能选前者,即底层库给 Electron 页面推送视频流,在页面实时播放。 再此之前,笔者几乎没有接触过音视频开发,我能想到的是通过类似直播的方式,底层库作为”主播端”, Web 页面作为”观众端”。
因为视频流只是在本地进行转发,所以我们不需要考虑各种复杂的网络情况、带宽限制。唯一的要求是低延迟,低资源消耗:
- 我们视频会议语音和视频是分离的。 只有一路混合语音,通过 SIP 传输。而会议视频则可能存在多路,使用 WebRTC 进行传输。我们不需要处理语音(由底层库直接播放), 这就要求我们的视频播放延迟不能太高, 出现语音和视频不同步。
- 不需要考虑浏览器兼容性。Electron 浏览器版本为 Chrome 80
- 本地转发,不需要考虑网络情况、带宽限制
最近因为工作需要才有机会接触到音视频相关的知识,我知道的只是皮毛,所以文章肯定存在不少问题,敬请斧正。下面,跟着音视频小白的我,一起探索探索有哪些方案。
目录
- ① 典型的Web直播方案
- RTMP 推流
- RTMP 拉流
- RTMP 低延迟优化
- ② JSMpeg & BroadwayJS
- Relay 服务器
- 推送
- 视频播放
- 多进程优化
- 简单说一下 Broadway.js
- ③ 直接渲染 YUV
- 扩展阅读
① 典型的Web直播方案
Web 直播有很多方案(参考这篇文章:《Web 直播,你需要先知道这些》):
- RTMP (Real Time Messaging Protocol) 属于 Adobe。延时低,实时性较好。不过浏览器需要借助 Flash 才能播放; 但是我们也可以转换成 HTTP/Websocket 流喂给
flv.js
实现播放。 - RTP (Real-time Transport Protocol) WebRTC 底层就基于 RTP/RTCP。实时性非常好,适用于视频监控、视频会议、IP 电话。
- HLS (Http Live Streaming) 苹果提出的基于 HTTP 的流媒体传输协议。Safari 支持较好,高版本 Chrome 也支持,也有一些比较成熟的第三方方案。
HLS 延迟太高,不符合我们的要求,所以一开始就放弃了。搜了很多资料,很多都是介绍 RTMP 的,可见 RTMP 在国内采用有多广泛, 因此我们打算试试:
首先是搭建 RMTP 服务器,可以直接基于 Node-Media-Server,代码很简单:
代码语言:javascript复制const NodeMediaServer = require('node-media-server')
const config = {
// RMTP 服务器, 用于RTMP 推流和拉流
rtmp: {
port: 1935, // 1935 是RTMP的标准端口
chunk_size: 0,
gop_cache: false,
ping: 30,
ping_timeout: 60,
},
// HTTP / WebSocket 流,暴露给 flv.js
http: {
port: 8000,
allow_origin: '*',
},
}
var nms = new NodeMediaServer(config)
nms.run()
RTMP 推流
ffmpeg
是音视频开发的必备神器,本文将通过它来捕获摄像头,进行各种转换和处理,最后进行视频流推送。 下面看看怎么用 ffmpeg 进行 RTMP 推流。
首先进行视频采集,下面命令列举所有支持的设备类型:
代码语言:javascript复制本文的所有命令都在 macOS 下面执行, 其他平台用法差不多,自行搜索
$ ffmpeg -devices
Devices:
D. = Demuxing supported
.E = Muxing supported
--
D avfoundation AVFoundation input device
D lavfi Libavfilter virtual input device
E sdl,sdl2 SDL2 output device
macOS
下通常使用 avfoundation
进行设备采集, 下面列举当前终端所有支持的输入设备:
$ fmpeg -f avfoundation -list_devices true -i ""
[AVFoundation input device @ 0x7f8487425400] AVFoundation video devices:
[AVFoundation input device @ 0x7f8487425400] [0] FaceTime HD Camera
[AVFoundation input device @ 0x7f8487425400] [1] Capture screen 0
[AVFoundation input device @ 0x7f8487425400] AVFoundation audio devices:
[AVFoundation input device @ 0x7f8487425400] [0] Built-in Microphone
[AVFoundation input device @ 0x7f8487425400] [1] Boom2Device
我们将使用 FaceTime HD Camera
这个输入设备来采集视频,并推送 RTMP 流:
$ ffmpeg -f avfoundation -r 30 -i "FaceTime HD Camera" -c:v libx264 -preset superfast -tune zerolatency -an -f flv rtmp://localhost/live/test
稍微解释一下上面的命令:
-f avfoundation -r 30 -i "FaceTime HD Camera"
表示从FaceTime HD Camera
中以 30 fps 的帧率采集视频-c:v libx264
输出视频的编码格式是 H.264, RTMP 通常采用H.264 编码-f flv
指的视频的封包格式, RTMP 一般采用 flv 封包格式。-an
忽略音频流-preset superfast -tune zerolatency
H.264 的转码预设参数和调优参数。会影响视频质量和压缩率
封包格式(format)和编码(codec)是音视频开发中最基础的概念。 封包格式: 相当于一种储存视频信息的容器,将编码好的音频、视频、或者是字幕、脚本之类的文件根据相应的规范组合在一起,从而生成一个封装格式的文件。常见的封包格式有 avi、mpeg、flv、mov 等 编码格式: 编码主要的目的是为了压缩。从设备采集到的音视频流称为裸码流(rawvideo 格式, 即没有经过编码压缩处理的数据)。举例:一个 720p,30fps,60min 的电影,裸流大小为:12Bx1280x720x30x60x100 = 1.9T。这不管在文件系统上存储、还是在网络上传输,成本都太高了,所以我们需要编码压缩。 H264 是目前最常见的编码格式之一。
RTMP 拉流
最简单的,我们可以使用 ffplay
(ffmpeg 提供的工具套件之一) 播放器来测试推流和拉流是否正常:
$ ffplay rtmp://localhost/live/test
Flash 已经过时, 为了在 Web 页面中实现 RTMP 流播放,我们还要借助 flv.js
。 flvjs 估计大家都很熟悉(花边:如何看待哔哩哔哩的 flv.js 作者月薪不到 5000 元?),它是 B 站开源的 flv 播放器。按照官方的介绍:
flv.js works by transmuxing FLV file stream into ISO BMFF (Fragmented MP4) segments, followed by feeding mp4 segments into an HTML5
<video>
element throughMedia Source Extensions API
.
上面提到,flv(Flash Video) 是一个视频封包格式,flvjs
做的就是把 flv 转换成 Fragmented MP4(ISO BMFF) 封包格式,然后喂给Media Source Extension API, MSE, 接着我们将 MSE 挂载到 <video>
就可以直接播放了, 它的架构如下:
flvjs 支持通过 HTTP Streaming、 WebSocket 或者自定义数据源等多种形式拉取二进制视频流。下面示例通过 flvjs 来拉取 node-media-server
的视频流:
<script src="https://cdn.bootcss.com/flv.js/1.5.0/flv.min.js"></script>
<video id="video"></video>
<button id="play">play</button>
<script>
if (flvjs.isSupported()) {
const videoElement = document.getElementById('video');
const play = document.getElementById('play');
const flvPlayer = flvjs.createPlayer(
{
type: 'flv',
isLive: true,
hasAudio: false,
url: 'ws://localhost:8000/live/test.flv',
},
{
enableStashBuffer: true,
},
);
flvPlayer.attachMediaElement(videoElement);
play.onclick = () => {
flvPlayer.load();
flvPlayer.play();
};
}
</script>
完整示例代码在这里
RTMP 低延迟优化
推流端
ffmpeg
推流端可以通过一些控制参数来降低推流的延迟,主要优化方向是提高编码的效率、减少缓冲大小,当然有时候要牺牲一些代码质量和带宽。 这篇文章 ffmpeg 的转码延时测试与设置优化 总结了一些优化措施可以参考一下:
- 关闭 sync-lookahead
- 降低 rc-lookahead,但别小于 10,默认是-1
- 降低 threads(比如从 12 降到 6)
- 禁用 rc-lookahead
- 禁用 b-frames
- 缩小 GOP
- 开启 x264 的 -preset fast/faster/verfast/superfast/ultrafast 参数
- 使用-tune zerolatency 参数
node-media-server
NMS 也可以通过降低缓冲大小和关闭 GOP Cache 来优化延迟。
flvjs 端
flvjs 可以开启 enableStashBuffer
来提高实时性。 实际测试中,flvjs 可能会出现’累积延迟’现象,可以通过手动 seek来纠正。
经过一番折腾,优化到最好的延迟是 400ms,往下就束手无策了(对这块熟悉的同学可以请教一下)。而且在对接到底层库实际推送时,播放效果并不理想,出现各种卡顿、延迟。由于时间和知识有限,我们很难定位到具体的问题在哪, 所以我们暂时放弃了这个方案。
② JSMpeg & BroadwayJS
Jerry Qu 写得 《HTML5 视频直播(二)》 给了我不少启发,得知了 JSMpeg
和 Broadwayjs
这些方案
这两个库不依赖于浏览器的 video 的播放机制,使用纯 JS/WASM 实现视频解码器,然后直接通过 Canvas2d 或 WebGL 绘制出来。Broadwayjs 目前不支持语音,JSMpeg 支持语音(基于 WebAudio)。
经过简单的测试, 相比 RTMP, JSMpeg 和 BroadwayJS 延迟都非常低,基本符合我们的要求。下面简单介绍一下 JSMpeg 用法。Broadwayjs 用法差不多, 下文会简单带过。它们的基本处理过程如下:
Relay 服务器
因为 ffmpeg 无法向 Web 直接推流,因此我们还是需要创建一个中转(relay)服务器来接收视频推流,再通过 WebSocket 转发给页面播放器。
ffmpeg 支持 HTTP、TCP、UDP 等各种推流方式。HTTP 推流更方便我们处理, 因为是本地环境,这些网络协议不会有明显的性能差别。
下面创建一个 HTTP 服务器来接收推流,推送路径是 /push/:id
:
this.server = http
.createServer((req, res) => {
const url = req.url || '/'
if (!url.startsWith('/push/')) {
res.statusCode = 404
// ...
return
}
const id = url.slice(6)
// 禁止超时
res.connection.setTimeout(0)
// 转发出去
req.on('data', (c) => {
this.broadcast(id, c)
})
req.on('end', () => {
/* ... */
})
})
.listen(port)
接着通过 WebSocket
将流转发出去, 页面可以通过 ws://localhost:PORT/pull/{id}
拉取视频流:
/**
* 使用 webSocket 拉取流
*/
this.wss = new ws.Server({
server: this.server,
// 通过 /pull/{id} 拉流
verifyClient: (info, cb) => {
if (info.req.url && info.req.url.startsWith('/pull')) {
cb(true)
} else {
cb(false, undefined, '')
}
},
})
this.wss.on('connection', (client, req) => {
const url = req.url
const id = url.slice(6)
console.log(`${prefix}new player attached: ${id}`)
let buzy = false
const listener = {
id,
onMessage: (data) => {
// 推送
if (buzy) {
return
}
buzy = true
client.send(data, { binary: true }, function ack() {
buzy = false
})
},
}
this.attachListener(listener)
client.on('close', () => {
console.log(`${prefix} player dettached: ${id}`)
this.detachListener(listener)
})
})
推送
这里同样使用 ffmpeg 作为推送示例:
代码语言:javascript复制$ ffmpeg -f avfoundation -r 30 -i "FaceTime HD Camera" -f mpegts -codec:v mpeg1video -an -bf 0 -b:v 1500k -maxrate 2500k http://localhost:9999/push/test
稍微解释一下 ffmpeg 命令
-f mpegts -codec:v mpeg1video -an
指定使用 MPEG-TS 封包格式, 并使用 mpeg1 视频编码,忽略音频-bf 0
JSMpeg 解码器暂时不能正确地处理 B 帧。所以这些将 B 帧禁用。关于什么是 I/B/P 帧, 参考这篇文章-b:v 1500k -maxrate 2500k
设置推流的平均码率和最大码率。经过测试,JSMpeg 码率过高容易出现花屏和数组越界崩溃。
另外 JSMpeg 还要求,视频的宽度必须是 2 的倍数。ffmpeg 可以通过滤镜(filter)或设置视频尺寸(-s)来解决这个问题, 不过多余转换都要消耗一定 CPU 资源的:
代码语言:javascript复制ffmpeg -i in.mp4 -f mpeg1video -vf "crop=iw-mod(iw,2):ih-mod(ih,2)" -bf 0 out.mpg
视频播放
代码语言:javascript复制<canvas id="video-canvas"></canvas>
<script type="text/javascript" src="jsmpeg.js"></script>
<script type="text/javascript">
const canvas = document.getElementById('video-canvas')
const url = 'ws://localhost:9999/pull/test'
var player = new JSMpeg.Player(url, {
canvas: canvas,
audio: false,
pauseWhenHidden: false,
videoBufferSize: 8 * 1024 * 1024,
})
</script>
API 很简单,上面我们传递一个画布给 JSMpeg,禁用了 Audio, 并设置了一个较大的缓冲区大小, 来应对一些码率波动。
完整代码见这里
多进程优化
实际测试下来,JSMpeg 视频延迟在 100ms - 200ms 之间。当然这还取决于视频的质量、终端的性能等因素。
受限于终端性能以及解码器效率, 对于平均码率(笔者粗略测试大概为 2000k)较高的视频流,JSMpeg 有很大概率会出现花屏或者内存访问越界问题(memory access out of bounds)。
因此我们不得不通过压缩视频的质量、降低视频分辨率等手段来降低视频码率。然而这并不能根本解决问题,这是使用 JSMpeg 的痛点之一。详见JSMpeg 的性能说明
因为解码本身是一个 CPU 密集型的操作,且由浏览器来执行,CPU 占用还是挺高的(笔者机器单个页面单个播放器, CPU 占用率在 16%左右),而且 JSMpeg 播放器一旦异常崩溃会难以恢复。
在我们的实际应用场景中,一个页面可能会播放多路视频, 如果所有视频都在浏览器主进程中进行解码渲染,页面操作体验会很差。 所以最好是将 JSMpeg 分离到 Worker 中, 一来保证主进程可以响应用户的交互,二来 JSMpeg 崩溃不会连累主进程。
好在将 JSMpeg 放在 Worker 中执行容易: Worker 中支持独立 WebSocket 请求,另外 Canvas 通过 transferControlToOffscreen()
方法创建 OffscreenCanvas
对象并传递给 Worker,实现 canvas 离屏渲染。
先来看看 worker.js
, 和上面的代码差不多,主要是新增了 worker 通讯:
importScripts('./jsmpeg.js')
this.window = this
this.addEventListener('message', (evt) => {
const data = evt.data
switch (data.type) {
// 创建播放器
case 'create':
const { url, canvas, ...config } = data.data
this.id = url
this.player = new JSMpeg.Player(url, {
canvas,
audio: false,
pauseWhenHidden: false,
videoBufferSize: 10 * 1024 * 1024,
...config,
})
break
// 销毁播放器
case 'destroy':
try {
if (this.player) {
this.player.destroy()
}
this.postMessage({ type: 'destroyed' })
} catch (err) {
console.log(LOGGER_FREFIX '销毁失败: ', global.id, err)
this.postMessage({
type: 'fatal',
data: err,
})
}
break
}
})
// 就绪
this.postMessage({ type: 'ready', data: {} })
再来看看主进程, 通过 transferControlToOffscreen()
生成离屏渲染画布,让 JSMpeg 可以无缝迁移到 Worker:
const video = document.getElementById('video')
const wk = new Worker('./jsmpeg.worker.js')
wk.onmessage = (evt) => {
const data = evt.data
switch (data.type) {
case 'ready':
// 创建 OffscreenCanvas 对象
const oc = video.transferControlToOffscreen()
wk.postMessage(
{
type: 'create',
data: {
canvas: oc,
url: 'ws://localhost:9999/pull/test',
},
},
[oc] // 注意这里
)
break
}
}
简单说一下 Broadway.js
还有一个类似 JSMpeg 的解决方案 ———— Broadwayjs。 它是一个 H.264
解码器, 通过 Emscripten
工具从 Android 的 H.264 解码器转化而成。它支持接收 H.264 裸流,不过也有一些限制:不支持 weighted prediction for P-frames
& CABAC entropy encoding
。
推送示例:
代码语言:javascript复制$ ffmpeg -f avfoundation -r 30 -i "FaceTime HD Camera" -f rawvideo -c:v libx264 -pix_fmt yuv420p -vprofile baseline -tune zerolatency -coder 0 -bf 0 -flags -loop -wpredp 0 -an http://localhost:9999/push/test
客户端示例:
代码语言:javascript复制const video = document.getElementById('video')
const url = `ws://localhost:9999/pull/test`
const player = new Player({
canvas: video,
})
const ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.onmessage = function (evt) {
var data = evt.data
if (typeof data !== 'string') {
player.decode(new Uint8Array(data))
} else {
console.log('get command from server: ', data)
}
}
完整代码看这里
经过测试,同等质量和尺寸的视频流 JSMpeg 和 Broadway CPU 消耗差不多。但是 Broadway 视频流不受码率限制,没有花屏和崩溃现象。当然, 对于高质量视频, ffmpeg 转换和 Broadway 播放, 资源消耗都非常惊人。
其他类似的方案:
- wfs html5 player for raw h.264 streams.
③ 直接渲染 YUV
回到文章开始,其实底层库从 WebRTC 中拿到的是 YUV 的原始视频流, 也就是没有经过编码压缩的一帧一帧的图像。上文介绍的方案都有额外的解封包、解编码的过程,最终输出的也是 YUV 格式的视频帧,它们的最后一步都是将这些 YUV 格式视频帧转换成 RGB 格式,渲染到 Canvas 中。
那能不能将原始的 YUV 视频帧直接转发过来,直接在 Cavans 上渲染不就得了? 将去掉中间的解编码过程, 效果怎样?试一试。
此前已经有文章做过这方面的尝试: 《IVWEB 玩转 WASM 系列-WEBGL YUV 渲染图像实践》。我们参考它搞一个。
至于什么是 YUV
,我就不科普, 自行搜索。 YUV 帧的大小可以根据这个公式计算出来: (width * height * 3) >> 1
,
即 YUV420p
的每个像素占用 1.5 bytes。
因此我们只需要知道视频的大小, 就可以切割视频流,将视频帧分离出来了。 下面新建一个中转服务器来接收推流, 在这里将 YUV 裸流切割成一帧一帧图像数据,下发给浏览器:
代码语言:javascript复制this.server = http.createServer((req, res) => {
// ...
const parsed = new URL('http://host' url)
let id = parsed.searchParams.get('id'),
width = parsed.searchParams.get('width'),
height = parsed.searchParams.get('height')
const nwidth = parseInt(width)
const nheight = parseInt(height)
const frameSize = (nwidth * nheight * 3) >> 1
// 按照字节大小切割流
const stream = req.pipe(new Splitter(frameSize))
stream.on('data', (c) => {
this.broadcast(id, c)
})
// ...
})
Splitter
根据固定字节大小切割 Buffer。
如果渲染 YUV ? 可以参考 JSMpeg WebGL 渲染器, Broadway.js WebGL 渲染器。 具体如何渲染就不展开了, 下面直接将 Broadway.js 的 YUVCanvas.js
直接拿过来用:
const renderer = new YUVCanvas({
canvas: video,
type: 'yuv420',
width: width,
height: height,
})
// 通过 WebSocket 接收 YUV 帧. 并抽取出 YUV 分量
function onData(data) {
const ylen = width * height
const uvlen = (width / 2) * (height / 2)
renderer.render(
buff.subarray(0, ylen),
buff.subarray(ylen, ylen uvlen),
buff.subarray(ylen uvlen, ylen uvlen uvlen),
true
)
}
需要注意的是:JSMpeg 和 Broadway 的 Canvas 渲染都要求视频的宽度必须是 8 的倍数。不符合这个要求的会报错,《IVWEB 玩转 WASM 系列-WEBGL YUV 渲染图像实践》 处理了这个问题。
最后看看 ffmpeg 推送示例:
代码语言:javascript复制$ ffmpeg -f avfoundation -r 30 -i "FaceTime HD Camera" -f rawvideo -c:v rawvideo -pix_fmt yuv420p "http://localhost:9999/push?id=test&width=320&height=240"
完整代码看这里
下面看看简单资源消耗对比。 笔者设备是 15 款 Macboook pro, 视频源采集自摄像头,分辨率 320x240、像素格式 uyvy422、帧率 30。
下表 J
表示 JSMpeg
、B
表示 Broadway
、Y
表示 YUV
CPU (J/B/Y) | 内存 (J/B/Y) | 平均码率 (J/B/Y) | |
---|---|---|---|
ffmpeg | 9% / 9% / 5% | 12MB / 12MB / 9MB | 1600k / 200k / 27000k |
服务器 | 0.6% / 0.6% /1.4% | 18MB / 18MB / 42MB | N/A |
播放器 | 16% / 13% / 8% | 70MB / 200MB / 50MB | N/A |
从结果来看,直接渲染 YUV 综合占用的资源最少。因为没有经过压缩,码率也是非常高的,不过本地环境不受带宽限制,这个问题也不大。我们还可以利用requestAnimationFrame
由浏览器来调度播放的速率,丢掉积累的帧,保持低延迟播放。