前端 视频录制剖析

前端 视频录制剖析

作者:@ 很菜的小白在分享
时间:2021年12月7日

音视频三部曲

前端 音频录制剖析
前端 视频录制剖析
前端 桌面共享剖析

介绍

身为一个优秀的前端 coder 我们可能会遇到各种各样的需求,昨天我接到了一个新的需求,需要在项目中添加一个视频录制功能【疑问】【疑问】【疑问】,为什么要实现这种东西呢? 身为打工人只能默默接收。
拿到需求的我一顿操作来到了MDN官网,潦草看了一下文档看起来很简单嘛,于是撸起袖子准备开始今天的 codeing。

       1. 目录

           1.1 授权摄像头

           1.2 处理设备返回的流

           1.3 录制视频

           1.4 生成视频文件

           1.5 其他

           1.5 完整代码

流程

1. 授权摄像头

HTML5 提供了Navigation.getUserMedia()【部分浏览器已废弃】MediaDevices.getUserMedia()【新】API,这里我们只讲解新API。
MediaDevices.getUserMedia() 会提示用户给予使用媒体输入的许可,媒体输入会产生一个 MediaStream(媒体流),里面包含了请求的媒体类型的轨道。此流可以包含一个视频轨道(来自硬件或者虚拟视频源,比如相机、视频采集设备和屏幕共享服务等等)、一个音频轨道(同样来自硬件或虚拟音频源,比如麦克风、A/D转换器等等),也可能是其它轨道类型。 —— MDN

注意

授权摄像头的 API 只能在 localhost 或 https 才可以拿到。
授权
它返回一个 Promise 对象,MediaStream 就是从 resolve 中返回的,若用户拒绝授权或设备不可用则会触发 reject 返回错误信息。

语法
window.navigator.mediaDevices.getUserMedia().then(stream => {
	// resolve
}).catch(error => {
	// reject
})
参数

options | Object

名称类型说明例子
audioboolean / Object授权音频Boolean: true / false | Object: {}
videoboolean / Object授权视频Boolean: true / false | Object: {width: 1280,height:720}
············

参考
兼容性

2. 处理设备返回的流

经过权限获取后我们可以在结果中拿到 stream,这时我们要用一个容器来承载这些流数据,HTML5 还提供了另一个组件 <video> ,video 可以说是一个很强大的存在,目前我们在网页中看到的视频播放组件都是由 video 搭载的,同样它也可以搭载我们视频设备返回的流。
下面我们来看看 video 是如何来搭载我们的视频流的。

语法
<!-- HTML -->

<video id="video-record"></video>
/** JavaScript **/

let video = document.querySelector('#video-record')
function getUserMediaPermissions() {
	// 授权视频设备获取流数据
	window.navigator.mediaDevices.getUserMedia({video: true}).then(stream => {
		// 将摄像头返回的流添加的 video 组件的 src 上,srcObject 是一个新属性
		if ('srcObject' in this.video) {
         	video.srcObject = stream
        } else {
          	video.src = window.URL.createObjectURL(stream)
       	}	
	})
}

到这里就实现了将摄像头捕捉到的流通过 video 呈现到我们的网页了。是不是很开心。

我的仙人球!!咳咳~~ 因为摄像头是外接的像素不是特别清楚【呲牙】
效果图

3. 录制视频

重点来了,录制的核心部分。

原理
  1. 实时获取当前视频流轮询绘制到canvas上
  2. 将当前绘制的流(准确说是一个blob数据)添加到一个列表中
  3. 录制结束后将生成的 blobs 进行合并处理成一个整体,这时视频就诞生了。
创建画布视频捕获器

captureStream API 将会返回一个实时视频捕获的画布
语法

let mediaStream = canvas.captureStream(frameRate)
/*
frameRate: 设置双精准度浮点值为每个帧的捕获速率。如果未设置,则每次画布更改时都会捕获一个新帧。如果设置为0,则会捕获单个帧。
*/
轮询绘制 canvas
// JavaScript
let canvasOrigin = document.querySelector('#canvas-originally');
let canvasOriginContext = canvasOrigin.getContext('2d')
let video = document.querySelector('#video-record')

getUserMediaPermissions() {
	// 授权视频设备获取流数据
	window.navigator.mediaDevices.getUserMedia({video: true}).then(stream => {
		// 将摄像头返回的流添加的 video 组件的 src 上,srcObject 是一个新属性
		if ('srcObject' in this.video) {
         	video.srcObject = stream
        } else {
          	video.src = window.URL.createObjectURL(stream)
       	}	
       	video.onloadedmetadata = (e) => {
           	video.play()
            canvasDrawLoop()
        }
	})
}
canvasDrawLoo() {
	canvasOriginContext.drawImage(video, 0, 0, 1280, 760);
	requestAnimationFrame(canvasDrawLoop);
}
初始化媒体录制器

主角MediaRecorder API提供了录制的接口。
参数

名称类型说明
treamstream | DOM将要记录的流,可以是getUserMedia创建的流或者来自
audio、video以及<canvas>DOM元素
optionsObject一个字典对象,包含mimeType(类型)、
audioBitsPerSecond、videoBitsPerSecond、bitsPerSecond

方法

名称参数说明
isTypeSupported()-返回一个Boolean值,来表示设置的MIME类型
是否被当前用户的设备支持。
pause()-暂停媒体录音
requestData()-请求一个从开始到当前接收到的,存储为Blob类型的录音内容。
或者是返回从上一次调用requestData()方法之后的内容)。
调用这个方法后,创建一个记录继续进行,但会出现新的Blob对象
resume()-继续录制之后被暂停的动作。
start()timeslice / Number开始录制媒体
stop()-停止侵权。再次触发dataavailable事件,
返回一个存储Blob内容的录音数据。之后不再记录

Event

名称参数说明
ondataavailable()-该事件可用于获取摄像的媒体资源
(在事件的 data属性中会提供一个可用的Blob对象。)
onstart()-处理 start事件,该事件在媒体开始录制时触发MediaRecorder.start()。
stop()-处理stop事件,该事件会在媒体录制结束时、媒体流(MediaStream)
结束时、或者调用MediaRecorder.stop() (en-US)方法后触发。
·········

参考
代码实现

<canvas id="canvas-originally" :width="cameraInfo.width" :height="cameraInfo.height"></canvas>
// JavaScript
// 用来存放视频 blob 数据
let streams = []
let canvas = document.querySelector('#canvas-originally');
let canvasStream = canvas.captureStream(25) // 该方法返回的是一个 canvas 实时视频捕获的画布

// 初始化视频录制器
let options = { mimeType: "video/webm; codecs=vp9" };
let recorder = new MediaRecorder(canvasStream, options)
recorder.start(100)
// 监听获取媒体资源
recorder.ondataavailable = (event) => {
	streams.push(event.data)
}
recorder.onstop = () => {
	// 合并 blobs 
	let blob = new Blob(streams, {
        type: 'video/mp4'
    })
    // 生成文件
    generateFile(blob)
	// do something
}
recorder.onstart = () => {/*do something*/}
recorder.onerror = (error) => {/*do something*/}

在合并 blob 后可以通过 URL.createObjectURL(blob) 来生成一个 blobUrl 可以在浏览器中预览了,到这里我们的工作已经完成一半了。

4. 生成视频文件

现实场景中我们可能并不是单纯的去录制就OK了,我们要的是将这个视频保存到服务器,这个时候我们就需要将这个视频生成文件上传到服务器,因为这时的视频其实只是一个 blob 数据流,与File还是不同的。
直接上代码。

// JavaScript
generateFile(blob) {
	let filename = new Date().getTime() + '.mp4';
	let file = new File([blob], name, {type: 'video/mp4'})
}

是不是感觉很简单,没错,就是这么两行代码。下面介绍一下 File 这个API。

通常情况下, File 对象是来自用户在一个 <input> 元素上选择文件后返回的 FileList 对象,也可以是来自由拖放操作生成的 DataTransfer 对象,或者来自 HTMLCanvasElement 上的 mozGetAsFile() API。在Gecko中,特权代码可以创建代表任何本地文件的File对象,而无需用户交互。

File 对象是特殊类型的 Blob,且可以用在任意的 Blob 类型的 context 中。比如说, FileReader, URL.createObjectURL(), createImageBitmap() (en-US), 及 XMLHttpRequest.send() 都能处理 Blob 和 File。

语法

new File(bits, name, options)

参数

名称类型说明
bitsArrayBuffer
ArrayBufferView
Blob
Array
一个包含ArrayBuffer,ArrayBufferView,Blob,
或者 DOMString 对象的 Array — 或者任何这些对象的组合。
这是 UTF-8 编码的文件内容。
nameString文件名称,或者文件路径。
optionsObject包含文件可选属性:{type, lastModified}

属性

名称说明
File.lastModified返回当前 File 对象所引用文件最后修改时间,
自 UNIX 时间起始值(1970年1月1日 00:00:00 UTC)以来的毫秒数。
File.lastModifiedDate返回当前 File 对象所引用文件最后修改时间的 Date 对象。
File.name返回当前 File 对象所引用文件的名字。
File.size返回文件的大小。
File.webkitRelativePath返回 File 相关的 path 或 URL。
File.type返回文件的类型

参考

5. 其他

细心的同学可能发现了,我们生成的视频在本地播放器中无法拖动进度条。
这是个很严重的问题吗?
是的,灰常严重。
会有哪些问题?

  1. 首先产品经理肯定不会同意
  2. 用户体验不好
  3. 如果是要做视频切片处理的话会发现切出来的图片只有一张,别问为什么,因为我出现了。我的理解是,虽然我们录制了很久但始终为一帧,因为我们的进度无法拖动,也就没有时长的概念,导致获取到的视频长度为0,此时就只将视频的第一帧切出来了。

如何解决?
我因为时间问题就没太去研究这块了,找了一个插件 后续会研究一下这块

// JavaScript
// duration 长度可以通过开始录制时间和结束的时间算出来
fixWebmDuration(blob, duration, (fixedBlob) => {
    let blob = fixedBlob
    // 将处理后的 blob 生成文件
    this.generateFile(blob)
});

( 完 )
到这里就完成了视频录制的所有流程。如果在过程中遇到什么问题,可以私信我进行交流。

后续会更新一篇关于录屏的实现,敬请期待!!

前端 桌面共享剖析
前端 音频录制剖析

附完整代码:

<!-- HTML -->
<div class="video-record" v-show="cameraStatus">
	<div class="canvas-originally-container">
		<video id="video-record" ref="videoRecord"></video>
		<div class="status" v-if="recorderStatus"></div>
		<img src="../../../public/img/close.png" alt="" class="close-icon" @click="closeCamera">
		<canvas 
			id="canvas-originally" 
			:width="cameraInfo.width" 
			:height="cameraInfo.height" 
			ref="canvasOrigin"></canvas>
		<div class="start-record" @click="startRecord">
			<div class="start-record-inner"></div>
		</div>
	</div>
</div>
// JavaScript

<script>
const fixWebmDuration = require('../../utils/duration')
export default {
  name: 'videoFragmentation',
  data() {
    return {
      videoFile: {
        fileName: '20211009204948_1605318046468.mp4',
        url: 'http://demo-face-detection.obs.cn-east-3.myhuaweicloud.com/image/20211009204948_1605318046468.mp4'
      },
      // 相机状态
      cameraStatus: false,
      cameraInfo: {
        time: 0,
        width: 1280,
        height: 760
      },
      // 录制的视频播放器
      video: null,
      // 视频流列表
      streams: [],
      // 当前流数据
      curStream: null,
      // 录制实例化对象
      recorder: null,
      // 画布
      canvasOrigin: null,
      canvasOriginContext: null,
      // canvas 视频流
      canvasStream: null,
      // 录制后上传OBS生成的结果
      recorderVideo: {
        file: null,
        type: 2
      },
      // 录制时间
      recorderTime: 10,
      // 录制进度
      recorderProgress: null,
      // 录制状态
      recorderStatus: false,
      loading: null,
      eventType: 'auto',
    }
  },
  mounted() {
    this.video = this.$refs.videoRecord
    this.canvasOrigin = this.$refs.canvasOrigin
    this.canvasOriginContext = this.canvasOrigin.getContext('2d')
    this.canvasStream = this.canvasOrigin.captureStream(25)
  },
  methods: {
    /**
     * @description: 获取设备摄像头权限
     * @param  {*}
     * @return {*}
     */
    getUserMediaPermissions() {
      if (!window.navigator.mediaDevices.getUserMedia) {
        return;
      }
      // 1. 获取用户摄像头权限
      window.navigator.mediaDevices.getUserMedia({video: { width: { ideal: 1024 },
        height: { ideal: 776 }}})
        .then(stream => {
          this.curStream = stream

          // 2. 将摄像头返回的流赋给视频组件
          if ('srcObject' in this.video) {
            this.video.srcObject = stream
          } else {
            this.video.src = window.URL.createObjectURL(stream)
          }

          // 3. 监听数据加载完成
          this.video.onloadedmetadata = (e) => {
            // 4. 开始播放,并轮询绘制
            this.video.play()
            
            this.cameraStatus = true
            this.canvasDrawLoop()
          }
        }).catch(error => {
          console.log('获取用户 Media 权限失败', error);
        })
    },
    /**
     * @description: 开始录制
     * @param  {*}
     * @return {*}
     */
    startRecord() {
      this.recorderStatus = true
      this.initMediaRecorder(() => {
      	// 关闭摄像头使用
        this.curStream.getTracks()[0].stop()
        this.curStream = null
      })
    },
    /**
     * @description: 生成mp4文件
     * @param  {*}
     * @return {*}
     * @param {*} blob 需要转 file 的 blob 数据
     */
    generateFile(blob) {
      let name = new Date().getTime()+'.mp4'
      let file = new File([blob], name, {type: 'video/mp4'})
      this.recorderVideo.file = file
    },
    /**
     * @description: 初始化视频流记录
     * @param  {*}
     * @return {*}
     */
    initMediaRecorder(callback) {
      let options = { mimeType: "video/webm; codecs=vp9" };
      // 1. 初始化视频录制
      this.recorder = new MediaRecorder(this.canvasStream, options);

      // 2. 获取媒体资源,ondataavailable 函数的回调中将返回每一帧的 blob 流文件
      this.recorder.ondataavailable = (event) => {
        this.streams.push(event.data)
      }

      this.recorder.start(100)
		
      let duration = 0
      let startTime = 0

      // 3. 监听开始录制事件
      this.recorder.onstart = () => {
        	startTime = new Date().getTime()
        	this.recorderProgress = setInterval(() => {
	        	// 我的需求是录制10秒,所有这么写的
	          	this.cameraInfo.time += 1
	          	if (this.cameraInfo.time == this.recorderTime) {
	            	this.recorder.stop()
	          	}
        	}, 1000)
      }

      // 4. 监听录制失败
      this.recorder.onerror = function (error) {
        console.log('error', error);
      }

      // 5. 监听录制结束,结束后通过 Blob将流文件整合成类型为 mp4 的视频 blob 流
      this.recorder.onstop = (event) => {
        if (this.eventType == 'close') {
          this.resetCamera()
          return
        }
        duration = new Date().getTime() - startTime
        
        let blob = new Blob(this.streams, {
          type: 'video/mp4'
        })
        
        fixWebmDuration(blob, duration, (fixedBlob) => {
          blob = fixedBlob
          this.recorderUrl = URL.createObjectURL(blob)
          console.log('recorderUrl', this.recorderUrl);
          // 6. 将 blob 转化为 File 文件
          this.generateFile(blob)
          callback()
          this.resetCamera()
        });
      }
    },
    /**
     * @description: 在 canvas 上轮询绘制当前视频
     * @param  {*}
     * @return {*}
     */
    canvasDrawLoop() {
      this.canvasOriginContext.drawImage(this.video, 0, 0, this.cameraInfo.width, this.cameraInfo.height);
      requestAnimationFrame(this.canvasDrawLoop);
    },
    /**
     * @description: 重置相机
     * @param  {*}
     * @return {*}
     */
    resetCamera() {
      this.cameraInfo.time = 0
      this.streams = []
      this.curStream && this.curStream.getTracks()[0].stop()
      this.canvasOriginContext && this.canvasOriginContext.clearRect(0, 0, this.cameraInfo.width, this.cameraInfo.height);
      this.recorderStatus = false
      this.cameraStatus = false
      this.eventType = 'auto'
      this.recorder = null
      clearInterval(this.recorderProgress)
    },
    /**
     * @description: 关闭相机
     * @param  {*}
     * @return {*}
     */
    closeCamera() {
      this.eventType = 'close'
      if (this.recorder && this.recorder.stop) {
        this.recorder.stop()
      } else {
        this.resetCamera()
      }
    }
  }
}
</script>

音视频三部曲

前端 音频录制剖析
前端 视频录制剖析
前端 桌面共享剖析

  • 12
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 30
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 30
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值