背景
前段时间,客户那边新提了一个需求,要求实现活体检测的功能。经过调研,发现我的项目是一个h5项目,不能像小程序一样使用uni的camera就可以实现视频录制及添加蒙板等功能。最后,经过n久的调研和开发的尝试后,终于将功能实现出来了。【⚠️iphone端,此功能只支持14.3以上版本。原因是:低版本不支持MediaRecorder】如果有哪位小伙伴知道怎么兼容ios低版本,希望可以分享给我。谢谢~
实现原理
进入视频录制页面时,首先使用navigator.mediaDevices请求获取用户媒体权限。获取到用户权限后,初始化视频流信息,将流数据挂到video标签上。实现了实时视频数据播放功能。从而模拟实现了视频录像功能了。那么如何将视频录制并保存起来传给后端呢?个人理解就是使用MediaRecorder将实时数据流中的 某一段时间的视频流数据 处理保存,就生成了我们需要的mp4数据了。
实现效果
具体实现
安装依赖插件
npm i recordrtc
完整代码(可复制粘贴使用)
live-video.vue
```
<template>
<div class="face-wrapper">
<div class="tip-wrapper">
<div class="tip-header">
保持正脸对准摄像头朗读以下数字
</div>
<div class="tip-content">
<span id="text" class="text"> {{label}}</span>
</div>
</div>
<div class="video-wrapper">
<div class="img-module">
<img src="@/assets/img/identity/ico-head.png" class="img-content"/>
</div>
<video id="video" autoplay="autoplay" class="video-content" muted x5-playsinline playsinline webkit-playsinline ></video>
</div>
<div class="btn-wrapper">
<div class="btn-submit">
<div class="btn-submit-content" v-if="btnStatus !== 'stop'">
<div id="record-btn" class="btn-submit-content-name" @click="handleVideo">
{{btnStatus === 'start' ? '停止': '录制' }}
</div>
</div>
</div>
<div v-if="btnStatus === 'stop'" class="btn-submit1" >
<div class="btn-submit1-content1" @click="handleCancel">
取消
</div>
<div class="btn-submit1-content2" @click="handleSubmit">
确定
</div>
</div>
</div>
</div>
</template>
<script>
import RecordRTC from "recordrtc"
import {blobToBase64} from "@/util/upload"
import {Toast} from "vant"
export default {
name: "live-video",
props: {
label: {
required: true,
type: String,
default: 'rx_photo'
}
},
data() {
return {
video: {},
mediaRecorder: {},
recorderFile: {},
stream: {},
textInfo: "请将头像放入相框正中间,准备检测",
streamInfo: {},
systemType: '',
btnStatus: 'init',
timer: 0, // 定时器
time: 0, // 监听视频录制时间
lock: true,
base64Data: '', // 录像的base64数据
};
},
mounted() {
this.getUserMedia()
},
computed: {},
methods: {
/**
* 为标签添加class样式
**/
addClass(ele, name) {
if (name) {
//判断该dom有没有class,有则在原class基础上增加,无则直接赋值
ele.className ? ele.className = ele.className + " " + name : ele.className = name;
} else {
throw new Error("请传递一个有效的class类名");
}
},
/**
* 为标签移除class样式
**/
removeClass(ele, name) {
if(!name) throw new Error("请传递一个有效的class类名");
let classNameValue = ele.className;
if(!classNameValue) return
let arr = classNameValue.split(" ");
arr.forEach((item, index) => {
if(item === name) {
arr.splice(index, 1)
}
})
ele.className=arr.join();
},
/**
* 为唇语提示文字添加动态样式
* */
initLabel() {
this.addClass(document.getElementById('text'),'load')
},
/**
* 重置唇语标签动态样式
* */
resetLabel() {
this.removeClass(document.getElementById('text'),'load')
},
/**
* 获取用户媒体设备权限
*/
getUserMedia() {
this.video = document.getElementById('video')
// 这个用来控制你需要调取的设备
const constraints = {
audio: false, // 调用录音
video: {
deviceId: "default",
facingMode: "user" //调用前置摄像头
}
}
if(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices
.getUserMedia(constraints)
.then(this.initIosVideo)
.catch(this.handleVideoError)
} else {
window.alert("没有匹配到可以使用的浏览器内核");
}
},
/**
* ios系统下 调用用户摄像头成功调用此函数 保存数据流信息 实时影像
* */
initIosVideo(camera) {
this.recorder = RecordRTC(camera, {
type: "video",
});
this.streamInfo = camera
this.recorder.camera = camera;
this.video = document.querySelector("video");
video.srcObject = this.recorder.camera;
video.play()
},
/**
* 调用用户摄像头失败 调用该函数 打印错误信息
*/
handleVideoError(e) {
console.log("err:", e)
},
handleVideo() {
if(this.btnStatus === 'init') {
this.btnStatus = 'start'
this.startIosVideo()
setTimeout(() => {
this.initLabel()
}, 500)
} else {
this.btnStatus = 'stop'
this.resetLabel()
this.stopIosVideo()
}
},
startIosVideo() {
this.video.srcObject = this.recorder.camera;
this.video.play()
this.recorder.startRecording();
this.timer = setInterval(() => {
this.time++
}, 1000)
},
stopIosVideo() {
if(this.time < 4) {
Toast('视频时间过短,请重新录制!')
this.handleResetVideo()
} else {
let _this = this
this.recorder.stopRecording(() => {
_this.video.srcObject = null;
let blob = _this.recorder.getBlob();
_this.video.src = URL.createObjectURL(blob)
blobToBase64(blob).then(function(base64) {
_this.base64Data = base64
})
this.clearTime()
})
}
},
clearTime() {
this.time = 0
clearInterval(this.timer)
},
handleResetVideo() {
this.btnStatus = 'init'
this.video.srcObject = null;
this.video.src = null
this.initIosVideo(this.streamInfo)
this.clearTime()
},
handleCancel() {
this.handleResetVideo()
},
handleSubmit() {
this.$emit('handleVideoSuccess', this.base64Data)
this.video.srcObject = null;
this.clearTime()
this.closeStream(this.stream)
},
/**
* 关闭流
* @param stream
*/
closeStream(stream) {
const tracks = this.streamInfo.getTracks();
tracks.forEach(track => {
track.stop();
});
}
}
};
</script>
<style scoped>
.face-wrapper {
position: relative;
z-index: 9;
height: 100vh;
width: 100%;
background-color: rgba(0, 0, 0, 0.85);
}
@media screen and (min-height: 750px) {
.tip-wrapper {
position: relative;
width: 100%;
height: 18%;
}
.tip-header {
font-size: 16px;
color: #FFF;
text-align: center;
padding-top: 12%;
}
.tip-content {
width: 107px;
font-size: 30px;
font-weight: 500;
color: #FFF;
line-height: 30px;
position: relative;
left: calc(50% - 40px);
padding-top: 22px;
display: flex;
justify-content: space-between;
}
.btn-submit1 {
height: 100%;
padding: 0px 8px;
position: relative;
bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-submit1-content1 {
position: relative;
left: 0px;
color: #FFF;
font-size: 18px;
}
.btn-submit1-content2 {
position: relative;
right: 0px;
color: #FFF;
font-size: 18px;
}
.btn-wrapper {
height: 20%;
padding-top: 32px;
}
}
@media screen and (max-height: 740px) {
.tip-wrapper {
position: relative;
width: 100%;
height: 15%;
}
.tip-header {
font-size: 16px;
color: #FFF;
text-align: center;
padding-top: 6%;
}
.tip-content {
width: 107px;
font-size: 30px;
font-weight: 500;
color: #FFF;
line-height: 30px;
position: relative;
left: calc(50% - 40px);
padding-top: 10px;
display: flex;
justify-content: space-between;
}
.btn-submit1 {
height: 100%;
padding: 0px 8px;
position: relative;
bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-submit1-content1 {
position: relative;
left: 0px;
color: #FFF;
font-size: 18px;
}
.btn-submit1-content2 {
position: relative;
right: 0px;
color: #FFF;
font-size: 18px;
}
.btn-wrapper {
height: 17%;
padding-top: 32px;
}
}
@keyframes scan {
0% {
background-size:0 100%;
}
100% {
background-size:100% 100%;
}
}
.text {
letter-spacing: 8px;
background:#fff -webkit-linear-gradient(left, #407EF0 , #407EF0 ) no-repeat 0 0;
-webkit-text-fill-color:transparent;
-webkit-background-clip:text;
background-size:0 100%;
}
.load {
background-size:100% 100%;
animation: scan 5s linear;
}
.video-wrapper {
width: 100%;
height: 56%;
position: relative;
min-height: 420px;
}
.img-module {
position: absolute;
bottom: 0;
width: 100%;
z-index: 10;
}
.img-content {
position: relative;
width: 100%;
}
.video-content {
width: 100%;
position: relative;
height: 100%;
object-fit: none;
object-position: left top;
}
.btn-submit {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.btn-submit1-content {
text-align: center;
color: #FFF;
font-size: 18px;
}
.btn-submit-content {
width: 80px;
height: 80px;
border-radius: 50%;
z-index: 9;
background-color: rgba(255, 255, 255, 0.16);
display: flex;
justify-content: center;
align-items: center;
}
.btn-submit-content-name {
width: 70px;
height: 70px;
border-radius: 50%;
z-index: 10;
background-color: #FFF;
font-size: 18px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
color: #EE1710;
}
.btn-tip {
z-index: 10;
font-size: 16px;
text-align: center;
color: #FFF;
padding-top: 18px;
}
</style>
```
util/upload.js
/**
* blob数据转换为base64
* @param blob
* @returns {Promise<unknown>}
*/
export function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader()
fileReader.onload = e => {
resolve(e.target.result)
}
fileReader.readAsDataURL(blob)
fileReader.onerror = () => {
reject(new Error("文件流异常"))
}
})
}
最后
这个功能真的是折磨我太久了,希望看到这篇文章的小伙伴能少受一些调研的折磨😭。最后,感谢公司的前辈们帮忙调研!