文章目錄
- 技術棧
- 功能介紹
- video標簽屬性
- 完整代碼
- js 前端實現將視頻Blob轉Base64
- java 后端實現將視頻Base64轉mp4文件
在移動端網頁開發中,使用攝像頭錄制視頻并自動生成截圖是一個常見的需求,比如身份認證、人臉識別或互動問卷等場景。本文將介紹如何使用 Vue 實現一個簡潔的前端視頻錄制組件,并在錄制結束后自動截圖。
技術棧
- Vue 2.x
- JavaScript(原生 API)
- WebRTC(MediaDevices、MediaRecorder)
- HTML5 元素
- Canvas 截圖
功能介紹
- 支持前/后攝像頭切換;
- 錄制指定時長(默認 5 秒)的視頻;
- 錄制完成后自動截圖視頻中間幀;
- 視頻播放支持 controls 控件;
- 截圖以 Base64 顯示;
- 提供 @change 和 @screenshot 事件給父組件處理。
video標簽屬性
- autoplay:頁面加載完成后自動播放視頻。注意瀏覽器通常要求視頻是靜音的才能自動播放。
- playsinline:允許視頻在網頁內聯播放,阻止在 iOS 上自動全屏。是 HTML 標準屬性。
- controls:是否顯示視頻播放控件,布爾值控制。
- muted:是否靜音播放。如果不顯示控件就靜音,滿足自動播放要求。
- webkit-playsinline:iOS Safari 專用,允許內聯播放,防止自動全屏。
- x5-playsinline:騰訊 X5 內核瀏覽器專用(如微信瀏覽器),允許內聯播放。
- x5-video-player-type=“h5”:強制使用 HTML5 播放器而不是系統播放器。適用于 X5 內核瀏覽器。
- x5-video-player-fullscreen=“false”:禁止自動全屏(X5 內核瀏覽器),和 x5-playsinline 配合使用。
完整代碼
- index.vue
<template><div class="container"><video ref="video" class="video-container"autoPlayplaysinlinewebkit-playsinlinex5-playsinlinex5-video-player-type="h5"x5-video-player-fullscreen="false":controls="showVideoControls":muted="!showVideoControls"></video><div class="btn-group"><van-button icon="revoke" @click="toggleCamera" :disabled="recordStatus === 1">切換{{ cameraFacing === 'user' ? '后置' : '前置' }}</van-button><van-button type="primary" icon="play-circle" @click="startRecord" :disabled="recordStatus === 1">{{ `${recordStatus === 1 ? countDown : ''} ${recordStatusText[recordStatus]}` }}</van-button></div><img v-if="isScreenshot && videoScreenshotUrl" class="screenshot-container" :src="videoScreenshotUrl"></div>
</template><script src="./index.js">
</script><style scoped>
@import "./index.css";
</style>
- index.js
export default {name: 'video-record',data() {return {videoWidth: 320,videoHeight: 240,videoType: 'video/mp4',imageType: 'image/png',stream: null,mediaRecorder: null,recordedChunks: [],videoBlob: null,showVideoControls: false,// 前置攝像頭cameraFacing: 'user',// 0-未開始 1-錄制中 2-錄制完成recordStatus: 0,recordStatusText: ['開始錄制', '秒后停止', '重新錄制'],// 錄制時長countDown: 5,timer: null,// 視頻截圖Base64數據videoScreenshotUrl: null,// 是否展示截圖isScreenshot: true}},methods: {toggleCamera() {this.cameraFacing = this.cameraFacing === 'user' ? 'environment' : 'user';},async startRecord() {this.showVideoControls = falsethis.countDown = 5try {this.stream = await navigator.mediaDevices.getUserMedia({video: {facingMode: this.cameraFacing,width: {ideal: this.videoWidth},height: {ideal: this.videoHeight},frameRate: {ideal: 15}},audio: true,});this.$refs.video.srcObject = this.stream;this.mediaRecorder = new MediaRecorder(this.stream);this.mediaRecorder.ondataavailable = e => {if (e.data.size > 0) this.recordedChunks.push(e.data);};// 錄制結束回調this.mediaRecorder.onstop = this.onRecordStop;// 開始錄制this.mediaRecorder.start();this.recordStatus = 1;// 開始倒計時this.timer = setInterval(() => {this.countDown--;this.elapsedSeconds++;if (this.countDown <= 0) {this.stopRecord();}}, 1000);} catch (err) {console.error('獲取攝像頭失敗', err);this.recordStatus = 0}},stopRecord() {if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {this.mediaRecorder.stop();}if (this.stream) {this.stream.getTracks().forEach(track => track.stop());this.$refs.video.srcObject = null;}this.recordStatus = 2;clearInterval(this.timer);this.timer = null;},onRecordStop() {this.videoBlob = new Blob(this.recordedChunks, {type: this.videoType});const videoUrl = URL.createObjectURL(this.videoBlob);this.$emit('change', {videoBlob: this.videoBlob});const video = this.$refs.video;video.src = videoUrl;video.onloadedmetadata = () => {this.showVideoControls = true;video.currentTime = 0;video.play();// 播放到視頻中間段自動執行截圖const duration = (isFinite(video.duration) && !isNaN(video.duration)) ? video.duration : 5.0;const targetTime = duration / 2;const onTimeUpdate = () => {if (video.currentTime >= targetTime) {// 移除監聽器,防止多次觸發截圖操作。video.removeEventListener('timeupdate', onTimeUpdate)// 在瀏覽器下一幀進行截圖,確保渲染完成后再執行requestAnimationFrame(() => {// console.log('執行截圖操作')this.captureFrame()})}}// 注冊事件監聽器:只要視頻播放,onTimeUpdate 會不斷被觸發(每約250ms,甚至更頻繁),直到滿足條件。video.addEventListener('timeupdate', onTimeUpdate);}},// 截圖操作captureFrame() {const video = this.$refs.videoif (!video) {console.warn('未找到 video 元素,跳過截圖');return}const canvas = document.createElement('canvas');const ctx = canvas.getContext('2d');canvas.width = video.videoWidth || 320;canvas.height = video.videoHeight || 240;ctx.drawImage(video, 0, 0, canvas.width, canvas.height);// 圖片Base64數據this.videoScreenshotUrl = canvas.toDataURL(this.imageType);this.$emit('screenshot', {videoScreenshot: this.videoScreenshotUrl});}}
}
- index.css
.container {display: flex;flex-direction: column;align-items: center;width: 100vw;
}.video-container {margin-top: 24px;margin-bottom: 24px;width: 320px;height: 240px;background: #000000;
}.btn-group {display: flex;flex-direction: row;justify-content: space-between;align-items: center;width: 320px;margin-bottom: 24px;
}.screenshot-container {width: 320px;height: 240px;
}
js 前端實現將視頻Blob轉Base64
function blobToBase64(blob) {return new Promise((resolve, reject) => {const reader = new FileReader();reader.onloadend = () => resolve(reader.result); // 結果是 data:video/mp4;base64,...reader.onerror = reject;reader.readAsDataURL(blob);});
}
java 后端實現將視頻Base64轉mp4文件
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;private void base64ToFile(String base64Str, Path filePath) throws IOException {// 如果 base64Str 含有 "data:video/mp4;base64," 頭部,需要去除if (base64Str.contains(",")) {base64Str = base64Str.substring(base64Str.indexOf(",") + 1);}// Base64 解碼byte[] data = Base64.getDecoder().decode(base64Str);// 寫入文件try (OutputStream stream = Files.newOutputStream(filePath)) {stream.write(data);}
}
Path videoFile = Files.createTempFile("filename", ".mp4");
base64ToFile(videoBase64, videoFile);