Electron 低延迟视频流播放方案探索

好久不见,接近四个月没更新博客了!

去年最后一篇文章介绍了我们的 Electron 桌面客户端的一些优化措施,这篇文章也跟我们正在开发的 Electron 客户端有一定关系。最近我们正在预研在 Electron 页面中实时播放会议视频流的方案。



视频会议界面是最后一块没有被 Web 取代的页面, 它完全用原生开发的,所以开发效率比较低,比如要做一些动画效果开发很痛苦,难以响应多变的产品需求。所以我们在想: 能不能将 Web 页面端来播放底层库 WebRTC 接收到的视频流? 或者为什么不直接通过浏览器的 WebRTC API 来进行通讯呢

先回答后者,因为我们视频会议这块的逻辑处理、音视频处理已经被抽取成独立的、跨平台的模块,统一进行维护;另外浏览器的 WebRTC API 提供的接口非常高级,就像一个黑盒一样,无法定制化、扩展,遇到问题也很难诊断和处理, 受限于浏览器。最大的原因还是变动有点大,时间上不允许。

因此目前只能选前者,即底层库给 Electron 页面推送视频流,在页面实时播放。 再此之前,笔者几乎没有接触过音视频开发,我能想到的是通过类似直播的方式,底层库作为”主播端”, Web 页面作为”观众端”。


因为视频流只是在本地进行转发,所以我们不需要考虑各种复杂的网络情况、带宽限制。唯一的要求是低延迟,低资源消耗:

  • 我们视频会议语音和视频是分离的。 只有一路混合语音,通过 SIP 传输。而会议视频则可能存在多路,使用 WebRTC 进行传输。我们不需要处理语音(由底层库直接播放), 这就要求我们的视频播放延迟不能太高, 出现语音和视频不同步。
  • 不需要考虑浏览器兼容性。Electron 浏览器版本为 Chrome 80
  • 本地转发,不需要考虑网络情况、带宽限制




最近因为工作需要才有机会接触到音视频相关的知识,我知道的只是皮毛,所以文章肯定存在不少问题,敬请斧正。下面,跟着音视频小白的我,一起探索探索有哪些方案。




目录




① 典型的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,代码很简单:

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 推流。

首先进行视频采集,下面命令列举所有支持的设备类型:

本文的所有命令都在 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 through Media 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 视频直播(二)》 给了我不少启发,得知了 JSMpegBroadwayjs 这些方案

这两个库不依赖于浏览器的 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 作为推送示例:

$ 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 资源的:

ffmpeg -i in.mp4 -f mpeg1video -vf "crop=iw-mod(iw\,2):ih-mod(ih\,2)" -bf 0 out.mpg




视频播放

<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


推送示例:

$ 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




客户端示例:

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 裸流切割成一帧一帧图像数据,下发给浏览器:


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 推送示例:

$ 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 表示 JSMpegB 表示 BroadwayY 表示 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 由浏览器来调度播放的速率,丢掉积累的帧,保持低延迟播放。




本文完

扩展阅读