背景
业务近期的需求涉及到了一些视频录制的工作,web端的视频录制虽然相对简单,不涉及视频编码之类的硬核知识,但是实际在使用上还是有一些小细节。本次主要介绍一下视频录制的开发实践和一些踩过的坑,有类似需求时可以参考
录制架构
录制API原理
前端录视频原理非常简单,使用浏览器提供的API即可,主要有两个
- getUserMedia,用于拉取用户的摄像头/麦克风设备,接受一个音视频参数的约束(MediaTrackConstraints,告诉浏览器想要一个什么流),获取一个音视频流(MedieaStream,可以直接拿来放在video上播放)
- MediaRecorder,用于录制一个MedieaStream流
核心代码也非常简单,主要分两步:获取音频流 -> 交给MediaRecorder录制
const stream =
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });//获取流
videoElementRef.current.src = stream // 实时预览
const mediaRecorder = new MediaRecorder(stream); //设置录制器
mediaRecorder.start(); // 开始录制
mediaRecorder.ondataavailable = e => {
// 处理视频数据
};
但需要注意的是MedieaRecorder录制的视频有个问题,不管是默认的webm编码还是mp4视频文件都不含视频时长(一个小坑),所以需要我们手动记录一下时长,然后写进视频元信息里。webm相比与mp4视频文件更好处理一些,矩阵管家使用的就是默认webm编码,使用fix-webm-duration写入时长
manager拆分
录制架构按照获取流与录制分离设计,由两个Manager来管理,架构如下
StreamManager是音视频流管理器,主要负责管理与选择用户设备,以及获取并持有音视频流。流的分发与使用采用观察者模式,维护一个回调map,使用回调传递需要用到音视频流的地方(例如Video元素所在位置,用于实时预览)。
RecorderManager是录制管理器,负责进行视频的实际录制与计时,主要持有录制器MideaRecorder和计时器(实际上就是开始和结束两个时间戳,在暂停时会有一点的计算)。用户操作时,RecorderManager保证计时器和录制器同时被操作,譬如暂停时,录制器和计时器都需要对应的暂停。
对于矩阵管家业务来说,还有有按时间弹出模拟弹幕和提示的需求。练播页面所有涉及到时间的地方都只会从RecordManager中获取,保证时间的准确一致。
在这个架构下,流管理和录制分开控制,用起来的体验还是挺不错的
下边就是一些在流获取和录制上需要注意的一些问题
流获取
用户设备切换
为什么要切换
直接用getUserMedia({ audio: true, video: true })可以满足大部分的需求,但是在用户设备环境比较复杂的情况下可能会出问题(又一个小坑)。
对于PC端,比较常见的就是使用外接摄像头。我们用相机直接捅到电脑上是没有办法被识别为摄像头的,通常需要安装一个摄像头驱动,例如佳能相机需要安装EOS Webcam Utility。但实际使用中出现的问题是,即使相机没有被接入,这个驱动也是可以被正常授权选中的(不会报错),只不过会显示一个兜底图片,例如索尼会展示一个黑屏,佳能则会展示如下
并且用户在授权时选择的设备
以及设置中的设备选择
并不总是能够正常的生效到视频流获取上
经过测试,浏览器设置中的摄像头选择完全没用。授权时的选择则因操作系统和浏览器而异,有时可用,部分浏览器授权时不提供选择
音频设备在用户使用多通道声卡的情况下也有类似问题,所以我们需要支持用户切换不同的设备
具体实现
浏览器提供了APInavigator.mediaDevices.enumerateDevices(),可以用来获取用户设备。不过enumerateDevices并不会主动拉起用户授权,所以需要先调用一遍默认的视频流getUserMedia({ audio: true, video: true })拿到用户授权后(仅用来触发授权,拿到后将流丢弃即可),再获取用户的设备列表。
在调用getUserMedia时针对用户拒绝、没有设备等各种场景会有不同的报错,可以参考MDN-MediaDevices: getUserMedia() method对应的错误,提示用户进行处理
小提示,音视频API对应的MDN中文文档经常会少内容,阅读时不要切中文
PC WEB端
Web端相对比较简单,enumerateDevices返回的设备列表如下
我们直接展示出对应的label然后让用户选择自己想要的设备就好了,选中后我们拿到deviceId传给getUser,可以用applyConstraints直接将配置应用到当前流或者用getUserMedia在拉取一遍流均可
getUserMedia({
video: {
....
deviceId: {
exact: video.deviceID, // 注意制定exact,默认的约束是期望值
},
},
audio:
{
deviceId: {
exact: audio.deviceID, // 音频和视频是分开的
},
}
}
移动端
移动端的前置通常只有一颗摄像头,比较简单,和PC一样直接获取使用即可。但对于后置摄像头的场景则比较复杂,现在手机一般有多个后置摄像头,对应不同的用途,由于各家厂商硬件的设置不同情况会有比较大的不同。
例如在iphone 14机型上,实际上有两个物理摄像头。但enumerateDevices会有三个摄像头返回,实际测试发现后置双广角和后置相机为同一个设备,都为主摄。后置超广角则对应实际的广角镜头
但在xiaomi 13手机上,该机型有三颗后摄(主摄、长焦和广角),但enumerateDevices只返回了一个后摄像头,检查参数后发现所给出的这个摄像头是后置主摄,长焦和广角都没出现
所以我们在移动端很难指定到一个比较具体的摄像头设备。所以最好只指定前摄或者后摄(对应facingMode参数),不指定具体的摄像头由浏览器以分辨率等参数来决定,以保证一个比较好适配。
但是在个别分辨率和比例参数下,浏览器可能会拉起错误的摄像头导致视频的画面质量不合预期。虽然浏览器也提供了API可以获取不同的摄像头参数(下边会提),但这个API对于的获取与设置的适配性并不好。
对于拉起非主摄这个问题,目前没有发现很好的解决方案。如果确实需要可以通过查询有无torch参数(能不能控制闪光灯)、查看zoom范围(排除zoom范围特别离谱的)、查看focusDistance范围(这个值代表焦距范围,筛选非常有效但不是所有设备都返回)等等方法来尝试寻找并强制指定主摄,需要大量的测试与总结。
视频流参数指定
我们对于录制的视频流通常是有一定要求的,例如分辨率、缩放、帧率等。这里用一个例子来介绍,譬如说我们可能希望录制一个分辨率为1080P的竖屏视频,参数可能会像这样
await navigator.mediaDevices.getUserMedia({
video: {
height: 1960,
width: 1080,
aspectRatio: 9 / 16,
deviceId: {
exact: video.deviceID,
},
} ,
audio: true,
})
对于像aspectRatio和weidth height这种可能会相互冲突的字段来说,getUserMedia会优先采用先出现的那一个
但实际展示的视频可能并不是想要的那样,虽然我们请求了一个竖屏的视频流,在我使用的mac上,chrome实际返回的是一个横屏视频。这是因为对于getUserMedia来说,接受的MediaTrackConstraints参数更像是请求而非命令,浏览器会尽量满足需求(参考阅读 Capabilities, constraints, and settings - Web APIs | MDN)
MediaTrackConstraints参数的传递有三种形式
- 默认传/ideal
height:{ideal: 1920}:理想值,浏览器尽力满足,满足不了就算了,不会出错 - exact
height: {exact: 1920}:指定值,就要这个。如果不满足将会抛出错误(实际上在指定exact参数时,非常容易抛错,需要做好错误处理) - min-max
height:{min: 1080;max:1920}:指定范围值,如果满足不了任意一个范围也会抛出错误
浏览器提供了getCapabilities API,可以查看摄像头的对于焦距、分辨率等约束的支持范围(包括色温、曝光时间、对比度等高阶参数),我们可以借助这个API来看看摄像头的具体支持到什么参数。getCapabilities需要在MideaStream的tracks上调用,所以需要依次先拉取目标摄像头的流才能获取到。
我们回到刚刚这个例子,我们看一下电脑摄像头的capabilities
{
"aspectRatio": {
"max": 1920,
"min": 0.0005208333333333333
},
"deviceId": "a334ea622a76742171013bae7653f33b224a35e6dde9f3074e81477f4ef30a27",
"facingMode": [],
"frameRate": {
"max": 30,
"min": 0
},
"groupId": "2fd737c2571a5dce98a56209561357b01b8ea10cef7440236b1aeb1470ca8009",
"height": {
"max": 1920,
"min": 1
},
"resizeMode": [
"none",
"crop-and-scale"
],
"width": {
"max": 1920,
"min": 1
}
}
这里高度的max是1920,我们尝试获取最高高度试试
await navigator.mediaDevices.getUserMedia({
video: {
height: { exact: 1920 }, // 只写死最高的高度,以保证最高的清晰度
aspectRatio: 9 / 16,
deviceId: {
exact: video.deviceID,
},
} ,
audio: true,
})
结果发现报错了,被浏览器拒绝
又是一个小坑,高宽返回的capabilities直接传并不能总是生效的,可能是因为摄像头硬件原因并不支持录制这么高清的竖屏视频。我们只能尝试强制指定aspectRatio来获取9: 16的视频
await navigator.mediaDevices.getUserMedia({
video: {
aspectRatio: { exact: 9/16 }, // 只要9:16的视频,别的不要
deviceId: {
exact: video.deviceID,
},
} ,
audio: true,
})
这下浏览器终于返回了一个竖屏的视频回来,检查发现是实际是一个608*1080的视频流。
通常来说硬件摄像头设备对于通用分辨率会支持的比较好,例如1080P、720P这种常见的分辨率。所以前端我们在录制视频的时候,一个比较方便的实现方法是。前端去指定一个相对比较通用的分辨率(尽管可能实际返回的分辨率并非是正确的),录制预览时video用样式object-fit裁剪后,后端再进行同步的裁剪处理。
另一个坑点:height和width在移动端设备上是反着的,可能是出于硬件好设置的考虑
如果实在对视频参数设置有需要,可以参考webrtchacks.github.io/WebRTC-Came…
WebView适配
对于移动端浏览器的适配,我们通常直接使用移动端的浏览器直接访问测试即可,表现基本和PC端一致。这里注意一下对于ios端(包括ipadOS),由于苹果公司的要求,所有的浏览器都是WebKit内核,也就是说大家全都是safari套壳,在PC上开发时可以注意对齐safari的效果。
对于WebView情况则比较特殊,我们没有办法在页面上直接获取到用户手机系统的摄像头权限,所以需要和app做一定的交互,这里ios和安卓情况不太一样,我们分开看。
IOS
和浏览器类似,苹果公司强制要求应用必须使用WKWebView来嵌入网页内容。对于视频录制的这一套WebRTC API WKWebView从ios 14.3版本(含此版本)开始内置支持,对于老版本则需要处理一下
在H5中使用时直接调用getUserMediea即可,app会自动弹出摄像头授权弹窗(每次使用都会弹)
安卓
安卓的上应用则对于内嵌的webview没有强制要求,所以情况复杂一点
在使用安卓自带原生webview的情况下,原生webview对于webRTC支持的比较好也比较早,版本问题基本可以忽略。但是摄像头麦克风权限需要在每次使用时都动态请求,这个权限请求需要由app完成,所以在H5上需要调用JSBridge在开始使用让app请求到摄像头权限,然后就可以正常的使用录制API。
流录制
录制比特率限制
录制视频时视频内容会存在内存里,录制完成后需要上传,视频太大的话容易爆内存上传耗时也会很久,所以我们需要想办法限制录制出来的视频大小(如果不加限制的话,safari录制出来的视频就特别的大)。我们可以通过两种方式来减小视频大小:减小视频分辨率、压缩视频质量
首先我们要弄清楚视频分辨率和视频质量分别代表什么
- 视频分辨率即视频图像的尺寸,越高的分辨率代表着视频每一帧有更多的像素,通常我们说的清晰度就是分辨率
- 视频质量是一个综合的度量,通常压缩率的提高会一定程度上降低视频质量,例如过高的压缩率可能会导致视频内容中出现色块、拖影、坏帧等问题,影响观看体验
对于控制视频分辨率,我们可以通过上边提到的MediaTrackConstraints指定分辨率来实现,例如1080P过大的话我们就指定720P。对于视频质量则可以通过MedieaRecorder指定录制时的比特率来实现,像这样
mediaRecorder = new MediaRecorder(streamManager.stream, {
bitsPerSecond: 932_000, // 即 932 kbps,限制15min不超过100mb
});
检查发现bitsPerSecond字段通常并非严格的恒定比特率,浏览器会有优化逻辑,控制比特率在这个值附近浮动,不太用担心恒定比特率带来的问题
合适的比特率取决于多种因素,例如视频内容的复杂度(视频内运动更简单的视频更容易编码算法压缩)、帧率等参数。我们选择的比特率和分辨率也要搭配起来,譬如说我们如果指定了一个过高的分辨率和一个过低的比特率,可能会导致视频的质量被压缩的没法看,显然是不合适的。
那我们怎么选择一个合适的比特率呢?虽然有不少比特率的计算算法和工具可以参考,但这些工具计算出来的值不一定适合我们的场景。个人使用的方式是先在对应场景下,录几个不同分辨率的demo视频(嫌麻烦可以录一次然后用ffmpeg指定不同的参数重编码),按照我们的要求从这个分辨率中挑一个,然后根据大小和视频时长来算一个平均的比特率。
MediaRecorder重复启动
重复的启动MediaRecorder(反复调用mediaRecorder.start())可能会出现莫名其妙的问题,并且不会报错。譬如搭配fix-webm-duration在大时长下可能会出现音频重复的问题,使用MediaRecorder时最好自己在管理类中控制一下状态或者做好防抖,避免重复start。
其他
麦克风音量提示
对于视频录制场景,基本只需要展示音量大小让用户知道麦克风是否可用就可以,比较简单。AudioContext可以直接处理音视频流(会自动忽视其中的视频内容),我们直接使用StreamManager的音频流即可。音量的计算就使用最经典的RMS音量即可,计算方式非常简单,xi代表不同频域的值,RMS音量则是
不过计算出的音量波动会比较厉害,我们需要加一个平滑算法让他丝滑一些,使用比较简单的EMA平滑即可,最后的实现如下
const initVolume = Math.sqrt(
dataArray.reduce((sum, val) => sum + val * val, 0) /
dataArray.length,
); // RMS 计算
const volume = initVolume < threshold ? 0 : initVolume + gain; // 加个静音阈值减少波动
const lastVolume = prevValueRef.current;
const smoothedVolume = alpha * lastVolume + (1 - alpha) * volume; // alpha越大越平滑
prevValueRef.current = smoothedVolume;
前置摄像头翻转
拉起的前置摄像头默认是镜像翻转的,需要翻转回来,在video上设置transform: scaleX(-1)即可。移动端的后置摄像头则通常不需要,切换前后摄时注意样式的设置