feat(material): remove video cover extraction and simplify upload API
- Remove extractVideoCoverOptional function and related video cover processing - Update MaterialService.uploadFile method signature to remove coverBase64 parameter - Simplify uploadAndIdentifyVideo function by removing cover generation logic - Remove loading indicator from VideoSelector component during video preview - Add presignGetUrlWithProcess method to FileClient interface for processed file URLs - Add logging support to S3FileClient implementation
This commit is contained in:
@@ -6,22 +6,6 @@ import { MaterialService } from './material'
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
/**
|
||||
* 从视频中提取封面(可选操作)
|
||||
*/
|
||||
async function extractVideoCoverOptional(file) {
|
||||
try {
|
||||
const { extractVideoCover } = await import('@/utils/video-cover')
|
||||
const cover = await extractVideoCover(file, {
|
||||
maxWidth: 800,
|
||||
quality: 0.8
|
||||
})
|
||||
return cover.base64
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行人脸识别并返回结果
|
||||
*/
|
||||
@@ -103,9 +87,7 @@ export async function identifyUploadedVideo(videoFile) {
|
||||
* 上传视频并识别
|
||||
*/
|
||||
export async function uploadAndIdentifyVideo(file) {
|
||||
const coverBase64 = await extractVideoCoverOptional(file)
|
||||
|
||||
const uploadRes = await MaterialService.uploadFile(file, 'digital_human', coverBase64, null, null)
|
||||
const uploadRes = await MaterialService.uploadFile(file, 'digital_human', null, null)
|
||||
if (uploadRes.code !== 0) {
|
||||
throw new Error(uploadRes.msg || '上传失败')
|
||||
}
|
||||
|
||||
@@ -65,12 +65,11 @@ export const MaterialService = {
|
||||
* 上传文件
|
||||
* @param {File} file - 文件对象
|
||||
* @param {string} fileCategory - 文件分类(video/generate/audio/mix/voice)
|
||||
* @param {string} coverBase64 - 视频封面 base64(可选,data URI 格式)
|
||||
* @param {number} duration - 视频时长(秒,可选,自动获取)
|
||||
* @param {number} groupId - 分组编号(可选)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async uploadFile(file, fileCategory, coverBase64 = null, duration = null, groupId = null) {
|
||||
async uploadFile(file, fileCategory, duration = null, groupId = null) {
|
||||
if (duration === null && file.type.startsWith('video/')) {
|
||||
duration = await getVideoDuration(file);
|
||||
}
|
||||
@@ -83,10 +82,6 @@ export const MaterialService = {
|
||||
formData.append('duration', duration.toString());
|
||||
}
|
||||
|
||||
if (coverBase64) {
|
||||
formData.append('coverBase64', coverBase64)
|
||||
}
|
||||
|
||||
if (groupId !== null) {
|
||||
formData.append('groupId', groupId.toString())
|
||||
}
|
||||
|
||||
@@ -251,12 +251,10 @@ const handlePreview = async (video) => {
|
||||
}
|
||||
|
||||
// 显示加载提示
|
||||
const hideLoading = message.loading('正在获取视频地址...', 0)
|
||||
|
||||
try {
|
||||
// 调用后端API获取带签名的视频播放URL
|
||||
const res = await MaterialService.getVideoPlayUrl(video.id)
|
||||
hideLoading()
|
||||
|
||||
if (res.code === 0 && res.data) {
|
||||
previewVideo.value = video
|
||||
@@ -266,7 +264,6 @@ const handlePreview = async (video) => {
|
||||
message.error(res.msg || '获取视频地址失败')
|
||||
}
|
||||
} catch (error) {
|
||||
hideLoading()
|
||||
console.error('获取视频播放URL失败:', error)
|
||||
message.error('获取视频地址失败,请重试')
|
||||
}
|
||||
|
||||
@@ -144,7 +144,6 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UploadOutlined, FileOutlined, LoadingOutlined, CheckCircleOutlined, CheckOutlined } from '@ant-design/icons-vue'
|
||||
import { isVideoFile, extractVideoCover } from '@/utils/video-cover'
|
||||
import { getFileName, getFileSize, formatFileSize } from '@/utils/file'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
|
||||
@@ -153,10 +152,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
uploading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fileCategory: {
|
||||
type: String,
|
||||
default: 'video'
|
||||
@@ -174,8 +169,6 @@ const { upload } = useUpload()
|
||||
|
||||
// 数据
|
||||
const fileList = ref([])
|
||||
const fileCoverMap = ref(new Map())
|
||||
const DEFAULT_FILE_CATEGORY = 'video'
|
||||
const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac'
|
||||
|
||||
// 常量
|
||||
@@ -216,7 +209,6 @@ const getTotalSize = () => {
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
fileList.value = []
|
||||
fileCoverMap.value.clear()
|
||||
resetUploadState()
|
||||
}
|
||||
})
|
||||
@@ -266,7 +258,7 @@ const handleBeforeUpload = (file) => {
|
||||
}
|
||||
|
||||
// 文件列表变化
|
||||
const handleFileChange = async (info) => {
|
||||
const handleFileChange = (info) => {
|
||||
const { file, fileList: newFileList } = info
|
||||
|
||||
if (file && file.status !== 'uploading') {
|
||||
@@ -278,25 +270,6 @@ const handleFileChange = async (info) => {
|
||||
return item
|
||||
})
|
||||
.filter(item => item.status !== 'removed')
|
||||
|
||||
// 如果是视频文件,自动提取封面
|
||||
const fileObj = file.file || file.originFileObj || file
|
||||
if (fileObj instanceof File && isVideoFile(fileObj)) {
|
||||
const fileKey = file.uid || fileObj.name
|
||||
|
||||
if (!fileCoverMap.value.has(fileKey)) {
|
||||
try {
|
||||
const coverResult = await extractVideoCover(fileObj, {
|
||||
maxWidth: 800,
|
||||
quality: 0.8
|
||||
})
|
||||
fileCoverMap.value.set(fileKey, coverResult.base64)
|
||||
console.log(`[封面提取成功] ${fileObj.name}`)
|
||||
} catch (error) {
|
||||
console.warn(`[封面提取失败] ${fileObj.name}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,9 +280,6 @@ const handleRemove = (fileItem) => {
|
||||
(getFileName(item) === getFileName(fileItem))
|
||||
)
|
||||
if (index > -1) {
|
||||
const removed = fileList.value[index]
|
||||
const fileKey = removed.uid || getFileName(removed)
|
||||
fileCoverMap.value.delete(fileKey)
|
||||
fileList.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
@@ -327,71 +297,65 @@ const handleConfirm = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 提取文件对象和对应的封面
|
||||
const filesWithCover = fileList.value
|
||||
// 提取文件对象
|
||||
const files = fileList.value
|
||||
.map(item => {
|
||||
const fileObj = item.file || item.originFileObj || item
|
||||
if (!(fileObj instanceof File)) return null
|
||||
|
||||
const fileKey = item.uid || fileObj.name
|
||||
return {
|
||||
file: fileObj,
|
||||
coverBase64: fileCoverMap.value.get(fileKey) || null
|
||||
}
|
||||
return fileObj
|
||||
})
|
||||
.filter(item => item !== null)
|
||||
|
||||
if (filesWithCover.length === 0) {
|
||||
if (files.length === 0) {
|
||||
message.error('无法获取文件对象,请重新选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 开始上传
|
||||
await performUpload(filesWithCover)
|
||||
await performUpload(files)
|
||||
}
|
||||
|
||||
// 执行上传
|
||||
const performUpload = async (filesWithCover) => {
|
||||
const performUpload = async (files) => {
|
||||
uploading.value = true
|
||||
totalUploadCount.value = filesWithCover.length
|
||||
totalUploadCount.value = files.length
|
||||
currentUploadIndex.value = 0
|
||||
completedFiles.value = []
|
||||
|
||||
// 使用传入的fileCategory,如果没有则使用默认值
|
||||
const category = props.fileCategory || DEFAULT_FILE_CATEGORY
|
||||
// 使用传入的fileCategory
|
||||
const category = props.fileCategory || 'video'
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (let i = 0; i < filesWithCover.length; i++) {
|
||||
const fileWithCover = filesWithCover[i]
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
currentUploadIndex.value = i + 1
|
||||
currentFileName.value = fileWithCover.file.name
|
||||
currentFileSize.value = fileWithCover.file.size
|
||||
currentFileName.value = file.name
|
||||
currentFileSize.value = file.size
|
||||
currentFileUploaded.value = 0
|
||||
currentFileProgress.value = 0
|
||||
|
||||
try {
|
||||
await upload(fileWithCover.file, {
|
||||
await upload(file, {
|
||||
fileCategory: category,
|
||||
groupId: props.groupId,
|
||||
coverBase64: fileWithCover.coverBase64,
|
||||
onProgress: (progress) => {
|
||||
currentFileProgress.value = progress
|
||||
currentFileUploaded.value = Math.round((progress / 100) * currentFileSize.value)
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('文件上传成功:', fileWithCover.file.name)
|
||||
console.log('文件上传成功:', file.name)
|
||||
successCount++
|
||||
completedFiles.value.push({
|
||||
name: fileWithCover.file.name,
|
||||
name: file.name,
|
||||
success: true
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('文件上传失败:', fileWithCover.file.name, error)
|
||||
console.error('文件上传失败:', file.name, error)
|
||||
failCount++
|
||||
completedFiles.value.push({
|
||||
name: fileWithCover.file.name,
|
||||
name: file.name,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
@@ -400,7 +364,7 @@ const performUpload = async (filesWithCover) => {
|
||||
console.error('上传异常:', error)
|
||||
failCount++
|
||||
completedFiles.value.push({
|
||||
name: fileWithCover.file.name,
|
||||
name: file.name,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ function getVideoDuration(file) {
|
||||
* @typedef {Object} UploadOptions
|
||||
* @property {string} fileCategory - 文件分类(video/voice/audio/image/digital_human)
|
||||
* @property {number|null} groupId - 分组编号(可选,仅素材库模块使用)
|
||||
* @property {string|null} coverBase64 - 封面base64(可选)
|
||||
* @property {number|null} duration - 视频时长(可选,视频文件自动获取)
|
||||
* @property {Function} onProgress - 进度回调(可选)
|
||||
* @property {Function} onStart - 开始回调(可选)
|
||||
@@ -132,7 +131,6 @@ export function useUpload() {
|
||||
const {
|
||||
fileCategory,
|
||||
groupId = null,
|
||||
coverBase64 = null,
|
||||
duration: inputDuration,
|
||||
onProgress,
|
||||
onStart,
|
||||
@@ -193,7 +191,6 @@ export function useUpload() {
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
groupId,
|
||||
coverBase64,
|
||||
duration
|
||||
})
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* 视频封面提取工具
|
||||
* 使用 HTML5 Video API 在浏览器中提取视频第一帧作为封面
|
||||
*/
|
||||
|
||||
export interface VideoCoverOptions {
|
||||
/** 封面最大宽度(默认 800px) */
|
||||
maxWidth?: number
|
||||
/** 封面质量 0-1(默认 0.8) */
|
||||
quality?: number
|
||||
/** 提取时间点(秒),默认取视频的 1/4 位置或第1秒(取较小值) */
|
||||
seekTime?: number
|
||||
/** 超时时间(毫秒,默认 10秒) */
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface VideoCoverResult {
|
||||
/** 封面 base64 数据(data URI 格式) */
|
||||
base64: string
|
||||
/** 封面宽度 */
|
||||
width: number
|
||||
/** 封面高度 */
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为视频文件
|
||||
* @param file 文件对象
|
||||
* @returns 是否为视频文件
|
||||
*/
|
||||
export function isVideoFile(file: File): boolean {
|
||||
return file.type.startsWith('video/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取视频封面
|
||||
* @param file 视频文件
|
||||
* @param options 提取选项
|
||||
* @returns Promise<VideoCoverResult> 封面 base64 数据
|
||||
*/
|
||||
export function extractVideoCover(
|
||||
file: File,
|
||||
options: VideoCoverOptions = {}
|
||||
): Promise<VideoCoverResult> {
|
||||
const {
|
||||
maxWidth = 800,
|
||||
quality = 0.8,
|
||||
seekTime,
|
||||
timeout = 10000
|
||||
} = options
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.muted = true
|
||||
video.playsInline = true
|
||||
video.setAttribute('webkit-playsinline', 'true')
|
||||
video.setAttribute('playsinline', 'true')
|
||||
|
||||
const url = URL.createObjectURL(file)
|
||||
video.src = url
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
URL.revokeObjectURL(url)
|
||||
video.src = ''
|
||||
video.remove()
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
// 设置超时
|
||||
timeoutId = setTimeout(() => {
|
||||
handleError(new Error('提取封面超时'))
|
||||
}, timeout)
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
try {
|
||||
// 计算提取时间点
|
||||
let targetTime = seekTime
|
||||
if (targetTime === undefined) {
|
||||
// 默认取视频的 1/4 位置或第1秒(取较小值),避免黑屏
|
||||
targetTime = Math.min(1, video.duration / 4)
|
||||
}
|
||||
// 确保时间在有效范围内
|
||||
targetTime = Math.max(0, Math.min(targetTime, video.duration - 0.1))
|
||||
video.currentTime = targetTime
|
||||
} catch (error) {
|
||||
handleError(new Error('设置视频时间点失败'))
|
||||
}
|
||||
}
|
||||
|
||||
video.onseeked = () => {
|
||||
try {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const videoWidth = video.videoWidth
|
||||
const videoHeight = video.videoHeight
|
||||
|
||||
if (videoWidth === 0 || videoHeight === 0) {
|
||||
handleError(new Error('无法获取视频尺寸'))
|
||||
return
|
||||
}
|
||||
|
||||
// 计算缩放比例,限制最大宽度
|
||||
const scale = Math.min(1, maxWidth / videoWidth)
|
||||
canvas.width = Math.floor(videoWidth * scale)
|
||||
canvas.height = Math.floor(videoHeight * scale)
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
handleError(new Error('无法获取 Canvas 上下文'))
|
||||
return
|
||||
}
|
||||
|
||||
// 绘制视频帧到 Canvas
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 转换为 base64(JPEG 格式,指定质量)
|
||||
const base64 = canvas.toDataURL('image/jpeg', quality)
|
||||
|
||||
cleanup()
|
||||
|
||||
resolve({
|
||||
base64,
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error instanceof Error ? error : new Error('提取封面失败'))
|
||||
}
|
||||
}
|
||||
|
||||
video.onerror = () => {
|
||||
handleError(new Error('视频加载失败'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量提取视频封面
|
||||
* @param files 文件数组
|
||||
* @param options 提取选项
|
||||
* @returns Promise<Map<string, VideoCoverResult>> 文件到封面的映射(key: 文件名或 uid)
|
||||
*/
|
||||
export async function extractVideoCovers(
|
||||
files: File[],
|
||||
options: VideoCoverOptions = {}
|
||||
): Promise<Map<string, VideoCoverResult>> {
|
||||
const result = new Map<string, VideoCoverResult>()
|
||||
|
||||
// 并行提取所有视频封面
|
||||
const promises = files
|
||||
.filter(isVideoFile)
|
||||
.map(async (file) => {
|
||||
try {
|
||||
const cover = await extractVideoCover(file, options)
|
||||
result.set(file.name, cover)
|
||||
} catch (error) {
|
||||
console.warn(`[extractVideoCovers] 提取封面失败: ${file.name}`, error)
|
||||
// 提取失败不影响其他文件
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { InboxOutlined, SoundOutlined, LoadingOutlined } from '@ant-design/icons
|
||||
import { VoiceService } from '@/api/voice'
|
||||
import { MaterialService } from '@/api/material'
|
||||
import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman'
|
||||
import { extractVideoCover } from '@/utils/video-cover'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
|
||||
|
||||
@@ -391,19 +390,6 @@ const handleVideoUpload = async (file) => {
|
||||
try {
|
||||
uploadedVideo.value = await toDataURL(file)
|
||||
uploadedVideoFile.value = file // 保存文件对象
|
||||
|
||||
// 提取视频封面
|
||||
try {
|
||||
const cover = await extractVideoCover(file, {
|
||||
maxWidth: 800,
|
||||
quality: 0.8
|
||||
})
|
||||
uploadedVideoFile.value.coverBase64 = cover.base64 // 保存封面到文件对象
|
||||
} catch (coverError) {
|
||||
console.warn('视频封面提取失败:', coverError)
|
||||
// 封面提取失败不影响主流程
|
||||
}
|
||||
|
||||
message.success('视频上传成功')
|
||||
} catch (error) {
|
||||
console.error('视频上传失败:', error)
|
||||
@@ -513,14 +499,10 @@ const generateVideo = async () => {
|
||||
// 上传视频文件到后端
|
||||
const uploadVideoFile = async (file) => {
|
||||
try {
|
||||
// 获取封面base64
|
||||
const coverBase64 = file.coverBase64 || null
|
||||
|
||||
// 使用useUpload Hook上传文件
|
||||
const fileId = await upload(file, {
|
||||
fileCategory: 'video',
|
||||
groupId: null, // 数字人视频模块不使用groupId
|
||||
coverBase64,
|
||||
onStart: () => {},
|
||||
onProgress: () => {},
|
||||
onSuccess: (id) => {
|
||||
|
||||
@@ -375,7 +375,6 @@ async function handleCustomUpload(options) {
|
||||
const fileId = await upload(file, {
|
||||
fileCategory: 'voice',
|
||||
groupId: null,
|
||||
coverBase64: null,
|
||||
onSuccess: async function(id, fileUrl) {
|
||||
formData.fileId = id
|
||||
formData.fileUrl = fileUrl
|
||||
|
||||
@@ -152,8 +152,8 @@
|
||||
<!-- 预览图 -->
|
||||
<div class="material-item__preview">
|
||||
<img
|
||||
v-if="file.coverBase64"
|
||||
:src="file.coverBase64"
|
||||
v-if="file.previewUrl"
|
||||
:src="file.previewUrl"
|
||||
:alt="file.fileName"
|
||||
@error="handleImageError"
|
||||
loading="lazy"
|
||||
|
||||
@@ -159,8 +159,8 @@
|
||||
<template #item="{ element: candidate, index: cIndex }">
|
||||
<div class="candidate-thumb">
|
||||
<img
|
||||
v-if="getFileById(candidate.fileId)?.coverBase64"
|
||||
:src="getFileById(candidate.fileId).coverBase64"
|
||||
v-if="getFileById(candidate.fileId)?.previewUrl"
|
||||
:src="getFileById(candidate.fileId).previewUrl"
|
||||
/>
|
||||
<div v-else class="thumb-placeholder">
|
||||
<VideoCameraOutlined />
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
@dblclick="handleQuickConfirm(file)"
|
||||
>
|
||||
<div class="card-cover">
|
||||
<img v-if="file.coverBase64" :src="file.coverBase64" />
|
||||
<img v-if="file.previewUrl" :src="file.previewUrl" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user