【前端】Vue3 获取视频首帧和时长

导读: 最近在做前端工作项目中做了一个功能,要求可以从本地选取或拖拽视频文件,校验是否是 mp4 文件,并执行相关上传操作。

由于还是前端小白,此处动动手指,敲敲键盘,记录一下~

效果图

实现思路

  • 该功能依托的环境是 Vue 3.4.21Element Plus 2.5.6Pinia 2.0.32
  • 此处的弹框是借助了 Element Plusel-dialog 小组件;
  • 此处的从本地选择文件和拖拽文件功能是借助了 Element Plusel-upload 小组件;
  • 当选择完文件会进行一波文件的基础信息校验,只有当文件符合规则再添加到对应的数组中去;
  • 紧接着在每个文件 itemonMounted 生命周期钩子函数中去处理本地视频的相关信息;
  • 通过手动创建 video 视频标签元素,播放视频文件:
    • 如果可以播放 oncanplay 方法有回调,即代表视频可以播放,进而通过 canvas 绘制首帧图片;
    • 即使可以播放,实际上也要进行筛选,因为音频文件通过 video 标签实际上也是可以播放的,只不过没有视频轨道而已,所以就需要进行过滤;
    • 如果不可以播放,则在 onerror 方法中会有对应回调信息,代表当前文件不支持播放。
  • oncanplay 方法回调中可以通过 video.duration 获取视频时长;
  • 通过 canvas 的绘制获取首帧图片。

实现代码(核心代码)

vue 复制代码
<script setup lang="ts">
onMounted(() => {
  // 获取视频首帧和时长
  if (props.file.uploadInfo.file.raw) {
    let videoUrl = null;
    if (window.URL !== undefined) {
      // 获取一个 http 格式的 url 路径
      videoUrl = window.URL.createObjectURL(props.file.uploadInfo.file.raw);
    } else if (window.webkitURL !== undefined) {
      videoUrl = window.webkitURL.createObjectURL(
        props.file.uploadInfo.file.raw
      );
    }
    if (videoUrl) {
      const video = document.createElement("video");
      video.preload = "metadata";
      video.src = videoUrl;
      // 解决跨域问题,也就是提示污染资源无法转换视频
      video.crossOrigin = "anonymous";
      // 获取第一帧
      video.currentTime = 1;

      // 获取 canvas 对象
      const canvas = document.createElement("canvas");
      // 绘制 2d
      const ctx = canvas.getContext("2d");
      // 检测可播放的情况下
      video.oncanplay = () => {
        if (ctx) {
          canvas.width = video.clientWidth;
          canvas.height = video.clientHeight;
          // 利用 canvas 对象方法绘图
          ctx.drawImage(video, 0, 0, video.clientWidth, video.clientHeight);
          // 转换成 base64 形式
          const imgUrl = canvas.toDataURL("image/png");
          firstFrameImageURL.value = imgUrl;
          // 判断是否是 mp4 文件
          if (video.videoWidth === 0 || video.videoHeight === 0) {
            uploadStore.handleUpdateIsMp4(props.file, false);
          } else if (video.duration && video.duration > 0 && imgUrl) {
            uploadStore.handleUpdateIsMp4(props.file, true);
          }
          // 将图片转成文件流
          canvas.toBlob((blob) => {
            if (blob) {
              // 创建文件对象
              const blobFile = new File([blob], `${fileName}.png`, {
                type: "image/png",
              });
              // eslint-disable-next-line vue/no-mutating-props
              props.file.uploadInfo.imgFile = blobFile;
              uploadStore.handleUploadImageFile(props.file, video.duration);
            }
            // 清除创建的元素
            URL.revokeObjectURL(video.src);
            video.remove();
            canvas.remove();
          }, "image/png");
        }
      };
      // 检测不可播放
      video.onerror = () => {
        uploadStore.handleUpdateIsMp4(props.file, false);
        // 清除创建的元素
        URL.revokeObjectURL(video.src);
        video.remove();
        canvas.remove();
      };
      // 检测是否是纯音频文件,如果文件只包含音频轨道而没有视频轨道,那么它就是一个纯音频文件
      // video.onloadedmetadata = () => {
      //   uploadStore.handleUpdateIsMp4(props.file, !(video.videoWidth === 0 && video.videoHeight === 0))
      // }
      document.body.appendChild(video);
    }
  }
});
</script>

实现代码(完整)

上传弹框界面

vue 复制代码
// UploadDialog.vue ------------------

<script setup lang="ts">
import { Ref, ref, watch, onMounted, onUnmounted, computed } from "vue";
import UploadItem from "@/components/upload/UploadItem.vue";
import { UploadFile } from "element-plus/es/components/upload/src/upload";
import { useUploadStore } from "@/stores/modules/upload.module";
import { throttle } from "lodash";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { v4 } from "uuid";

const uploadStore = useUploadStore();

const props = defineProps<{
  isShowDialog: boolean;
}>();

const emit = defineEmits<{
  (e: "changeShowDialog"): void;
}>();

// 是否展示弹框
const isShow = ref(false);
// 警告内容
const warningContent = ref("");
// 上传列表
const uploadList: Ref<VideoItem[]> = ref([]);
// 是否将文件拖拽入列表区
const isDragEnter = ref(false);
// 是否允许上传
const isValidUpload = computed(() => {
  return uploadStore.isHaveInValidFiles;
});

watch([() => props.isShowDialog], () => {
  isShow.value = props.isShowDialog;
});
watch(
  uploadStore.preUploadList,
  () => {
    uploadList.value = uploadStore.preUploadList;
    if (uploadList.value.length === 0) {
      isDragEnter.value = false;
    }
  },
  { deep: true }
);

onMounted(() => {
  // 监听网络
  window.addEventListener("online", handleNetworkChange);
  window.addEventListener("offline", handleNetworkChange);
  // 监听拖拽
  window.addEventListener("dragover", handleDragOver);
});

onUnmounted(() => {
  window.removeEventListener("online", handleNetworkChange);
  window.removeEventListener("offline", handleNetworkChange);
  window.removeEventListener("dragover", handleDragOver);
});

const handleNetworkChange = () => {
  if (!navigator.onLine) {
    showWarningAlert("网络连接不稳定");
  }
};

// 弹框提醒
const showWarningAlert = (content: string, duration = 1500) => {
  warningContent.value = content;
  setTimeout(() => {
    warningContent.value = "";
  }, duration);
};

// 选择文件
const selectFilesChange = (file: UploadFile) => {
  const findIndex = uploadList.value.findIndex(
    (item) =>
      item.uploadInfo.file.name === file.name &&
      item.uploadInfo.file.size === file.size &&
      item.uploadInfo.file.raw?.type === file.raw?.type
  );
  if (findIndex > -1) {
    showWarningAlert("视频重复上传");
    return;
  }
  const isExceedsGB =
    Number(((file.size || 0) / (1024 * 1024 * 1024)).toFixed(0)) > 1;
  if (isExceedsGB) {
    showWarningAlert("文件过大,请上传1GB以下视频");
    return;
  }

  // 此处处理必要属性
  const item: VideoItem = {
    fileName: file.name.split(".")[0],
    id: getRandomVideoId(32),
    ...
  };
  uploadStore.preUploadList.push(item);
};

// 生成一个 32 位的随机字符串作为本地视频的 id
const getRandomVideoId = (length: number) => {
  // const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
  let result = "";
  for (let i = 0; i < length; i++) {
    const randomIndex = Math.floor(Math.random() * characters.length);
    result += characters.charAt(randomIndex);
  }
  return result;
};

const listEvent = (event: MouseEvent) => {
  event.preventDefault();
};

// 关闭弹框
const closeEvent = () => {
  uploadStore.handleDeleteAllFiles(false);
  emit("changeShowDialog");
};

// 取消事件
const cancelEvent = () => {
  closeEvent();
};

// 上传事件
const uploadEvent = throttle(async () => {
  if (uploadList.value.length < 1) {
    return;
  }
  uploadStore.handleUploadVideFiles(uploadList.value);
  closeEvent();
});

const handleDragOver = (event: any) => {
  if (
    event.target.id !== "upload-drag-tips" &&
    !event.target.closest("#upload-drag-tips")
  ) {
    isDragEnter.value = false;
  }
};

const handleTipsDragOver = () => {
  isDragEnter.value = true;
};
</script>

<template>
  <el-dialog
    ref="dialogUpload"
    class="dialog-wrapper"
    v-model="isShow"
    width="50%"
    :show-close="false"
    :close-on-click-modal="false"
    :close-on-press-escape="false"
    :align-center="true"
    :append-to-body="true"
  >
    <div class="dialog-header">
      <p class="upload-title">上传视频</p>
      <div class="upload-close" @click="closeEvent()">
        <img src="@/assets/icons/icon_dialog_close.png" alt="" />
      </div>
    </div>
    <div class="dialog-content" :class="{ list: uploadList.length > 0 }">
      <el-upload
        ref="uploadRef"
        class="upload-drag-wrapper"
        :class="{ drag: uploadList.length === 0 }"
        drag
        multiple
        :show-file-list="false"
        :auto-upload="false"
        :accept="'video/mp4'"
        :on-change="selectFilesChange"
      >
        <template v-if="uploadList.length === 0">
          <div
            id="upload-drag-tips"
            class="upload-drag-tips"
            @dragover="handleTipsDragOver"
          >
            <img
              class="tips-icon-video"
              src="@/assets/icons/icon_upload_video.png"
              alt=""
            />
            <div class="tips-content-main">
              <span>{{
                isDragEnter ? "拖动视频文件到这里" : "拖动视频文件到这里或"
              }}</span>
              <span v-if="!isDragEnter" class="tips-warning">选择文件上传</span>
            </div>
            <div class="tips-content-sub">
              <span>支持</span>
              <span class="tips-warning"> MP4 </span>
              <span>格式文件,单个最大</span>
              <span class="tips-warning">1GB</span>
            </div>
          </div>
        </template>
        <template v-else>
          <div class="upload-files-list" @click.stop="listEvent">
            <template v-for="item in uploadList" :key="item.id">
              <UploadItem :file="item" />
            </template>
          </div>
        </template>
      </el-upload>
    </div>
    <div v-if="uploadList.length > 0" class="dialog-footer">
      <el-upload
        ref="uploadReAddRef"
        class="footer-btn-add-wrapper"
        multiple
        :show-file-list="false"
        :auto-upload="false"
        :accept="'video/mp4'"
        :on-change="selectFilesChange"
      >
        <el-button class="footer-btn-add">
          <img src="@/assets/icons/icon_upload_file.png" alt="" />
          <span>选择文件</span>
        </el-button>
      </el-upload>
      <div class="footer-right">
        <el-button class="footer-btn-cancel" @click.stop="cancelEvent"
          >取消</el-button
        >
        <el-button
          :disabled="!isValidUpload"
          class="footer-btn-upload"
          :class="{ inValid: !isValidUpload }"
          @click.stop="uploadEvent"
          >上传</el-button
        >
      </div>
    </div>
    <div v-if="warningContent.length" class="alert-warning">
      <img src="@/assets/icons/icon_upload_warning.png" alt="" />
      <span>{{ warningContent }}</span>
    </div>
  </el-dialog>
</template>

上传弹框单个文件 Item

vue 复制代码
// UploadItem.vue -------------

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useUploadStore } from "@/stores/modules/upload.module";

const uploadStore = useUploadStore();

const props = defineProps<{
  file: VideoItem;
}>();

// 关键帧图片 URL
const firstFrameImageURL = ref("");
// 文件名称
const fileName =
  props.file.uploadInfo.file.name.split(".")[0] ||
  props.file.uploadInfo.file.name;
// 文件大小
const fileSize = computed(() => {
  return uploadStore.handleGetFileSize(
    props.file.uploadInfo.file.size || 0,
    true
  );
});

// 是否显示警告信息
const isShowWarning = computed(() => {
  return !props.file.uploadInfo.isMp4 || props.file.uploadInfo.isExceedsGB;
});
// 警告信息内容
const warningContent = computed(() => {
  let content = "";
  if (isShowWarning.value) {
    if (!props.file.uploadInfo.isMp4) {
      content = "格式错误,请上传MP4视频";
    }
    if (props.file.uploadInfo.isExceedsGB) {
      content = "文件过大,请上传1GB以内的视频格式";
    }
  }
  return content;
});

onMounted(() => {
  // 获取视频首帧和时长
  if (props.file.uploadInfo.file.raw) {
    let videoUrl = null;
    if (window.URL !== undefined) {
      // 获取一个 http 格式的 url 路径
      videoUrl = window.URL.createObjectURL(props.file.uploadInfo.file.raw);
    } else if (window.webkitURL !== undefined) {
      videoUrl = window.webkitURL.createObjectURL(
        props.file.uploadInfo.file.raw
      );
    }
    if (videoUrl) {
      const video = document.createElement("video");
      video.preload = "metadata";
      video.src = videoUrl;
      // 解决跨域问题,也就是提示污染资源无法转换视频
      video.crossOrigin = "anonymous";
      // 获取第一帧
      video.currentTime = 1;

      // 获取 canvas 对象
      const canvas = document.createElement("canvas");
      // 绘制 2d
      const ctx = canvas.getContext("2d");
      // 检测可播放的情况下
      video.oncanplay = () => {
        if (ctx) {
          canvas.width = video.clientWidth;
          canvas.height = video.clientHeight;
          // 利用 canvas 对象方法绘图
          ctx.drawImage(video, 0, 0, video.clientWidth, video.clientHeight);
          // 转换成 base64 形式
          const imgUrl = canvas.toDataURL("image/png");
          firstFrameImageURL.value = imgUrl;
          // 判断是否是 mp4 文件
          if (video.videoWidth === 0 || video.videoHeight === 0) {
            uploadStore.handleUpdateIsMp4(props.file, false);
          } else if (video.duration && video.duration > 0 && imgUrl) {
            uploadStore.handleUpdateIsMp4(props.file, true);
          }
          // 将图片转成文件流
          canvas.toBlob((blob) => {
            if (blob) {
              // 创建文件对象
              const blobFile = new File([blob], `${fileName}.png`, {
                type: "image/png",
              });
              // eslint-disable-next-line vue/no-mutating-props
              props.file.uploadInfo.imgFile = blobFile;
              uploadStore.handleUploadImageFile(props.file, video.duration);
            }
            // 清除创建的元素
            URL.revokeObjectURL(video.src);
            video.remove();
            canvas.remove();
          }, "image/png");
        }
      };
      // 检测不可播放
      video.onerror = () => {
        uploadStore.handleUpdateIsMp4(props.file, false);
        // 清除创建的元素
        URL.revokeObjectURL(video.src);
        video.remove();
        canvas.remove();
      };
      // 检测是否是纯音频文件,如果文件只包含音频轨道而没有视频轨道,那么它就是一个纯音频文件
      // video.onloadedmetadata = () => {
      //   uploadStore.handleUpdateIsMp4(props.file, !(video.videoWidth === 0 && video.videoHeight === 0))
      // }
      document.body.appendChild(video);
    }
  }
});

// 删除事件
const deleteEvent = () => {
  uploadStore.handleDeleteFiles([props.file], false);
};

// // 检查文件类型(由于通过编码类型检查有问题,暂未使用)
// const checkVideo = (file: UploadFile) => {
//   const fileReader = new FileReader()
//   console.log('检查视频 1 --- ')
//   fileReader.onloadend = () => {
//     console.log('检查视频 3 --- ', fileReader.result)
//     const view = new DataView(fileReader.result)
//     const firstBytes = []

//     // 读取文件的前几个字节
//     for (let j = 0; j < 8; j++) {
//       firstBytes.push(view.getUint8(j))
//     }

//     // 检查文件头部是否符合 MP4 文件的标识符
//     if (isMP4File(firstBytes)) {
//       alert(`${file.name} 是一个真实的 MP4 文件`)
//     } else {
//       alert(`${file.name} 不是一个真实的 MP4 文件`)
//     }
//   }

//   console.log('检查视频 2 --- ', file.raw?.slice(0, 8))
//   fileReader.readAsArrayBuffer(file.raw?.slice(0, 8))
// }

// const isMP4File = (bytes: number[]) => {
//   // 检查文件头部是否符合 MP4 文件的标识符
//   return (
//     bytes[0] === 0x00 &&
//     bytes[1] === 0x00 &&
//     bytes[2] === 0x00 &&
//     bytes[3] === 0x18 &&
//     bytes[4] === 0x66 &&
//     bytes[5] === 0x74 &&
//     bytes[6] === 0x79 &&
//     bytes[7] === 0x70
//   )
// }
</script>

<template>
  <div class="upload-item">
    <div class="item-thumbnail">
      <img
        v-if="firstFrameImageURL.length"
        :id="`${props.file.uploadInfo.id}`"
        class="thumbnail-data"
        :src="firstFrameImageURL"
        alt=""
      />
      <img
        v-else
        class="thumbnail-nodata"
        src="@/assets/icons/icon_upload_nodata.png"
        alt=""
      />
    </div>
    <div class="item-info">
      <el-tooltip
        :content="fileName"
        placement="bottom-start"
        popper-class="video-tooltip"
      >
        <span class="info-title">{{ fileName }}</span>
      </el-tooltip>
      <span v-if="!isShowWarning" class="info-subTitle info-size">{{
        fileSize
      }}</span>
      <span v-else class="info-subTitle info-warning">{{
        warningContent
      }}</span>
    </div>
    <div class="item-delete" @click.stop="deleteEvent">
      <img src="@/assets/icons/icon_upload_delete.png" alt="" />
    </div>
  </div>
</template>

数据状态管理 Pinia

vue 复制代码
// src/stores/index.ts ----------- 

import { createPinia } from 'pinia'

const store = createPinia() 

export default store
vue 复制代码
// src/stores/modules/upload.ts ---------

import { defineStore } from 'pinia'
import { uploadImage, upload } from '@/utils/sup-new'
import { $message } from '@/utils/message'

export const useUploadStore = defineStore('upload', {
  state: (): UploadState => {
    return <UploadState>{
      preUploadList: [],
      uploadList: [],
    }
  },
  getters: {},
  actions: {
    // 判断一个文件名称是否包含指定特殊字符
    handleIsValidFileName(name: string): boolean {
      // 匹配规则:以.mp4结尾,且字符串只包含数字、中文、字母、-、_、(、)、(、)
      const pattern = /^[\u4e00-\u9fa5\w\d\-_()()]*\.mp4$/
      return pattern.test(name)
    },
    // 获取视频文件尺寸信息
    handleGetFileSize(size: number, isUnit = false) {
      const KB = 1024
      const MB = 1024 * 1024
      const GB = 1024 * 1024 * 1024
      if (!size) return '0'
      if (size < MB) return (size / KB).toFixed(1) + (isUnit ? 'KB' : '')
      if (size < GB) return (size / MB).toFixed(1) + (isUnit ? 'MB' : '')
      return (size / GB).toFixed(1) + (isUnit ? 'G' : '')
    },
    // 上传图片文件
    handleUploadImageFile(file: VideoItem, duration: number) {
      uploadImage(file, (res) => {
        ...
      })
    },
    // 取消上传
    handleCancelVideoFiles(files: VideoItem[]) {
      if (files.length < 1) return
      files.forEach((item) => {
        if (item.uploadInfo && item.uploadInfo.source) {
          item.uploadInfo.source.cancel()
          item.uploadInfo.source = null
        }
      })
    },
    // 上传视频文件
    handleUploadVideFiles(files: VideoItem[]) {
      if (files.length < 1) return
      if (!this.isHaveInValidFiles) return
      ...
    },
  }
})

参考链接

版权声明

原文作者苜蓿鬼仙(苜蓿、jijiucheng)

原文链接GitHub.io - 苜蓿鬼仙 - 【前端】Vue3 获取视频首帧和时长

发表日期:2024/04/19 23:00:00

更新日期:2024/04/19 23:00:00

相关推荐
逐·風2 小时前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#
Devil枫2 小时前
Vue 3 单元测试与E2E测试
前端·vue.js·单元测试
尚梦3 小时前
uni-app 封装刘海状态栏(适用小程序, h5, 头条小程序)
前端·小程序·uni-app
GIS程序媛—椰子3 小时前
【Vue 全家桶】6、vue-router 路由(更新中)
前端·vue.js
前端青山4 小时前
Node.js-增强 API 安全性和性能优化
开发语言·前端·javascript·性能优化·前端框架·node.js
毕业设计制作和分享4 小时前
ssm《数据库系统原理》课程平台的设计与实现+vue
前端·数据库·vue.js·oracle·mybatis
程序媛小果4 小时前
基于java+SpringBoot+Vue的旅游管理系统设计与实现
java·vue.js·spring boot
从兄5 小时前
vue 使用docx-preview 预览替换文档内的特定变量
javascript·vue.js·ecmascript
凉辰5 小时前
设计模式 策略模式 场景Vue (技术提升)
vue.js·设计模式·策略模式
清灵xmf6 小时前
在 Vue 中实现与优化轮询技术
前端·javascript·vue·轮询