前端 视频录制剖析
作者:@ 很菜的小白在分享
时间: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
名称 | 类型 | 说明 | 例子 |
---|---|---|---|
audio | boolean / Object | 授权音频 | Boolean: true / false | Object: {…} |
video | boolean / 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. 录制视频
重点来了,录制的核心部分。
原理
- 实时获取当前视频流轮询绘制到canvas上
- 将当前绘制的流(准确说是一个blob数据)添加到一个列表中
- 录制结束后将生成的 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提供了录制的接口。
参数
名称 | 类型 | 说明 |
---|---|---|
tream | stream | DOM | 将要记录的流,可以是getUserMedia创建的流或者来自 audio、video以及<canvas>DOM元素 |
options | Object | 一个字典对象,包含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)
参数
名称 | 类型 | 说明 |
---|---|---|
bits | ArrayBuffer ArrayBufferView Blob Array | 一个包含ArrayBuffer,ArrayBufferView,Blob, 或者 DOMString 对象的 Array — 或者任何这些对象的组合。 这是 UTF-8 编码的文件内容。 |
name | String | 文件名称,或者文件路径。 |
options | Object | 包含文件可选属性:{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. 其他
细心的同学可能发现了,我们生成的视频在本地播放器中无法拖动进度条。
这是个很严重的问题吗?
是的,灰常严重。
会有哪些问题?
- 首先产品经理肯定不会同意
- 用户体验不好
- 如果是要做视频切片处理的话会发现切出来的图片只有一张,别问为什么,因为我出现了。我的理解是,虽然我们录制了很久但始终为一帧,因为我们的进度无法拖动,也就没有时长的概念,导致获取到的视频长度为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.videoRecordthis.canvasOrigin = this.$refs.canvasOriginthis.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 = truethis.canvasDrawLoop()}}).catch(error => {console.log('获取用户 Media 权限失败', error);})},/*** @description: 开始录制* @param {*}* @return {*}*/startRecord() {this.recorderStatus = truethis.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 = 0let startTime = 0// 3. 监听开始录制事件this.recorder.onstart = () => {startTime = new Date().getTime()this.recorderProgress = setInterval(() => {// 我的需求是录制10秒,所有这么写的this.cameraInfo.time += 1if (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() - startTimelet blob = new Blob(this.streams, {type: 'video/mp4'})fixWebmDuration(blob, duration, (fixedBlob) => {blob = fixedBlobthis.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 = 0this.streams = []this.curStream && this.curStream.getTracks()[0].stop()this.canvasOriginContext && this.canvasOriginContext.clearRect(0, 0, this.cameraInfo.width, this.cameraInfo.height);this.recorderStatus = falsethis.cameraStatus = falsethis.eventType = 'auto'this.recorder = nullclearInterval(this.recorderProgress)},/*** @description: 关闭相机* @param {*}* @return {*}*/closeCamera() {this.eventType = 'close'if (this.recorder && this.recorder.stop) {this.recorder.stop()} else {this.resetCamera()}}}
}
</script>
音视频三部曲
前端 音频录制剖析
前端 视频录制剖析
前端 桌面共享剖析