/** * 视频转码 API * * 处理视频素材的转码任务: * - 生成 540P 预览视频 * - 生成 10 秒超小预览片段 * - 从视频中截取封面图(如果用户没有上传) */ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { processVideoTranscode } from '@/lib/video-processor' import { transcodeQueue } from '@/lib/video-transcode-queue' import { tmpdir } from 'os' import { join } from 'path' export const dynamic = 'force-dynamic' export const maxDuration = 3600 // 1 hour timeout for transcode /** * GET /api/materials/transcode?materialId=xxx * 查询视频转码状态 */ export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url) const materialId = searchParams.get('materialId') if (!materialId) { return NextResponse.json( { error: '缺少素材ID' }, { status: 400 } ) } const material = await prisma.material.findUnique({ where: { id: materialId }, select: { id: true, videoProcessingStatus: true, videoPreview540Url: true, videoPreviewClipUrl: true, videoCoverUrl: true, videoDuration: true, }, }) if (!material) { return NextResponse.json( { error: '素材不存在' }, { status: 404 } ) } return NextResponse.json({ materialId: material.id, status: material.videoProcessingStatus || 'pending', hasPreview540: !!material.videoPreview540Url, hasClip: !!material.videoPreviewClipUrl, hasCover: !!material.videoCoverUrl, duration: material.videoDuration, }) } catch (error) { console.error('[Transcode] GET error:', error) return NextResponse.json( { error: '查询转码状态失败' }, { status: 500 } ) } } /** * POST /api/materials/transcode * 触发视频转码任务 */ export async function POST(req: NextRequest) { try { const body = await req.json().catch(() => ({})) const { materialId } = body if (!materialId) { return NextResponse.json( { error: '缺少素材ID' }, { status: 400 } ) } // 获取素材信息 const material = await prisma.material.findUnique({ where: { id: materialId }, select: { id: true, // @ts-ignore - materialKind 字段已在 schema 中定义 materialKind: true, videoSourceUrl: true, videoCoverUrl: true, videoProcessingStatus: true, materialType: true, }, }) if (!material) { return NextResponse.json( { error: '素材不存在' }, { status: 404 } ) } // 检查是否为视频素材 const materialKind = (material as any).materialKind || '' const isVideo = materialKind.toUpperCase() === 'VIDEO' || material.materialType === '视频' if (!isVideo) { return NextResponse.json( { error: '此素材不是视频类型' }, { status: 400 } ) } // 检查是否已有源视频 if (!material.videoSourceUrl) { return NextResponse.json( { error: '未找到源视频文件' }, { status: 400 } ) } // 检查转码状态 const forceRetry = body.forceRetry === true // 允许强制重新转码 if (material.videoProcessingStatus === 'processing' && !forceRetry) { return NextResponse.json( { error: '转码任务正在进行中', materialId }, { status: 409 } ) } if (material.videoProcessingStatus === 'done' && !forceRetry) { return NextResponse.json( { message: '转码已完成', materialId }, { status: 200 } ) } // 如果强制重试,重置状态 if (forceRetry && material.videoProcessingStatus === 'done') { console.log(`[Transcode] Force retry requested for material ${materialId}`) } // 更新状态为 processing await prisma.material.update({ where: { id: materialId }, data: { videoProcessingStatus: 'processing', }, }) // 异步执行转码任务(不阻塞响应) // needsCoverImage: 如果用户已经上传了封面图,则不需要从视频中截取 const needsCoverImage = !material.videoCoverUrl // 使用队列管理转码任务,避免并发过多导致系统过载 transcodeQueue.add(materialId, material.videoSourceUrl, needsCoverImage, processVideoTranscodeTask).catch( (error) => { console.error(`[Transcode] Task failed for material ${materialId}:`, error) // 更新状态为 failed prisma.material .update({ where: { id: materialId }, data: { videoProcessingStatus: 'failed', }, }) .catch((dbError) => { console.error(`[Transcode] Failed to update status to failed:`, dbError) }) } ) return NextResponse.json({ message: '转码任务已启动', materialId, status: 'processing', }) } catch (error) { console.error('[Transcode] API error:', error) return NextResponse.json( { error: '转码任务启动失败' }, { status: 500 } ) } } /** * 执行转码任务(异步,带重试机制) */ async function processVideoTranscodeTask( materialId: string, videoSourceUrl: string, needsCoverImage: boolean, retryCount: number = 0 ): Promise { const MAX_RETRIES = parseInt(process.env.VIDEO_TRANSCODE_MAX_RETRIES || '2', 10) try { console.log(`[Transcode] Starting transcode task for material ${materialId}${retryCount > 0 ? ` (retry ${retryCount}/${MAX_RETRIES})` : ''}...`) const outputDir = tmpdir() const result = await processVideoTranscode({ inputPath: videoSourceUrl, outputDir, materialId, needsCoverImage, }) // 更新数据库 const updateData: any = { videoProcessingStatus: 'done', videoPreview540Url: result.preview540Url, videoPreviewClipUrl: result.clipUrl, videoDuration: result.duration, } // 如果用户没有上传封面图,使用自动截取的封面图 if (needsCoverImage && result.coverUrl) { updateData.videoCoverUrl = result.coverUrl // 同时更新 previewUrl(如果当前是占位符) const material = await prisma.material.findUnique({ where: { id: materialId }, select: { previewUrl: true }, }) if (material?.previewUrl?.startsWith('data:image')) { updateData.previewUrl = result.coverUrl } } await prisma.material.update({ where: { id: materialId }, data: updateData, }) console.log(`[Transcode] Transcode task completed for material ${materialId}`) } catch (error) { console.error(`[Transcode] Transcode task failed for material ${materialId}:`, error) // 重试机制 if (retryCount < MAX_RETRIES) { const delay = Math.min(1000 * Math.pow(2, retryCount), 30000) // 指数退避,最多30秒 console.log(`[Transcode] Retrying transcode task for material ${materialId} after ${delay}ms...`) await new Promise((resolve) => setTimeout(resolve, delay)) // 递归重试 return processVideoTranscodeTask(materialId, videoSourceUrl, needsCoverImage, retryCount + 1) } else { // 达到最大重试次数,标记为失败 console.error(`[Transcode] Max retries (${MAX_RETRIES}) reached for material ${materialId}, marking as failed`) throw error } } }