导读: 最近在做前端工作项目中做了一个功能,要求可以从本地选取或拖拽视频文件,校验是否是 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
<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
GitHub :GitHub - jijiucheng
个人博客 :GitHub.io - 苜蓿鬼仙
小专栏 :小专栏 - 苜蓿鬼仙
掘金 :掘金 - 苜蓿鬼仙
微博 :微博 - 苜蓿鬼仙
公众号 :[微信 - 苜蓿小站](#微信 - 苜蓿小站 "#")
小程序 :[微信 - 苜蓿小站](#微信 - 苜蓿小站 "#")