WebRTC → 前端音视频录制器的实现

涉及技术栈浅析

API浅析

  • MediaDevices:提供取得任何硬件资源的媒体数据API

    • 该接口提供访问连接媒体输入的设备,如相机、麦克风、共享屏幕等

    • 常用API方法

      • MediaDevices.enumerateDevices():

        • 会请求一个可用的媒体输入和输出设备列表,如麦克风、摄像机、耳机设备等

        • 返回一个promise,成功时会携带描述设备的MediaDeviceInfo (en-US) 的数组

          js 复制代码
          if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
            console.log("不支持 enumerateDevices() .");
            return;
          }
          
          // 列出相机和麦克风。
          navigator.mediaDevices
            .enumerateDevices()
            .then(function (devices) {
              devices.forEach(function (device) {
                console.log(
                  device.kind + ": " + device.label + " id = " + device.deviceId,
                );
              });
            })
            .catch(function (err) {
              console.log(err.name + ": " + err.message);
            });
            
            
              // sudo:
              // videoinput: id = csO9c0YpAf274OuCPUA53CNE0YHlIr2yXCi+SqfBZZ8=
              // audioinput: id = RKxXByjnabbADGQNNZqLVLdmXlS0YkETYCIbg+XxnvM=
              // audioinput: id = r2/xw1xUPIyZunfV1lGrKOma5wTOvCkWfZ368XCndm0=
      • MediaDevices.getDisplayMedia():

        • 方法提示用户去选择和授权捕获展示的内容或部分内容(如一个窗口)在一个MediaStream 里。然后,这个媒体流可以通过使用 MediaStream Recording API 被记录或者作为WebRTC 会话的一部分被传输。
        • 返回一个Promise,成功是返回一个被解析为 MediaStreamPromise,其中包含一个视频轨道。视频轨道的内容来自用户选择的屏幕区域以及一个可选的音频轨道。
      • MediaDevices.getUserMedia():

        • 会提示用户给予使用媒体输入的许可,媒体输入会产生一个MediaStream,里面包含了请求的媒体类型的轨道。
        • MediaStream流包含一个视频轨道(来自硬件或者虚拟视频源、如相机、视频采集设备和屏幕共享服务等)和一个音频轨道(同样来自硬件或虚拟音频源,如麦克风、A/D转换器等),当然也可以为其他轨道类型
        • 返回一个Promise对象,成功后会resolve回调一个MediaStream对象,如用户拒绝使用权限或所选的媒体源不可用,会reject一个失败事件;
        js 复制代码
        navigator.mediaDevices
          .getUserMedia(constraints)
          .then(function (stream) {
            /* 使用这个 stream stream */
          })
          .catch(function (err) {
            /* 处理 error */
          });
    • 事件:

      • devicechange:当媒体输入或输出设备(如相机、麦克风或扬声器)连接到用户计算机或从用户计算机移除时触发

        js 复制代码
        // 检测方式一:addEventListener()
        navigator.mediaDevices.addEventListener('devicechange', function(event) {
          updateDeviceList();
        });
        
        // 检测方式二:ondevicechange
        navigator.mediaDevices.ondevicechange = function(event) {
          updateDeviceList();
        }
    • 自身继承自EventTargetAPI,EventTarget由可以接受事件、且可以创建侦听器的对象实现,即任何事件目标都会实现与该接口有关的三个方法

      • EventTarget的三个方法
        • EventTarget.addEventListener():注册指定事件类型的事件处理程序

          js 复制代码
          addEventListener(type, listener);
          addEventListener(type, listener, options);
          addEventListener(type, listener, useCapture);
          • options:object类型包括三个布尔值选项:
            • capture: 默认值为false(即 使用事件冒泡).,true---使用事件捕获;
            • once: 默认值为false,是否只调用一次,true---会在调用后自动销毁listener
            • passive:不同浏览器默认值不同。true---listener永远不远调用preventDefault方法。根据规范,默认值为false. 但是chrome, Firefox等浏览器为了保证滚动时的性能,在Window,、Document、 Document.body上针对 touchstart 和 touchmove 事件将passive默认值改为了true, 保证了在页面滚动时不会因为自定义事件中调用了preventDefault而阻塞页面渲染。
          • useCapture:bool类型: 默认值为false(即 使用事件冒泡),与capture用法相同。
        • EventTarget.removeEventListener():删除事件侦听器

        • EventTarget.dispatchEvent():将事件分配到此EventTarget

    • 拓展API

  • MediaStream常用事件

    • addTrack()方法:向媒体流中加入新的媒体轨道
      • stream.addTrack(track); // track:MediaStreamTrack
    • clone()方法:返回当前媒体流的副本,副本具有不同且唯一的标识
    • getAudioTracks()/getVideoTracks()方法:返回媒体种类为audio/video的媒体轨道对象数组,数组成员类型为MediaStreamTrack
    js 复制代码
    //getUserMedia()方法获取视频流,如果调用成功,则将媒体流附加到<`video`>元素,之后获取第一个视频轨道并从视频轨道截取图片
    navigator.mediaDevices.getUserMedia({ video: true }).then((mediaStream) => {
      document.querySelector("video").srcObject = mediaStream;
      const track = mediaStream.getVideoTracks()[0]; 
      // 截取图片
      const imageCapture = new ImageCapture(track);
      return imageCapture;
    });
    • getTrackById()方法:返回指定ID的轨道对象
    js 复制代码
    // 获取指定ID的媒体轨道并应用约束,将音量调整到0.3
    stream.getTrackById("primary-audio-track").applyConstraints({ volume: 0.3 });
    • getTracks()方法:返回所有媒体轨道对象数组,包括所有视频及音频轨道
  • MediaRecorder:录制媒体功能

    • 该接口用来进行媒体轻松录制的接口,通过实例化MediaRecorder()构造方法进行实例化

    • 通过MediaRecorder.MediaRecorder()创建一个新的MediaRecorder对象,对指定的MediaStream对象进行录制,支持的配置项包括设置容器的MIME类型(如「video/webm」、「video/MP4」)和音频以及视频的码率或者二者同用一个码率

    • 主要API浅析

      • void prepar():准备录制
      • void start():开始录制
      • void stop():停止录制
      • void reset():重置MediaRecorder
      • void release():释放MediaRecorder占用的资源
      • void stopPreview()/release():关闭与释放camera
      • void setAudioEncoder(int):设置音频的编码格式
      • void setAudioSource(int):设置音频的音频源
      • void setVideoEncoder(int):设置视频的编码格式
      • void setVideoSource(int):设置视频的视频源
      • void setOutoutFormat(int):设置记录的媒体文件的输出转换格式
      • void setOutputFile(String):设置媒体文件输出路径
      • 事件处理
        • MediaRecorder.ondataavailable:调用它用来处理 dataavailable 事件,该事件可用于获取录制的媒体资源 (在事件的 data 属性中会提供一个可用的 Blob 对象.),在媒体数据传递时触发
      js 复制代码
      const button = document.body;
      button.addEventListener("click", async () => {
        const stream = await navigator.mediaDevices.getDisplayMedia({
          video: true,
        });
      
        const mime = MediaRecorder.isTypeSupported("video/webm;codecs=h264")
          ? "video/webm;codecs=h264"
          : "video/webm";
      
        const mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
      
        const chunks = [];
        mediaRecorder.addEventListener("dataavailable", function (e) {
          chunks.push(e.data);
        });
      
        mediaRecorder.addEventListener("stop", () => {
          const blob = new Blob(chunks, { type: chunks[0].type });
          const url = URL.createObjectURL(blob);
          const a = document.createElement("a");
          a.href = url;
          a.download = "video.webm";
          a.click();
        });
        mediaRecorder.start();
      });
      // URL.revokeObjectURL() 静态方法用来释放一个之前已经存在的、通过调用 URL.createObjectURL() 创建的 URL 对象。当你结束使用某个 URL 对象之后,应该通过调用这个方法来让浏览器知道不用在内存中继续保留对这个文件的引用了。

拓展技术浅析

视频分辨率、码率、帧率的选择

  • 当希望通过自定义视频参数,如调高码率以保证视频质量,可以通过后续的配置化进行自定义实现。但是高分辨率、码率、帧率会提高视频的清晰度,同时可能导致卡顿,并引起计费增加,需要进行慎重考虑
  • 2 人视频通话场景:
    • 分辨率 320 x 240、帧率 15 fps、码率 200 Kbps
    • 分辨率 640 x 360、帧率 15 fps、码率 400 Kbps
  • 多人视频通话场景:
    • 分辨率 160 x 120、帧率 15 fps、码率 65 Kbps
    • 分辨率 320 x 180、帧率 15 fps、码率 140 Kbps
    • 分辨率 320 x 240、帧率 15 fps、码率 200 Kbps

video画中画浅析

画中画是Chrome70+的新功能,视频窗口可以从浏览器独立出来播放视频,看起来更像是一个本地应用;
出于安全考虑,浏览器只允许在用户主动触发下调用画中画功能,操作不限于点击或敲击键盘,类似于还有Web Speech API 以及 window.open 等逻辑一致;但是也有例外的场景,如果自动调用画中画之前用户已经开启了画中画的逻辑,此时是自动调用时可以执行的(仅限制于类似画中画切换的功能,同用户同网站不同tap也可以通用该逻辑);

  • 常用API

    • 禁止video开启画中画模式
    js 复制代码
    <video 
        id="video" 
        src="https://xxxxx.mp4" 
        poster="//xxxposter.jpg" 
        controls 
        playsinline 
        loop 
        disablepictureinpicture
    >
    </video>
    // disablepictureinpicture是禁止video开启画中画的配置
    // 可以通过设置「pointer-events: none;」CSS配置到video(video父盒子)实现禁止右键功能,避免通过右键开启画中画
    //playsinline是HTML5视频标签中的一个属性,它通常用来控制视频在iOS的浏览器中的显示方式。在这个属性的值为true时,代表着视频将在Web页面中播放,而不是全屏播放。它可以让我们在Web页面中以类似Native APP的方式播放视频。同时playsinline属性还对页面的性能、用户体验等都会有影响。
    • 进入画中画:video.requestPictureInPicture();
    • 退出画中画:document.exitPictureInPicture();
    • 检测浏览器video是否支持画中画模式
    js 复制代码
    /* 特征检测 */
    if ('pictureInPictureEnabled' in document == false) {
      log('当前浏览器不支持视频画中画。');
      togglePipButton.disabled = true;
    }
    • 检测是否已经开启画中画:document.pictureInPictureElement

    • 监测画中画事件

      js 复制代码
      // 进入画中画模式时候执行
      video.addEventListener('enterpictureinpicture', function(event) {
        // 已进入画中画模式
        pipWindow = event.pictureInPictureWindow;
        log('&gt; 视频窗体尺寸为:'+ pipWindow.width +' x ' + pipWindow.height);
      });
      
      // 退出画中画模式时候执行
      video.addEventListener('leavepictureinpicture', function(event) {
        // 已退出画中画模式
        pipWindow = event.pictureInPictureWindow;
        log('&gt; 视频窗体尺寸为:'+ pipWindow.width +' x ' + pipWindow.height);
      });
    • demo1地址

    • demo2地址

如何切换屏幕共享流和摄像头视频流

  • 工作原理
    • Web端屏幕共享实际上是通过创建一个屏幕共享的流来实现的,开始屏幕共享之前,需要在创建流的时候配置某些属性,不同的浏览器在创建流的时候,相关配置属性是不一样的;
  • 实现切换屏幕共享流和摄像头视频流的方式
    • 方式一:创建两路流
      • 发送端创建两个client对象,对应两路流:屏幕共享流和本地摄像头的视频流;
        • 当需要从屏幕共享流切换到摄像头视频流时,接收端选择订阅摄像头的视频流即可
    • 方式二:关闭当前流再重新新建流发布
      • 兼容性和稳定性较好,但是缺点是无法动态的切换,切换流的时间较长,会出现短暂的黑屏状态
    • 方式三:替换当前的视频轨道
      • 通过调用对应的API方法将本地音视频流中的视频轨道替换为屏幕共享流
      • 但是替换后可能会存在配置参数不一致导致的编码相关变化,如帧率的下降,且会存在浏览器版本环境兼容问题
  • 屏幕共享的相关应用
    • 视频会议中:可以将主持人本地的文件、数据、网页、PPT等画面分享给其他参会人员
    • 在线课堂场景中,屏幕共享可以将讲师的额课件、笔记、讲课内容等画面展示给学生观看
  • 相关问题汇总
    • 屏幕共享时一般采集到的是「扬声器」的视频流,此时在录制的场景中需要将设备的音频流(即麦克风)的流数据替换到共享中,从而实现录制中采集「麦克风」的数据流

WebRTC推流与拉流浅析

WebRTC是一种实时通讯协议,允许浏览器进行音视频通话和数据传输,其中最主要的就是推拉流;

推拉流示意图

  • 推流相关
    • 就是将直播的内容推送到服务器的过程,即将采集阶段封包好的内容(现场的视频信号)传输到服务器(网络)的过程
    • 推流对网络要求很高,如果网络不稳定,直播效果会很差
    • 要想使用推流还需要将音视频数据使用传输协议进行封装,变成流数据,最后通过一定的Qos算法将音视频流数据推送到网络端,通过CDN进行分发;
    • 常用的流传输协议有RTSP、RTMP(时延一般有1-3秒)、HSL等
  • 拉流相关
    • 指服务器已有直播内容(流媒体视频文件)用指定的地址(不同的网络协议类型如RTMP、RTSP、HTP等被读取的过程)进行拉取的过程
    • 此过程需要有三个因素
      • 服务器:提供视频文件的地方
      • 传输协议:用什么方式传输视频
      • 读取终端:通过什么播放出来

WebRTC拉流步骤

  • 获取媒体流

    • 可以通过getUserMedia/getDisplayMedia获取媒体流;媒体流可以是摄像头、麦克风或屏幕共享
    • 获取到媒体流之后就可以进行处理和发送了
    js 复制代码
    getDevices() {
        navigator.mediaDevices.enumerateDevices()
          .then((devices) => {
            console.log(devices,'devices=========')
            this.audioDevices = devices.filter(function (device) {
              return device.kind === "audioinput";
            });
            this.videoDevices = devices.filter(function (device) {
              return device.kind === "videoinput";
            });
            this.selectedMicrophoneId = this.audioDevices[0].deviceId;
            this.selectedCameraId = this.videoDevices[0].deviceId;
            return createVideoAndAudioTrack({
              cameraId: this.selectedCameraId,
              width: 1280,
              height: 720,
              frameRate: 15,
              bitrate: 1000,
              }, {
                microphoneId: this.selectedMicrophoneId,
                soundLevel: true,
              }, `xhm_${Date.now()}`)
            })
          .then((track) => {
            this.videoTrack = track.videoTrack;
            this.audioTrack = track.audioTrack
            this.videoTrack.play("inspectVideo",  { mirror: true });
            this.createVolumeTimer(this.audioTrack);
          }).catch(err => {
            Message.closeAll()
            this.$message({
              type: "error",
              message: err,
            });
          })
      }
      // [
      //   {
      //     "deviceId": "66fd1375ae7f0a5a7351d52ee310f9d35b0443f720522466d1594d5c5300a14d",
      //     "kind": "videoinput",
      //     "label": "FaceTime高清摄像头(内建) (05ac:8514)",
      //   },
      //   {
      //     "deviceId": "default",
      //     "kind": "audioinput",
      //     "label": "默认 - MacBook Pro麦克风 (Built-in)",
      //   },
      //   {
      //     "deviceId": "2008649ca72ce8884c210040002eb242019735bfb68dadb1e435bc5376245c35",
      //     "kind": "audioinput",
      //     "label": "MacBook Pro麦克风 (Built-in)",
      //   }
      // ]
    js 复制代码
    function getCameraStream() {
      // 获取媒体流(摄像头/音频流) 
      // selectedVideo 用户可能选择不调用摄像头
      let cfg;
      if (selectedVideo) {
        cfg = {
          video: {
            deviceId: selectedVideo,
            frameRate: { ideal: 15, max: 30 },
            // width: {min: 640},
            // height: {min: 480}
          },
          audio: selectedAudio ? {deviceId: selectedAudio} : true
        };
      } else {
        cfg = {
          video: {
            frameRate: { ideal: 15, max: 30 },
            // width: {min: 640}, height: {min: 480}
          },
          audio: selectedAudio ? {deviceId: selectedAudio} : true
        };
      }
      navigator.mediaDevices.getUserMedia(cfg)
        .then(on_get_user_media)
        .catch(on_get_user_media_fail);
    }
    js 复制代码
    function getShareStream() {
      // 获取共享窗口的流
      var cfg = {
        video: { 
          frameRate: 15,
          width: 1920,
          height: 1080
        },
        audio:  false};  //  frameRate 观看的流畅度
    
      navigator.mediaDevices.getDisplayMedia(cfg)
        .then(on_get_display_sucess)
        .catch(on_get_display_fail);
    }
  • 创建RTCPeerConnection

    • RTCPeerConnection是WebRTC中最重要的对象之一。它负责处理与远程之间的音视频通信;
    • 在拉流的场景中,需要使用RTCPeerConnection来接受远程对等方发送的流
    js 复制代码
    const PeerConnection = new RTCPeerConnection({});
  • 添加远程流

    • 在接受远程流之前,需要通知RTCPeerConnection需要的哪种类型的媒体流,可以通过addTrack方法将对应需要展示的流添加到RTCPeerConnection中
    js 复制代码
    // 获取到Stream流之后进行播放对应的捕获的流数据
    PeerConnection = new RTCPeerConnection(configuration);
    
    //暂时只涉及到音频和视频的流  一般需要遍历「navigator.mediaDevices.getUserMedia」获取的Stream的getTracks()方法的所有
    let asender = PeerConnection.addTrack(stream.getAudioTracks()[0]);
    let vsender = PeerConnection.addTrack(stream.getVideoTracks()[0]);
    
    PeerConnection.asender = asender;
    PeerConnection.vsender = vsender;
    • 也可以通过video进行获取Tracks
    js 复制代码
    videoElem.srcObject.getTracks()
  • 创建SDP

    • SDP(Session Description Protocol)是WebRTC用于交换媒体协商信息的格式
    • 在拉流的场景中,我们需要创建一个SDP,将其发送给远程对等方,告诉它我们想要接收哪种类型的媒体流,后续的信令服务也有类似的逻辑
    js 复制代码
    PeerConnection.createOffer({"offerToReceiveAudio": false, "offerToReceiveVideo": false}).then(function(offer) {
        console.log("createOffer success: ");
        return PeerConnection.setLocalDescription(offer);
      }).then(function() {
          // 然后通过WebSocket的send方法进行发送Offer信息给对等端
      })
  • 发送SDP

    • 在本地创建SDP后还需要将对应的SDP信息通过WebSocket等技术发送给对等方,从而打通双方的媒体等限制
    js 复制代码
    PeerConnection.setRemoteDescription(new RTCSessionDescription(o.answer))
  • 接收对等方发生的媒体流

    • 通过监听RTCPeerConnection的onTrack事件获得远程流,并将其显示到页面上
    js 复制代码
    PeerConnection.ontrack = function (event) {
        var v = document.getElementById('localVideo');
        if (!v.srcObject) {
          v.srcObject = event.streams[0];
        }
        if (peer.display) {
          v.muted = true;
        }
        v.play();
    };
  • 设置对等方SDP

    • 接收到远端SDP后,需要设置为远程对等方的描述。让RTCPeerConnection知道对等方希望发送哪种类型的媒体流

实践问题汇总

获取屏幕共享

  • getDisplayMedia的then中使用this需要提前bind处理
  • getDisplayMedia中添加权限校验时可以在resolve中进行拦截,不能通过时则通过throw抛出错误使其进入到getDisplayMedia()catch()中,但此时需要单独调用停止采集的逻辑,否则还是会进行采集,相当于拦截失败了
js 复制代码
const messageError = [
  'NotAllowedError: Permission denied', // 屏幕共享时点击了取消共享
]
getShareStream() {
  const { modeSelectType } = this.state
  var cfg = {
    video: { 
      frameRate: 15,
      width: 1920,
      height: 1080,
      displaySurface: modeSelectType===2?'monitor':'browser' //browser / application / monitor
    },
    audio:  false};  //  frameRate 观看的流畅度
  navigator.mediaDevices.getDisplayMedia(cfg)
    .then(this.on_get_display_sucess.bind(this)) // 📢📢📢
    .catch(this.on_get_display_fail);
}
on_get_display_fail(reason) {
  if(!messageError.includes(String(reason))){
    message.error('屏幕共享失败:' + reason || '人像+屏幕模式下只能选择共享整个屏幕!')
  }
}
on_get_display_sucess(screen_stream) {
  const { modeSelectType } = this.state
  if(modeSelectType===2){
    let displaySurface = screen_stream.getVideoTracks()[0].getSettings().displaySurface;
    if (displaySurface !== 'monitor' && modeSelectType === 2) {
      throw '人像+屏幕模式下只能选择共享整个屏幕!';
    }
  } 
  var videoModel = document.querySelector('video#localVideo');
  videoModel.srcObject = screen_stream;
  videoModel.muted = true;
  videoModel&&videoModel.play()
  videoModel.addEventListener('play',function(){
    console.log("开始播放videoModel");
  });
  // 监听浏览上对话框的"停止共享"按钮点击事件
  screen_stream.getVideoTracks()[0].addEventListener('ended', () => {
    // document.dispatchEvent(displayCancelBtnClick);
    console.log("err: 屏幕共享手动结束" );
  });
  
  // console.log("display media pushStatus", pushStatus);
  // if (pushStatus === "pushing") {
  //   console.log("display media pushing")
  //   connectWithRemote(screen_stream);
  // }
}

推荐文献

WebRTC → 一对一音视频实时通话

相关推荐
EasyCVR34 分钟前
EHOME视频平台EasyCVR视频融合平台使用OBS进行RTMP推流,WebRTC播放出现抖动、卡顿如何解决?
人工智能·算法·ffmpeg·音视频·webrtc·监控视频接入
安步当歌4 小时前
【WebRTC】视频编码链路中各个类的简单分析——VideoStreamEncoder
音视频·webrtc·视频编解码·video-codec
安步当歌18 小时前
【WebRTC】视频采集模块中各个类的简单分析
音视频·webrtc·视频编解码·video-codec
wyw00001 天前
解决SRS推送webrtc流卡顿问题
webrtc·srs
关键帧Keyframe2 天前
音视频面试题集锦第 7 期
音视频开发·视频编码·客户端
关键帧Keyframe2 天前
音视频面试题集锦第 8 期
ios·音视频开发·客户端
安步当歌4 天前
【WebRTC】WebRTC的简单使用
音视频·webrtc
西部秋虫4 天前
Windows下FFmpeg集成metaRTC实现webrtc推拉流的例子
ffmpeg·webrtc
johnny2336 天前
《Web性能权威指南》-WebRTC-读书笔记
webrtc
蚝油菜花7 天前
MimicTalk:字节跳动和浙江大学联合推出 15 分钟生成 3D 说话人脸视频的生成模型
人工智能·开源·音视频开发