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:
2026-03-04 22:37:31 +08:00
parent 07579e27e9
commit 27d1c53b49
23 changed files with 576 additions and 361 deletions

View File

@@ -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 || '上传失败')
}

View File

@@ -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())
}

View File

@@ -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('获取视频地址失败,请重试')
}

View File

@@ -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
})
}

View File

@@ -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
})

View File

@@ -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)
// 转换为 base64JPEG 格式,指定质量)
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
}

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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"

View File

@@ -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 />

View File

@@ -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>