【Vue3】使用canvas来实现H5页面摄像头录制的【视频压缩】功能

需求背景

用户对着PPT边讲解边进行摄像头录制,同时要把用户每一页PPT说的话转为字幕显示在评测结果中,但iPhone手机10秒钟基本就能录制一个大约12MB大小的视频,如果考试时间达到30分钟,那么消耗的流量将十分恐怖,而且极其容易出现网络问题。

最关键的是,部分IOS设备在录制较大文件时,会莫名奇妙地清空大内存数据,MediaRecorder得到的数据的size突然变成0字节,导致视频录制失败。(iPhone13大概录制2~3分钟视频就会出现这种情况,出现频率之高使得功能完全无法实现)所以视频压缩势在必行。

之前有尝试使用 FFmege.wasm失败,一是性能比较差,需要大概原视频50%的时间来进行压缩,在此期间对设备性能占用非常高;二是移动设备兼容性很差,笔者没办法成功跑起来。现在发现canvas也可以进行视频压缩,故可以尝试该方案。

核心思路

  1. 使用 navigator.mediaDevices.getUserMedia 调起用户摄像头,将媒体流存储到变量中;
  2. 将视频轨的每一帧绘制到canvas中,通过调整canvas元素的宽高和帧率来调整视频质量;
  3. 从之前的变量中拿到视音频媒体流,将音频轨单独抽出来,和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>

注意事项 

  1. 代码中请自行删除elementUI相关内容,使用仅仅是为了美观,并无实际作用;
  2. 申请用户摄像头权限的API仅可在 localhost 和 https 开头的安全域名中使用,这个无法避免,是浏览器的强制安全策略。
  3. 这里使用canvas的宽高和帧率都比较低,会使得视频显得比较糊,我们主要是为了提取音频转写为文字,视频是次要的,所以才可以使用这个方案,读者们请自行测试实际效果。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值