概述

随着前端音视频技术的不断成熟越来越多的直播平台开始提供网页开播的直播方式,不需要再使用 OBS 或者各家的直播伴侣。

网页开播的视频流传输方式是 WebRTC,这一点没有啥难度,可以提升使用体验的点在于能否像 OBS 以及直播伴侣那样在视频流中添加文字、图片等图层以及自由的布局。

要实现这些功能需要进行混流,OBS 等都是推流之前进行的混流,直播的水印等基本都是云端实现的混流。我们网页开播选择在前端进行混流,为了了解前端混流的方案我查了很多资料。

经过几天的搜索,找到了比较靠谱的两种方式:

  • 第三方依赖库 MultiStreamsMixer
  • 将要推流的视频内容布局在 canvas 中,使用 canvas.captureStream 捕获视频流进行推送。

下面我们将依次来实现这两种混流方式。

MultiStreamsMixer

MultiStreamsMixer 有两种方式引入,使用 CDN 引入

<script src="https://www.webrtc-experiment.com/MultiStreamsMixer.js"></script>

或者 npm 安装

$ npm install MultiStreamsMixer

唯一的缺陷在于没有 TS 的类型支持,如果使用在 Typescript 的项目中需要自己声明模块类型。

这里只是 demo,我们直接使用 CDN 导入,用最原生的 HTML+JavaScript 来实现。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>混流测试</title>
</head>

<body>
  <video autoplay muted id="mixStream" width="800" height="600"></video>
  <button onclick="captureScreen()">获取屏幕</button>
  <button onclick="captureCamera()">获取摄像头</button>
  <button onclick="mixStream()">混流</button>
  <script src="https://www.webrtc-experiment.com/MultiStreamsMixer.js"></script>
  <script src="./index.js" type="text/javascript"></script>
</body>

</html>

然后创建 index.js 并实现这三个函数

let screenStream; // 屏幕捕获流
let cameraStream; // 摄像头流
const video = document.getElementById('mixStream') // 视频元素

function gotLocalMediaStream(mediaStream) {
  video.srcObject = mediaStream;
}

async function captureScreen() {
  // await deviceCheck()
  try {
    screenStream = await navigator.mediaDevices.getDisplayMedia({
      video: {
        cursor: "always",
        frameRate: 60
      },
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        sampleRate: 44100
      }
    })
  } catch (error) {
    console.log("navigator.getDisplayMedia error: ", error)
  }


}

async function captureCamera() {
  try {
    cameraStream = await navigator.mediaDevices
      .getUserMedia({
        video: {
          frameRate: 15,
          facingMode: 'user',
        },
        audio: true // 音频
      })
  } catch (error) {
    console.log("navigator.getUserMedia error: ", error)
  }
}

function mixStream() {
  screenStream.fullcanvas = true; // 铺满全屏
  screenStream.width = screen.width; // 视频宽度
  screenStream.height = screen.height; // 视频高度

  cameraStream.width = parseInt((20 / 100) * screenStream.width);
  cameraStream.height = parseInt((20 / 100) * screenStream.height);
  cameraStream.top = screenStream.height - cameraStream.height - 40; // 上边距
  cameraStream.left = screenStream.width - cameraStream.width - 40; // 下边距

  const mixer = new MultiStreamsMixer([screenStream, cameraStream]);
  mixer.frameInterval = 1; 
  mixer.startDrawingFrames(); // 开始绘制帧

  gotLocalMediaStream(mixer.getMixedStream()) // 播放视频流
}

这个库提供的 API 非常简单,就只需要设置视频的位置和宽高,然后调用混流 API 就可以了,最后记得设置帧率并开始渲染帧。

主要 API 如下:

  1. getMixedStream: (function) 返回媒体流;
  2. frameInterval: (property) 设置帧间隔;
  3. startDrawingFrames: (function) 开始生成视频流;
  4. resetVideoStreams: (function) 用新的视频流替换所有现有的视频流;
  5. releaseStreams: (function) 停止当前的混流;
  6. appendStreams: (function) 添加新的流(任何时间都可以)。

效果预览

image-20221016220742980

这种方案实现混流还是很简单的。不足之处是只能基于视频流进行混流,如果想要添加一些文字图片之类的目前我还没有找到实现方案。

canvas

核心 API MediaStream = canvas.captureStream(frameRate);

  • frameRate 可选,设置双精准度浮点值为每个帧的捕获速率。如果未设置,则每次画布更改时都会捕获一个新帧。如果设置为0,则会捕获单个帧。

基于 canvas 我们可以实现更丰富的视频流内容,例如文字标题,图片水印等(不得不说,canvas 是真滴强大)。

image-20221016224032658

实现思路:

  1. 捕获屏幕/摄像头,生成视频元素并播放;
  2. 将视频元素在 canvas 中进行绘制;
  3. 添加额外的渲染内容;
  4. 捕获 canvas 的视频流。

下面我们将给予这个思路来对之前的混流方式进行改造。

捕获摄像头和屏幕分享的过程和之前是一样的,区别在于这里我们需要生成 video 元素,因为需要为 canvas 提供渲染素材

function genVideo(stream, width = ScreenWidth, height = ScreenHeight) {
  const videoEl = document.createElement('video');
  videoEl.autoplay = true;
  videoEl.srcObject = stream;
  videoEl.width = width;
  videoEl.height = height;
  videoEl.play();
  return videoEl;
}

async function captureScreen() {
  try {
    screenStream = await navigator.mediaDevices.getDisplayMedia({
      video: {
        cursor: "always",
        frameRate: 60
      },
      audio: false
    })
    screenVideo = genVideo(screenStream);
  } catch (error) {
    console.log("navigator.getDisplayMedia error: ", error)
  }


}

async function captureCamera() {
  try {
    cameraStream = await navigator.mediaDevices
      .getUserMedia({
        video: { // 视频
          frameRate: 15,
          facingMode: 'user',
        },
        audio: false // 音频
      })
    cameraVideo = genVideo(cameraStream, cameraWidth, cameraHeight)
  } catch (error) {
    console.log("navigator.getUserMedia error: ", error)
  }
}

之所以要生成 video 元素是因为在 canvas 中绘制图片必须要指定的类型,否则会报错

image-20221017114322120

我们需要不断的在 canvas 中绘制最新的视频帧

/**
 * 
 * @param {HTMLVideoElement} screenEl 屏幕捕获视频元素
 * @param {HTMLVideoElement} cameraEl 摄像头视频元素
 */
function drawToCanvasScreen(screenEl, cameraEl) {
  canvasContext.drawImage(screenEl, 0, 0)
  canvasContext.drawImage(cameraEl, cameraLeft, cameraTop, cameraWidth, cameraHeight)

  // // 设置字体
  // canvasContext.font = "40px Microsoft YaHei";
  // canvasContext.fillStyle = "#409eff";
  // canvasContext.textAlign = "center";
  // // 添加文字和位置
  // canvasContext.fillText("custom title", 100, 50);
  
  setTimeout(drawToCanvasScreen.bind(undefined, screenEl, cameraEl), 100);
}

如果需要绘制其他元素可以在这个过程中添加,如添加文字标题等

最后我们需要从 canvas 中捕获视频流。

function mixStreamCanvas() {
  drawToCanvasScreen(screenVideo, cameraVideo)
  gotLocalMediaStream(mixCanvas.captureStream())
}

当然 canvas 的方案也不是完美的, 使用 canvas 捕获的视频流只有画面没有声音,需要再使用 Audio API 捕获音频轨道,或者在捕获视频流绘制到 canvas 之前分理出音频轨道。

效果预览

image-20221017144511919

总结

两种方案各有优缺点,需要根据个人需求去斟酌

  • MultiStreamsMixer
    • 优点
      • 使用方便;
      • 保留原声,不再需要手动额外操作声音;
    • 缺点
      • 自定义视频流之外的内容不是很方便(结合 canvas 方案,将额外的内容转为视频流进行混流);
      • 没有 TS 类型支持;
  • canvas
    • 优点
      • 自定义内容元素,包括文本和图片等;
      • 原生 API 不需要安装额外的依赖,产物体积更小;
    • 缺点
      • captureStream 无法捕获声音,需要自己手动处理;

补充:今天看了一下源码,MultiStreamMixer 也是基于 canvas 进行的封装,所以本质上前端混流只有一种方式——canvas,前者只是封装了一些常用的操作。

image-20221017152322554


前端小白