H5项目,如何使用video标签实现活体检测视频录制功能

549 阅读1分钟

背景

前段时间,客户那边新提了一个需求,要求实现活体检测的功能。经过调研,发现我的项目是一个h5项目,不能像小程序一样使用uni的camera就可以实现视频录制及添加蒙板等功能。最后,经过n久的调研和开发的尝试后,终于将功能实现出来了。【⚠️iphone端,此功能只支持14.3以上版本。原因是:低版本不支持MediaRecorder】如果有哪位小伙伴知道怎么兼容ios低版本,希望可以分享给我。谢谢~

实现原理

进入视频录制页面时,首先使用navigator.mediaDevices请求获取用户媒体权限。获取到用户权限后,初始化视频流信息,将流数据挂到video标签上。实现了实时视频数据播放功能。从而模拟实现了视频录像功能了。那么如何将视频录制并保存起来传给后端呢?个人理解就是使用MediaRecorder将实时数据流中的 某一段时间的视频流数据 处理保存,就生成了我们需要的mp4数据了。

实现效果

WechatIMG415.jpeg WechatIMG414.jpeg WechatIMG412.jpeg

具体实现

安装依赖插件

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("文件流异常"))
        }
    })
}

最后

这个功能真的是折磨我太久了,希望看到这篇文章的小伙伴能少受一些调研的折磨😭。最后,感谢公司的前辈们帮忙调研!