需求背景
用户对着PPT边讲解边进行摄像头录制,同时要把用户每一页PPT说的话转为字幕显示在评测结果中,但iPhone手机10秒钟基本就能录制一个大约12MB大小的视频,如果考试时间达到30分钟,那么消耗的流量将十分恐怖,而且极其容易出现网络问题。
最关键的是,部分IOS设备在录制较大文件时,会莫名奇妙地清空大内存数据,MediaRecorder得到的数据的size突然变成0字节,导致视频录制失败。(iPhone13大概录制2~3分钟视频就会出现这种情况,出现频率之高使得功能完全无法实现)所以视频压缩势在必行。
之前有尝试使用 FFmege.wasm失败,一是性能比较差,需要大概原视频50%的时间来进行压缩,在此期间对设备性能占用非常高;二是移动设备兼容性很差,笔者没办法成功跑起来。现在发现canvas也可以进行视频压缩,故可以尝试该方案。
核心思路
- 使用 navigator.mediaDevices.getUserMedia 调起用户摄像头,将媒体流存储到变量中;
- 将视频轨的每一帧绘制到canvas中,通过调整canvas元素的宽高和帧率来调整视频质量;
- 从之前的变量中拿到视音频媒体流,将音频轨单独抽出来,和canvas中的视频轨合并,得到一个全新的媒体流,这个媒体流有音视频轨,但视频质量比较差,所以整体大小也会变小。
代码效果图:(大小为 PC端未被压缩视频大小)
压缩后大小:
经过多次测试,PC端和H5手机端页面都可以压缩到原本的 25% ~ 35% 左右的大小,效果虽然不算非常好,但是没有引入任何第三方库,代码量比较少,所以也算有它的价值所在。后来我们引入了腾讯云的COS对象存储来把大文件进行分块上传,同时引入IndexDB来防止断网异常情况,也算是解决这个问题了。
核心代码
// 获取用户摄像头媒体流
async function getMediaStream() {
if (!videoElement.value || !canvasElement.value) {
return Promise.reject('元素不存在')
}
try {
// 1、申请摄像头权限、获取音视频媒体流(注意:对于ios手机来说,申请摄像头权限之前,用户必须要跟页面有互动行为 比如点击事件、触摸事件等等,这是ios的强制限制)
videoStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// 2、监听播放事件,进行视频转换(思路是通过canvas画布上绘制视频帧,控制canvas的大小和帧率,以此达到压缩视频的目的)
videoElement.value.srcObject = videoStream // 指定video标签的媒体源
videoElement.value.addEventListener('loadedmetadata', () => {
// 指定video标签的宽高,根据摄像头录制的原始宽高比来调整canvas标签的尺寸从而获得最佳视频尺寸效果,此时要通过video标签的loadedmetadata事件获取video标签的实际尺寸,而不能是play事件
canvasElement.value!.width = videoElement.value!.offsetWidth || 400
canvasElement.value!.height = videoElement.value!.offsetHeight || 300
drawVideoToCanvas(videoElement.value!, canvasElement.value!)
})
await videoElement.value.play() // 设置video标签的 autoplay为false,手动执行play方法 来兼容老版本ios手机(比如IOS系统版本15.x的iPhone13)无法autoplay的bug
return Promise.resolve('获取媒体流成功')
} catch (error) {
return Promise.reject(error)
}
}
// 绘制视频到canvas
function drawVideoToCanvas(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
const context = canvas.getContext('2d')
function drawFrame() {
context!.drawImage(video, 0, 0, canvas.width, canvas.height)
requestAnimationFrame(drawFrame)
}
drawFrame()
}
// 创建 MediaRecorder 对象
function createMediaRecorder(videoStream: MediaStream) {
const canvasStream = canvasElement.value!.captureStream(15) // 15 FPS
const combinedStream = new MediaStream()
canvasStream.getVideoTracks().forEach(track => combinedStream.addTrack(track)) // 从canvas标签中提取视频轨道
videoStream.getAudioTracks().forEach(track => combinedStream.addTrack(track)) // 从video标签中提取音频轨道
const recorder = new MediaRecorder(combinedStream, {
mimeType: mimeType,
videoBitsPerSecond: 2500000, // 设置视频比特率为 2.5Mbps
audioBitsPerSecond: 64000 // 设置音频比特率为 64kbps
})
recorder.onstart = () => {
isRecording.value = true
console.log(`开始录制新的一页,mimeType: ${mimeType}`)
}
recorder.ondataavailable = (e: BlobEvent) => {
if (e.data.size > 0) {
recordBlobChunks.value.push(e.data)
} else {
ElMessage.error({
message: '最近10秒内数据录制大小为0',
duration: 0,
showClose: true
})
}
}
recorder.onstop = async () => {
if (recordBlobChunks.value.length > 0) {
let fullBlob = new Blob(recordBlobChunks.value, { type: mimeType })
const url = URL.createObjectURL(fullBlob)
addTableData(url, fullBlob.size) // 处理文件数据到表格中
page.value += 1 // 页数+1
recordBlobChunks.value = [] // 清空数组
recorder!.start(timeSlice) // 重新开始录制
} else {
ElMessage.error({
message: '录制异常,请重新录制本页,可尝试缩短录制时长',
duration: 0,
showClose: true
})
recorder!.start(timeSlice) // 开始录制下一页数据
}
}
recorder.onerror = (e: Event) => {
console.error('触发了 error 事件:', e, recorder?.state)
}
return recorder
}
代码全文
<template>
<div class="test-page" v-loading="isLoading_fullScreen">
<div class="flx-center" style="flex-direction: column">
<video ref="videoElement" webkit-playsinline playsinline muted style="object-fit: cover" width="300px"></video>
<!-- canvas 标签仅仅用于压缩视频 -->
<canvas ref="canvasElement" style="display: none"></canvas>
<div style="margin-top: 10px; column-gap: 30px" class="flx-center">
<h1 style="display: inline-block">{{ page }}</h1>
<el-button type="primary" @click="nextPage">翻页</el-button>
</div>
</div>
<div class="card">
<div style="margin-bottom: 10px; column-gap: 30px" class="flx-center">
<el-button @click="startRecord" v-if="!isRecording" style="margin-right: 10px">开始录制</el-button>
<el-text type="primary">{{ timer_min }}</el-text>
<el-text v-if="isRecording">{{ old_timer_min }}</el-text>
</div>
<el-table :data="tableData" border max-height="260">
<el-table-column prop="size" label="大小(MB)" min-width="100" align="center" />
<el-table-column prop="recordDuration" label="用时" min-width="100" align="center" />
<el-table-column prop="page" label="页码" width="80" align="center" />
<el-table-column prop="downloadUrl" label="下载" width="100" align="center">
<template #default="scope">
<el-button type="text" @click="createDownloadLink(scope.row.downloadUrl, scope.row.page)">下载</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts" name="test">
import { ElMessage } from 'element-plus'
import { ref, computed } from 'vue'
const page = ref(1)
// ============================================= 时间相关 =============================================
const timer_sec = ref(0)
const timer_min = computed(() => {
let mm = Math.floor(timer_sec.value / 60)
let ss = Math.floor(timer_sec.value % 60)
return (mm < 10 ? '0' + mm : mm) + ':' + (ss < 10 ? '0' + ss : ss)
})
const old_timer_min = ref('00:00')
// ============================================= 表格相关 =============================================
type TableDataItem = {
size: string | number // 文件大小
page: number // 所属页码
endTime: number // 结束录制时间
recordDuration: string // 录制时长
downloadUrl: string // 下载地址
}
const tableData = ref<TableDataItem[]>([])
const addTableData = (blobUrl: string, blobSize: number) => {
let recordDuration = ''
let lastItem = tableData.value[tableData.value.length - 1]
if (lastItem) {
recordDuration = secondToMinute((Date.now() - lastItem.endTime) / 1000)
} else {
recordDuration = secondToMinute(timer_sec.value)
}
tableData.value.push({
size: (blobSize / 1024 / 1024).toFixed(2),
endTime: Date.now(),
recordDuration: recordDuration,
page: page.value,
downloadUrl: blobUrl
})
}
// ===================================== 视频录制相关 =====================================
const isLoading_fullScreen = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null) // 用于播放的 VideoElement 对象
const canvasElement = ref<HTMLCanvasElement | null>(null) // 用于压缩视频的 CanvasElement 对象
let mediaRecorder: MediaRecorder | null = null // 用于录制的 MediaRecorder 对象
const isRecording = ref(false) // 是否正在录制
const recordBlobChunks = ref<Blob[]>([]) // 记录视频Blob数据
const timeSlice = 10 * 1000
let mimeType = getMimeType() || ''
let videoStream: MediaStream | null = null
function getMimeType() {
// 定义一个可能的 mimeType 列表,按优先级排序
const mimeTypes = [
'video/mp4', // MP4 with H.264 video codec
'video/webm', // WebM (browser default codecs)
'video/x-matroska; codecs=avc1' // Matroska with H.264 video codec
]
// 检查每个 mimeType 是否被支持
for (let mimeType of mimeTypes) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType
}
}
return 'video/mp4'
}
// 开始录制(把这个函数放到用户交互之后再调用,不要在用户进入页面时就调用 来避开ios的安全限制)
async function startRecord() {
isLoading_fullScreen.value = true
try {
// 1、获取媒体流
await getMediaStream()
// 2、创建MediaRecorder对象
mediaRecorder = createMediaRecorder(videoStream!)
mediaRecorder.start(timeSlice)
setInterval(() => {
timer_sec.value += 1
}, 1000)
isLoading_fullScreen.value = false
} catch (error) {
console.error('错误:', error)
alert('无法访问摄像头或麦克风')
}
}
// 获取用户摄像头媒体流
async function getMediaStream() {
if (!videoElement.value || !canvasElement.value) {
return Promise.reject('元素不存在')
}
try {
// 1、申请摄像头权限、获取音视频媒体流(注意:对于ios手机来说,申请摄像头权限之前,用户必须要跟页面有互动行为 比如点击事件、触摸事件等等,这是ios的强制限制)
videoStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
// 2、监听播放事件,进行视频转换(思路是通过canvas画布上绘制视频帧,控制canvas的大小和帧率,以此达到压缩视频的目的)
videoElement.value.srcObject = videoStream // 指定video标签的媒体源
videoElement.value.addEventListener('loadedmetadata', () => {
// 仅仅指定video标签的宽,让浏览器根据 摄像头录制的原始宽高比 来自行调整video标签的高度从而获得最佳视频尺寸效果,此时要通过video标签的loadedmetadata事件获取video标签的实际尺寸,而不能是play事件
canvasElement.value!.width = videoElement.value!.offsetWidth || 400
canvasElement.value!.height = videoElement.value!.offsetHeight || 300
drawVideoToCanvas(videoElement.value!, canvasElement.value!)
})
await videoElement.value.play() // 设置video标签的 autoplay为false,手动执行play方法 来兼容老版本ios手机(比如IOS系统版本15.x的iPhone13)无法autoplay的bug
return Promise.resolve('获取媒体流成功')
} catch (error) {
return Promise.reject(error)
}
}
// 创建 MediaRecorder 对象
function createMediaRecorder(videoStream: MediaStream) {
const canvasStream = canvasElement.value!.captureStream(15) // 15 FPS
const combinedStream = new MediaStream()
canvasStream.getVideoTracks().forEach(track => combinedStream.addTrack(track)) // 从canvas标签中提取视频轨道
videoStream.getAudioTracks().forEach(track => combinedStream.addTrack(track)) // 从video标签中提取音频轨道
const recorder = new MediaRecorder(combinedStream, {
mimeType: mimeType,
videoBitsPerSecond: 2500000, // 设置视频比特率为 2.5Mbps
audioBitsPerSecond: 64000 // 设置音频比特率为 64kbps
})
recorder.onstart = () => {
isRecording.value = true
console.log(`开始录制新的一页,mimeType: ${mimeType}`)
}
recorder.ondataavailable = (e: BlobEvent) => {
if (e.data.size > 0) {
recordBlobChunks.value.push(e.data)
} else {
ElMessage.error({
message: '最近10秒内数据录制大小为0',
duration: 0,
showClose: true
})
}
}
recorder.onstop = async () => {
if (recordBlobChunks.value.length > 0) {
let fullBlob = new Blob(recordBlobChunks.value, { type: mimeType })
const url = URL.createObjectURL(fullBlob)
addTableData(url, fullBlob.size) // 处理文件数据到表格中
page.value += 1 // 页数+1
recordBlobChunks.value = [] // 清空数组
recorder!.start(timeSlice) // 重新开始录制
} else {
ElMessage.error({
message: '录制异常,请重新录制本页,可尝试缩短录制时长',
duration: 0,
showClose: true
})
recorder!.start(timeSlice) // 开始录制下一页数据
}
}
recorder.onerror = (e: Event) => {
console.error('触发了 error 事件:', e, recorder?.state)
}
return recorder
}
// 绘制视频到canvas
function drawVideoToCanvas(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
const context = canvas.getContext('2d')
function drawFrame() {
context!.drawImage(video, 0, 0, canvas.width, canvas.height)
requestAnimationFrame(drawFrame)
}
drawFrame()
}
const nextPage = async () => {
if (mediaRecorder) {
mediaRecorder.stop()
old_timer_min.value = timer_min.value
}
}
// 处理时间 把秒数处理为分钟
const secondToMinute = (second: number, pad: string = '') => {
second = Math.floor(second)
return Math.floor(second / 60) + '分' + pad + (second % 60) + '秒'
}
// 创建下载链接
function createDownloadLink(url: string, pageNo: number) {
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = `测试视频-${pageNo}.mp4`
// 将下载链接添加到 DOM 中
document.body.appendChild(a)
// 触发点击事件以开始下载
a.click()
// 下载完成后移除下载链接
document.body.removeChild(a)
// 释放对象 URL
URL.revokeObjectURL(url)
}
</script>
<style lang="scss" scoped>
.test-page {
padding: 20px;
}
</style>
注意事项
- 代码中请自行删除elementUI相关内容,使用仅仅是为了美观,并无实际作用;
- 申请用户摄像头权限的API仅可在 localhost 和 https 开头的安全域名中使用,这个无法避免,是浏览器的强制安全策略。
- 这里使用canvas的宽高和帧率都比较低,会使得视频显得比较糊,我们主要是为了提取音频转写为文字,视频是次要的,所以才可以使用这个方案,读者们请自行测试实际效果。