JS本地媒体流 合并、录制、下载/上传

5,913 阅读4分钟

获取本地设备的媒体流

获取摄像头、屏幕共享的媒体流

function startRecording(){
    let constraints = {
      video: {
        cursor: "never"
      }, // 视频信息的设置
      audio: false, // 是否包含音频信息
      logicalSurface: false, // 设置是否包含所选屏幕外区域的一些信息
    }
    // navigator.mediaDevices.getDisplayMedia(constraints) // 录制屏幕共享
    
    // 录制摄像头
    navigator.mediaDevices.getUserMedia({
        audio: true,
        video: true,
    }).then(stream => {
      this.mediaStream = stream;
      this.startRecord(stream)
    })
}

同时录制屏幕共享及麦克风

屏幕共享是一个mediaStream, 麦克风和摄像头是另一个mediaStream, 需要将两个媒体流进行合并,方法就是将麦克风的音频轨道添加到是屏幕共享的mediaStream


function screenAudioRecording(){
    navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }).then(stream => {
        this.screenStream = stream;
        navigator.mediaDevices.getUserMedia({ video: false, audio: true }).then(audioStream => {
            // 屏幕共享和麦克风进行混流
            audioStream.getTracks().forEach(track => {
              this.screenStream.addTrack(track);
            })
            // 此时的screenStream已包含音频轨道
            this.startRecord(this.screenStream)
        })
    })
}

合并屏幕共享和摄像头

屏幕共享和摄像头是两个带视频的mediaStream, 直接使用添加轨道的方法,一个流回完全覆盖另一个流。可以借助video/canvas实现:

补充几个知识:

  1. video可直接播放媒体流
videoElement.srcObject = mediaStream
  1. canvas可直接绘制video内容, 并且可以指定位置和宽高
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
  1. canvas可导出媒体流
const stream = canvas.captureStream();
  1. 将音频轨道添加到该媒体流内
mediaStream.getAudioTracks().forEach(track => stream.addTrack(track))

这样就有了思路~,

  1. 首先将屏幕共享的流(下面称screenStream)和摄像头的流(下面称cameraStream)播放到两个<video>
  2. 然后将screenStream完全绘制到canvas上(撑满整个canvas),然后将cameraStream也绘制到canvas的指定位置上(例如右下角, 利用drawImage(x, y, width, height)设置位置和尺寸). 这样就绘制好了一帧的影像
  3. 再用requestAnimationFrame不停的更新canvas内容, 即可实现再canvas上播放混合流
  4. 再使用canvas.captureStream()获取合并后的媒体流
  5. 最后把音频添加进去. 使用mediaStream.getAudioTracks()获取音频轨道, 插入的行的媒体流中
async function mergeStream(screenStream, cameraStream) {
  const v1 = document.createElement('video')
  v1.srcObject = screenStream
  v1.addEventListener('play', () => {
    render = () => {
      if (screenStream) {
        ctx.drawImage(v1, 0, 0, canvas.width, canvas.height)
        window.requestAnimationFrame(render)
      }
    }
    render();
  })
  v1.play()


  // 摄像头
  video2.srcObject = cameraStream
  video2.addEventListener('play', () => {
    render = () => {
      if (cameraStream) {
        ctx.drawImage(video2, 100, 100, 100, 100)
        window.requestAnimationFrame(render)
      }
    }
    render();
  })
  video2.play()

  // 新的媒体流
  const newStream = canvas.captureStream()
  cameraStream.getAudioTracks().forEach(track => newStream.addTrack(track))
  screenStream.getAudioTracks().forEach(track => newStream.addTrack(track))
  return newStream
}

借助video-stream-merger可以更方便的实现

import { VideoStreamMerger } from 'video-stream-merger'

const begin = async () => {``
  merger = new VideoStreamMerger({
    width: 680,   // Width of the output video
    height: 380,  // Height of the output video
    fps: 25,       // Video capture frames per second
    clearRect: true
  })
  // 获取屏幕共享
  try {
    screen = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })
    merger.addStream(screen, {
      x: 0,
      y: 0,
      width: merger.width,
      height: merger.height
    })
  } catch (e) {
    console.error('出错了', e);
    screen = null
  }
  // 获取摄像头
  try {
    camera = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
    merger.addStream(camera, {
      x: merger.width - 100,
      y: merger.height - 100,
      width: 100,
      height: 100
    })
  } catch (e) {
    console.error('出错了', e);
    camera = null
  }

  merger.start()
  // 在video中播放
  const videoDom = document.querySelector('video')
  videoDom.srcObject = merger.result
  videoDom.play()
}

下载录制的视频

function download(chunk) {
  let url = URL.createObjectURL(chunk);
  // 创建隐藏的可下载链接
  var eleLink = document.createElement('a');
  eleLink.download = 'fileName.webm';
  // eleLink.style.display = 'none';
  // 字符内容转变成blob地址
  eleLink.href = url
  // 触发点击
  console.log('url', url)
  document.body.appendChild(eleLink);
  eleLink.click();
  // 然后移除
  document.body.removeChild(eleLink);
}

媒体流录制

直接录制

借助MediaRecorder, 可录制webm的视频

let recorder = null
// 开始录制
const startRecord = (stream) => {
  recorder = new MediaRecorder(stream)
  recorder.ondataavailable = e => {
    download(e.data)
  }
  recorder.start()
}

// 结束录制
const stopRecord = () => {
  recorder.stop()
}

大视频录制上传

如果录制时间很长,生成的录制文件就会很大,大文件再录制结束后再上传会耗用大量时间,并且上传过程中如果网络问题造成上传失败,就会前功尽弃,重新上传。 为了解决这个问题,可以再录制的时候就进行上传,每过n秒就生成视频文件进行上传,大量降低上传时间,即时上传失败也能进行断点续传。

let recorder = null
// 开始录制
const startRecord = (stream) => {
  let index = 0
  recorder = new MediaRecorder(stream)
  recorder.ondataavailable = e => {
    // 每次上传10s视频, 录制结束后通知后台 合并视频片段
    upload(e.data, index++) // index 为当前片段的索引
  }
  recorder.start(10000) // 每隔10s生成一个视频片段(触发一次ondataavailable)
}

// 结束录制
const stopRecord = () => {
  recorder.stop()
}

合并录制的视频片段

将每次录制的视频片段按顺序合并成一个Blob即可完成视频的拼接。前端合并演示如下,后端实现方式也是blob合并

const startRecord = (stream) => {
  let blob = null
  recorder = new MediaRecorder(stream)
  recorder.ondataavailable = e => {
    // 将当前生成的chunk与之前的chunk合并成一个blob
    blob = new Blob([blob ? blob : null, e.data], {
      type: "video/x-matroska;codecs=avc1,opus"
    })
    if (!recording.value) { // 当停止录制时,下载合并的blob
      download(blob, 'blob.webm')
    }
  }
  // recorder.start(time > 0 ? time : undefined)
  recorder.start(3000) // 每3s生成一个视频片段
  recording.value = true
}

录制问题

使用以上方法录制的视频格式为wbem, 该格式视频无法快进,且ios端无法播放,需要转码. MediaRecorder()构造函数会创建一个对指定的 MediaStream 进行录制

 function recoder (stream) {
    var options = {
      audioBitsPerSecond : 128000,
      videoBitsPerSecond : 2500000,
      mimeType : 'video/mp4'
    }
    var mediaRecorder = new MediaRecorder(stream,options);
    m = mediaRecorder;
 }

MediaRecorder

仓库demo地址

mediaStream-merge-record (github.com)

由于浏览器安全问题,navigator只有在本地localhost或者https下才能获取到用户媒体信息 webm视频可在chrome内直接播放