前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Electron 低延迟视频流播放方案探索

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

作者头像
_sx_
发布2020-04-10 18:16:20
6.2K0
发布2020-04-10 18:16:20
举报
文章被收录于专栏:前端技术地图前端技术地图

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

Bobi.ink

2020-04-05

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

去年最后一篇文章介绍了我们的 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,代码很简单:

代码语言: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 推流。

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

本文的所有命令都在 macOS 下面执行, 其他平台用法差不多,自行搜索

代码语言:javascript
复制
$ 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 进行设备采集, 下面列举当前终端所有支持的输入设备:

代码语言:javascript
复制
$ 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 流:

代码语言:javascript
复制
$ 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 提供的工具套件之一) 播放器来测试推流和拉流是否正常:

代码语言:javascript
复制
$ 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 的视频流:

代码语言:javascript
复制
<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:

代码语言:javascript
复制
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} 拉取视频流:

代码语言:javascript
复制
/**
 * 使用 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, ?developer/article/1612795/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 通讯:

代码语言:javascript
复制
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:

代码语言:javascript
复制
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 直接拿过来用:

代码语言:javascript
复制
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 表示 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 由浏览器来调度播放的速率,丢掉积累的帧,保持低延迟播放。

本文参与?腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2020-04-05,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客?前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与?腾讯云自媒体分享计划? ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Electron 低延迟视频流播放方案探索
    • ① 典型的Web直播方案
      • RTMP 推流
      • RTMP 拉流
      • RTMP 低延迟优化
    • ② JSMpeg & BroadwayJS
      • Relay 服务器
      • 推送
      • 视频播放
      • 多进程优化
      • 简单说一下 Broadway.js
    • ③ 直接渲染 YUV
    相关产品与服务
    云直播
    云直播(Cloud Streaming Services,CSS)为您提供极速、稳定、专业的云端直播处理服务,根据业务的不同直播场景需求,云直播提供了标准直播、快直播、云导播台三种服务,分别针对大规模实时观看、超低延时直播、便捷云端导播的场景,配合腾讯云视立方·直播 SDK,为您提供一站式的音视频直播解决方案。
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
    http://www.vxiaotou.com