diff --git a/config/unplugin/auto-import.ts b/config/unplugin/auto-import.ts index 3b4b32f..e80028b 100644 --- a/config/unplugin/auto-import.ts +++ b/config/unplugin/auto-import.ts @@ -16,7 +16,20 @@ export function configAutoImport() { '@vueuse/core', { dayjs: [['default', 'dayjs']], - 'lodash-es': ['cloneDeep', 'omit', 'pick', 'union', 'uniq', 'isNumber', 'uniqBy', 'isEmpty', 'merge', 'debounce', 'isEqual'], + 'lodash-es': [ + 'cloneDeep', + 'omit', + 'pick', + 'union', + 'map', + 'uniq', + 'isNumber', + 'uniqBy', + 'isEmpty', + 'merge', + 'debounce', + 'isEqual', + ], '@/hooks': ['useModal'], }, ], diff --git a/src/utils/tools.ts b/src/utils/tools.ts index 02e2178..513ad2b 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -113,22 +113,155 @@ export function genRandomId() { export function formatFileSize(bytes: number): string { if (bytes === 0) return '0 Bytes'; - + const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } -export function getVideoDuration(file: any) { +// 修改函数以同时获取视频时长和首帧 +export function getVideoInfo(file: File): Promise<{ duration: number; firstFrame: string }> { return new Promise((resolve) => { const video = document.createElement('video'); video.preload = 'metadata'; - video.onloadedmetadata = function() { - window.URL.revokeObjectURL(video.src); - resolve(video.duration); + video.crossOrigin = 'anonymous'; // 避免跨域问题 + video.muted = true; // 静音,避免意外播放声音 + video.style.position = 'fixed'; // 确保视频元素在DOM中 + video.style.top = '-1000px'; // 但不可见 + document.body.appendChild(video); // 添加到DOM + + let hasResolved = false; + + // 先获取元数据(时长) + video.onloadedmetadata = function () { + // 视频时长 + const duration = video.duration; + + // 尝试将视频定位到非常小的时间点,确保有帧可捕获 + if (duration > 0) { + video.currentTime = Math.min(0.1, duration / 2); + } }; + + // 当视频定位完成后尝试捕获首帧 + video.onseeked = function () { + if (hasResolved) return; + + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + } + + // 清理 + window.URL.revokeObjectURL(video.src); + document.body.removeChild(video); + + // 返回结果 + hasResolved = true; + resolve({ + duration: video.duration, + firstFrame: canvas.toDataURL('image/jpeg', 0.9) // 提高质量 + }); + }; + + // 作为备选方案,监听loadeddata事件 + video.onloadeddata = function () { + if (hasResolved) return; + + // 尝试捕获帧 + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + } + + // 检查是否捕获到有效帧(非全黑) + const imageData = ctx?.getImageData(0, 0, canvas.width, canvas.height); + if (imageData) { + let isAllBlack = true; + for (let i = 0; i < imageData.data.length; i += 4) { + if (imageData.data[i] > 10 || imageData.data[i+1] > 10 || imageData.data[i+2] > 10) { + isAllBlack = false; + break; + } + } + + if (!isAllBlack) { + // 清理 + window.URL.revokeObjectURL(video.src); + document.body.removeChild(video); + + // 返回结果 + hasResolved = true; + resolve({ + duration: video.duration, + firstFrame: canvas.toDataURL('image/jpeg', 0.9) + }); + return; + } + } + + // 如果是全黑帧,尝试定位到0.1秒 + if (video.duration > 0) { + video.currentTime = 0.1; + } + }; + + // 设置视频源以触发加载 video.src = URL.createObjectURL(file); + + // 设置超时,防止长时间无响应 + setTimeout(() => { + if (!hasResolved) { + document.body.removeChild(video); + resolve({ + duration: 0, + firstFrame: '' + }); + } + }, 5000); // 5秒超时 }); -} \ No newline at end of file +} + +export const formatDuration = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const remainingSecondsAfterHours = seconds % 3600; + const minutes = Math.floor(remainingSecondsAfterHours / 60); + const remainingSeconds = Math.floor(remainingSecondsAfterHours % 60); + + if (hours > 0) { + if (remainingSecondsAfterHours === 0) { + return `${hours}小时`; + } + if (remainingSeconds === 0) { + return `${hours}小时${minutes}分`; + } + return `${hours}小时${minutes}分${remainingSeconds}秒`; + } else if (minutes > 0) { + if (remainingSeconds === 0) { + return `${minutes}分`; + } + return `${minutes}分${remainingSeconds}秒`; + } else { + return `${remainingSeconds}秒`; + } +}; + +export const formatUploadSpeed = (bytesPerSecond: number): string => { + if (bytesPerSecond < 1024) { + return `${bytesPerSecond.toFixed(2)} B/s`; + } else if (bytesPerSecond < 1024 * 1024) { + return `${(bytesPerSecond / 1024).toFixed(2)} KB/s`; + } else { + return `${(bytesPerSecond / (1024 * 1024)).toFixed(2)} MB/s`; + } +}; diff --git a/src/views/creative-generation-workshop/manuscript/components/edit-form/index.vue b/src/views/creative-generation-workshop/manuscript/components/edit-form/index.vue index 6a1d3fd..8523ec7 100644 --- a/src/views/creative-generation-workshop/manuscript/components/edit-form/index.vue +++ b/src/views/creative-generation-workshop/manuscript/components/edit-form/index.vue @@ -5,8 +5,9 @@ import CommonSelect from '@/components/common-select'; import { VueDraggable } from 'vue-draggable-plus'; import TextOverTips from '@/components/text-over-tips'; -import { formatFileSize, getVideoDuration } from '@/utils/tools'; +import { formatFileSize, getVideoInfo, formatDuration, formatUploadSpeed } from '@/utils/tools'; import { getProjectList } from '@/api/all/propertyMarketing'; +import { EnumManuscriptType } from '@/views/creative-generation-workshop/manuscript/list/constants.ts'; import { getImagePreSignedUrl, getVideoPreSignedUrl } from '@/api/all/common'; import icon1 from '@/assets/img/creative-generation-workshop/icon-close.png'; @@ -15,13 +16,27 @@ import icon1 from '@/assets/img/creative-generation-workshop/icon-close.png'; const FORM_RULES = { title: [{ required: true, message: '请输入标题' }], }; - -const ENUM_UPLOAD_STATUS = { +export const ENUM_UPLOAD_STATUS = { DEFAULT: 'default', UPLOADING: 'uploading', END: 'end', }; +export const INITIAL_VIDEO_INFO = { + name: '', + size: '', + percent: 0, + poster: '', + time: '', + uploadSpeed: '0 KB/s', + startTime: 0, + lastTime: 0, + lastLoaded: 0, + estimatedTime: 0, + poster: '', + uploadStatus: ENUM_UPLOAD_STATUS.DEFAULT, +}; + export default { name: 'ManuscriptForm', props: { @@ -33,124 +48,167 @@ export default { type: Object, default: () => FORM_RULES, }, + formData: { + type: Object, + default: () => ({}), + }, }, - emits: ['reValidate', 'change'], + emits: ['reValidate', 'change', 'update:modelValue', 'updateVideoInfo'], setup(props, { emit, expose }) { const formRef = ref(null); + const formData = ref({}); const uploadRef = ref(null); - const projects = ref([]); - const uploadStatus = ref(ENUM_UPLOAD_STATUS.DEFAULT); - const videoInfo = ref({ - name: '', - size: '', - percent: 0, - poster: '', - time: '', - }); function getFileExtension(filename) { const match = filename.match(/\.([^.]+)$/); return match ? match[1].toLowerCase() : ''; } - const isVideo = computed(() => true); + const isVideo = computed(() => formData.value.type === EnumManuscriptType.Video); + const setVideoInfo = (file) => { + formData.value.videoInfo.percent = 0; + formData.value.videoInfo.name = file.name; + formData.value.videoInfo.size = formatFileSize(file.size); + formData.value.videoInfo.startTime = Date.now(); + formData.value.videoInfo.lastTime = Date.now(); + formData.value.videoInfo.lastLoaded = 0; + formData.value.videoInfo.uploadSpeed = '0 KB/s'; + emit('updateVideoInfo', formData.value.videoInfo); - function formatDuration(seconds) { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = Math.floor(seconds % 60); - const milliseconds = Math.floor((seconds % 1) * 100); - return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}.${milliseconds - .toString() - .padStart(2, '0')}`; - } + getVideoInfo(file) + .then(({ duration, firstFrame }) => { + console.log({ duration, firstFrame }); + formData.value.videoInfo.poster = firstFrame; + formData.value.videoInfo.time = formatDuration(duration); + emit('updateVideoInfo', formData.value.videoInfo); + }) + .catch((error) => { + console.error('获取视频时长失败:', error); + }); + }; - const replaceVideo = async (option) => { + const uploadVideo = async (option) => { try { + formData.value.videoInfo.uploadStatus = ENUM_UPLOAD_STATUS.UPLOADING; + emit('updateVideoInfo', formData.value.videoInfo); + const { fileItem: { file }, } = option; - - uploadStatus.value = ENUM_UPLOAD_STATUS.UPLOADING; - // 重置进度为0 - videoInfo.value.percent = 0; - videoInfo.value.name = file.name; - videoInfo.value.size = formatFileSize(file.size); - - // const duration = getVideoDuration(file); - // videoInfo.value.time = formatDuration(duration); + setVideoInfo(file); const response = await getVideoPreSignedUrl({ suffix: getFileExtension(file.name) }); - const { file_name, upload_url } = response?.data; + const { file_name, upload_url, file_url } = response?.data; if (!upload_url) { throw new Error('未能获取有效的预签名上传地址'); } const blob = new Blob([file], { type: file.type }); - const res = await axios.put(upload_url, blob, { + await axios.put(upload_url, blob, { headers: { 'Content-Type': file.type }, onUploadProgress: (progressEvent) => { const percentCompleted = Math.round(progressEvent.progress * 100); - videoInfo.value.percent = percentCompleted; + formData.value.videoInfo.percent = percentCompleted; + + const currentTime = Date.now(); + const currentLoaded = progressEvent.loaded; + const totalSize = progressEvent.total; + + if (formData.value.videoInfo.lastLoaded === 0) { + formData.value.videoInfo.lastLoaded = currentLoaded; + formData.value.videoInfo.lastTime = currentTime; + return; + } + + const timeDiff = (currentTime - formData.value.videoInfo.lastTime) / 1000; + const bytesDiff = currentLoaded - formData.value.videoInfo.lastLoaded; + + // 避免频繁更新,至少间隔200ms计算一次速率 + if (timeDiff >= 0.2) { + const bytesPerSecond = bytesDiff / timeDiff; + formData.value.videoInfo.uploadSpeed = formatUploadSpeed(bytesPerSecond); + formData.value.videoInfo.lastLoaded = currentLoaded; + formData.value.videoInfo.lastTime = currentTime; + + // 计算预估剩余时间 + if (totalSize && bytesPerSecond > 0) { + const remainingBytes = totalSize - currentLoaded; + const remainingSeconds = remainingBytes / bytesPerSecond; + formData.value.videoInfo.estimatedTime = formatDuration(remainingSeconds); + } else { + formData.value.videoInfo.estimatedTime = 0; + } + } + + emit('updateVideoInfo', formData.value.videoInfo); }, }); - props.modelValue.files.push(file_name); + + formData.value.files.push(file_url); + onChange(); } finally { - uploadStatus.value = ENUM_UPLOAD_STATUS.END; + formData.value.videoInfo.uploadStatus = ENUM_UPLOAD_STATUS.END; + emit('updateVideoInfo', formData.value.videoInfo); } }; + + const onChange = () => { + emit('change', formData.value); + }; // 文件上传处理 - const handleUpload = (option) => { + const uploadImage = async (option) => { const { fileItem: { file }, } = option; - // 验证文件类型 - const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; - if (!allowedTypes.includes(file.type)) { - AMessage.error('只能上传图片文件!'); - return; - } - // 验证文件大小 (5MB) - const maxSize = 5 * 1024 * 1024; - if (file.size > maxSize) { - AMessage.error('图片大小不能超过5MB!'); - return; - } + // const maxSize = 5 * 1024 * 1024; + // if (file.size > maxSize) { + // AMessage.error('图片大小不能超过5MB!'); + // return; + // } // 验证文件数量 - if (props.modelValue.files?.length >= 18) { + if (formData.value.files?.length >= 18) { AMessage.error('最多只能上传18张图片!'); return; } - props.modelValue.files.push(URL.createObjectURL(file)); - emit('change'); + const response = await getImagePreSignedUrl({ suffix: getFileExtension(file.name) }); + const { file_name, upload_url, file_url } = response?.data; + + const blob = new Blob([file], { type: file.type }); + await axios.put(upload_url, blob, { + headers: { 'Content-Type': file.type }, + }); + + formData.value.files.push(file_url); + onChange(); }; // 删除文件 const handleDeleteFile = (index) => { - props.modelValue.files.splice(index, 1); - emit('change'); + formData.value.files.splice(index, 1); + onChange(); }; - // 获取项目列表 - const getProjects = async () => { - try { - const { code, data } = await getProjectList(); - if (code === 200) { - projects.value = data; - } - } catch (error) { - console.error('获取项目列表失败:', error); - } - }; + // // 获取项目列表 + // const getProjects = async () => { + // try { + // const { code, data } = await getProjectList(); + // if (code === 200) { + // projects.value = data; + // } + // } catch (error) { + // console.error('获取项目列表失败:', error); + // } + // }; // 表单验证 const validate = () => { return new Promise((resolve, reject) => { formRef.value?.validate((errors) => { if (errors) { - reject(props.modelValue); + reject(formData.value); } else { resolve(); } @@ -160,6 +218,7 @@ export default { // 重置表单 const resetForm = () => { + formData.value = {}; formRef.value?.resetFields?.(); formRef.value?.clearValidate?.(); }; @@ -170,13 +229,13 @@ export default { ref={uploadRef} action="/" draggable - custom-request={replaceVideo} + custom-request={uploadVideo} accept=".mp4,.webm,.ogg,.mov,.avi,.flv,.wmv,.mkv" show-file-list={false} > {{ 'upload-button': () => { - if (uploadStatus.value === ENUM_UPLOAD_STATUS.DEFAULT) { + if (formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.DEFAULT) { return (
- 视频大小:{videoInfo.value.size} - 视频时长:2.73s + 视频大小:{formData.value.videoInfo.size} + 视频时长:{formData.value.videoInfo.time}
) : (