// 视频转码任务封装,供上传接口和转码API复用 import { prisma } from '@/lib/prisma' import { transcodeQueue } from '@/lib/video-transcode-queue' import { processVideoTranscode } from '@/lib/video-processor' /** * 实际的转码任务处理函数 * * 注意:retryCount 由队列控制,外部一般不用直接传 */ export async function executeTranscodeTask( materialId: string, videoSourceUrl: string, needsCoverImage: boolean, retryCount: number = 0 ): Promise { const MAX_RETRIES = 3 try { console.log(`[Transcode] Starting task for material ${materialId} (attempt ${retryCount + 1}/${MAX_RETRIES})`) // 更新状态为处理中 await prisma.material.update({ where: { id: materialId }, data: { videoProcessingStatus: 'processing', transcodeLastError: null, } as any, }) // 执行转码 const result = await processVideoTranscode({ inputPath: videoSourceUrl, outputDir: './uploads/transcoded', materialId, needsCoverImage, onProgress: (progress, stage) => { console.log(`[Transcode] Material ${materialId} - ${stage}: ${progress}%`) // 更新转码队列的进度信息 transcodeQueue.updateProgress(materialId, progress, stage) }, }) // 查询当前素材,避免覆盖用户手动上传的预览图 const currentMaterial = await prisma.material.findUnique({ where: { id: materialId }, select: { previewUrl: true, } as any, }) // 如果用户没有自己上传预览图,而转码生成了封面,则同步一份到 previewUrl const nextPreviewUrl = (currentMaterial as any)?.previewUrl && (currentMaterial as any).previewUrl.trim() !== '' ? (currentMaterial as any).previewUrl : result.coverUrl || null // 更新数据库 await prisma.material.update({ where: { id: materialId }, data: { videoProcessingStatus: 'done', videoPreview540Url: result.preview540Url, videoPreviewClipUrl: result.clipUrl, videoCoverUrl: result.coverUrl || undefined, videoDuration: result.duration, transcodeLastError: null, transcodeAttempts: retryCount + 1, // 同步预览图字段:仅在原来没有 previewUrl 时才使用自动生成的封面 previewUrl: nextPreviewUrl || undefined, } as any, }) console.log(`[Transcode] Task completed for material ${materialId}`) } catch (error: any) { const errorMsg = error?.message || String(error) console.error(`[Transcode] Task failed for material ${materialId}:`, errorMsg) // 如果还有重试次数,重新入队 if (retryCount < MAX_RETRIES - 1) { console.log(`[Transcode] Retrying material ${materialId} (${retryCount + 1}/${MAX_RETRIES})`) await transcodeQueue.add( materialId, videoSourceUrl, needsCoverImage, (id, url, needsCover) => executeTranscodeTask(id, url, needsCover, retryCount + 1) ) } else { // 重试次数用尽,标记为失败 await prisma.material.update({ where: { id: materialId }, data: { videoProcessingStatus: 'failed', transcodeLastError: errorMsg.substring(0, 500), transcodeAttempts: retryCount + 1, } as any, }) console.error(`[Transcode] Task failed permanently for material ${materialId} after ${MAX_RETRIES} attempts`) } } } /** * 验证视频文件是否存在 */ async function validateVideoFile(videoSourceUrl: string): Promise { try { // 如果是 HTTP URL,跳过本地文件检查 if (videoSourceUrl.startsWith('http://') || videoSourceUrl.startsWith('https://')) { return true } const path = await import('path') const { access, constants } = await import('fs/promises') let localFilePath = videoSourceUrl // 如果是相对路径(以 /uploads/ 开头),转换为绝对路径 if (videoSourceUrl.startsWith('/uploads/')) { const uploadDir = process.env.UPLOAD_DIR || './uploads' const absoluteUploadDir = path.isAbsolute(uploadDir) ? uploadDir : path.join(process.cwd(), uploadDir) localFilePath = path.join(absoluteUploadDir, videoSourceUrl.replace('/uploads/', '')) } else if (!path.isAbsolute(videoSourceUrl)) { // 相对路径:基于当前工作目录 localFilePath = path.join(process.cwd(), videoSourceUrl) } // 检查文件是否存在 await access(localFilePath, constants.F_OK) console.log(`[TranscodeTask] ✅ Video file exists: ${localFilePath}`) return true } catch (error) { console.error(`[TranscodeTask] ❌ Video file not found: ${videoSourceUrl}`, error) return false } } /** * 入队一个视频转码任务 */ export async function enqueueVideoTranscodeTask( materialId: string, videoSourceUrl: string, needsCoverImage: boolean ): Promise { // 验证视频文件是否存在 const fileExists = await validateVideoFile(videoSourceUrl) if (!fileExists) { throw new Error(`视频文件不存在: ${videoSourceUrl}`) } console.log(`[TranscodeTask] 📝 Enqueuing transcode task: materialId=${materialId}, videoSourceUrl=${videoSourceUrl}, needsCoverImage=${needsCoverImage}`) // 注意:这里不等待整个转码流程完成,只负责把任务丢进队列即可 // 队列内部会在任务开始/结束时更新数据库状态和进度 transcodeQueue .add(materialId, videoSourceUrl, needsCoverImage, executeTranscodeTask) .then(() => { console.log(`[TranscodeTask] ✅ Transcode task finished in queue: materialId=${materialId}`) }) .catch((error) => { // 这里一般不会抛出未处理错误,因为 executeTranscodeTask 内部已经负责更新失败状态 console.error(`[TranscodeTask] ❌ Transcode task error in queue for material ${materialId}:`, error) }) console.log(`[TranscodeTask] ✅ Transcode task enqueued successfully (fire-and-forget): materialId=${materialId}`) }