170 lines
6.0 KiB
TypeScript
170 lines
6.0 KiB
TypeScript
// 视频转码任务封装,供上传接口和转码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<void> {
|
||
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<boolean> {
|
||
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<void> {
|
||
// 验证视频文件是否存在
|
||
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}`)
|
||
}
|
||
|
||
|