{{
isDragEnter ? "拖动视频文件到这里" : "拖动视频文件到这里或"
}}
选择文件上传
导读: 最近在做前端工作项目中做了一个功能,要求可以从本地选取或拖拽视频文件,校验是否是 mp4 文件,并执行相关上传操作。 由于还是前端小白,此处动动手指,敲敲键盘,记录一下~ 效果图 实现思路 该功能依托的环境是 Vue 3.4.21、Element Plus 2.5.6、Pinia 2.0.32; 此处的弹框是借助了 Element Plus 的 el-dialog 小组件; 此处的从本地选择文件和拖拽文件功能是借助了 Element Plus 的 el-upload 小组件; 当选择完文件会进行一波文件的基础信息校验,只有当文件符合规则再添加到对应的数组中去; 紧接着在每个文件 item 的 onMounted 生命周期钩子函数中去处理本地视频的相关信息; 通过手动创建 video 视频标签元素,播放视频文件: 如果可以播放 oncanplay 方法有回调,即代表视频可以播放,进而通过 canvas 绘制首帧图片; 即使可以播放,实际上也要进行筛选,因为音频文件通过 video 标签实际上也是可以播放的,只不过没有视频轨道而已,所以就需要进行过滤; 如果不可以播放,则在 onerror 方法中会有对应回调信息,代表当前文件不支持播放。 在 oncanplay 方法回调中可以通过 video.duration 获取视频时长; 通过 canvas 的绘制获取首帧图片。 实现代码(核心代码) vue 复制代码 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); } } }); 实现代码(完整) 上传弹框界面 vue 复制代码 // UploadDialog.vue ------------------ 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 // 是否将文件拖拽入列表区 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; }; 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" > 上传视频 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" > id="upload-drag-tips" class="upload-drag-tips" @dragover="handleTipsDragOver" > class="tips-icon-video" src="@/assets/icons/icon_upload_video.png" alt="" /> {{ isDragEnter ? "拖动视频文件到这里" : "拖动视频文件到这里或" }} 选择文件上传 支持 MP4 格式文件,单个最大 1GB![]()
![]()
{{ warningContent }}
上传弹框单个文件 Item
vue
复制代码
// UploadItem.vue -------------
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
// )
// }
v-if="firstFrameImageURL.length"
:id="`${props.file.uploadInfo.id}`"
class="thumbnail-data"
:src="firstFrameImageURL"
alt=""
/>
v-else
class="thumbnail-nodata"
src="@/assets/icons/icon_upload_nodata.png"
alt=""
/>
:content="fileName" placement="bottom-start" popper-class="video-tooltip" > {{ fileName }}
{{
fileSize
}}
{{
warningContent
}}
![]()
数据状态管理 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
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
...
},
}
})
参考链接
Vue 官网
Pinia 官网
Element Plus 官网
el-dialog
el-upload
W3school 官网
HTM - video 标签
HTML - canvas 绘图
版权声明
原文作者 :苜蓿鬼仙(苜蓿、jijiucheng)
原文链接 :GitHub.io - 苜蓿鬼仙 - 【前端】Vue3 获取视频首帧和时长
发表日期:2024/04/19 23:00:00
更新日期:2024/04/19 23:00:00
GitHub :GitHub - jijiucheng
个人博客 :GitHub.io - 苜蓿鬼仙
小专栏 :小专栏 - 苜蓿鬼仙
掘金 :掘金 - 苜蓿鬼仙
微博 :微博 - 苜蓿鬼仙
公众号 :[微信 - 苜蓿小站](#微信 - 苜蓿小站 "#")
小程序 :[微信 - 苜蓿小站](#微信 - 苜蓿小站 "#")