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


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

    去年最后一篇文章介绍了我们的 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 视频直播(二)》 给了我不少启发,得知了 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 作为推送示例:

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




    本文完

    扩展阅读

  • 相关阅读:
    CF1280G Kirchhoff's Current Loss【表达式解析,不等式】
    [AGC040C] Neither AB nor BA
    [AGC040B]Two Contests
    [ARC101E]Ribbons on Tree(容斥,dp)
    [GXOI/GZOI2019]旧词
    [SDOI2015]寻宝游戏
    半平面交初步
    [CF585E]Marbles
    [P5348]密码解锁
    NOIP2018 保卫王国
  • 原文地址:https://www.cnblogs.com/zzsdream/p/13410224.html
Copyright © 2020-2023  润新知