/** * 视频转码 API * * 处理视频素材的转码任务: * - 生成 540P 预览视频 * - 生成 10 秒超小预览片段 * - 从视频中截取封面图(如果用户没有上传) */ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { isCurrentSuperAdmin } from '@/lib/admin-permissions' import { prisma } from '@/lib/prisma' import { enqueueVideoTranscodeJob, recoverStaleVideoTranscodeJobs } from '@/lib/video-transcode-service' export const dynamic = 'force-dynamic' export const maxDuration = 3600 // 1 hour timeout for transcode function getSessionUser(session: unknown) { return (session as any)?.user as { id?: string; role?: string } | undefined } function canManageTranscode(session: unknown, material: { userId?: string | null; createdByUserId?: string | null }) { const user = getSessionUser(session) if (!user?.id) return false if (user.role === 'ADMIN') return true return material.userId === user.id || material.createdByUserId === user.id } /** * GET /api/materials/transcode?materialId=xxx * 查询视频转码状态 */ export async function GET(req: NextRequest) { try { const session = await getServerSession(authOptions) if (!getSessionUser(session)?.id) { return NextResponse.json( { error: '请先登录' }, { status: 401 } ) } 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, userId: true, createdByUserId: true, videoProcessingStatus: true, videoPreview540Url: true, videoPreviewClipUrl: true, videoCoverUrl: true, videoDuration: true, }, }) if (!material) { return NextResponse.json( { error: '素材不存在' }, { status: 404 } ) } if (!canManageTranscode(session, material)) { return NextResponse.json( { error: '无权查看该素材的转码状态' }, { status: 403 } ) } 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 session = await getServerSession(authOptions) if (!getSessionUser(session)?.id) { return NextResponse.json( { error: '请先登录' }, { status: 401 } ) } 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, userId: true, createdByUserId: true, materialKind: true, videoSourceUrl: true, videoCoverUrl: true, videoProcessingStatus: true, materialType: true, }, }) if (!material) { return NextResponse.json( { error: '素材不存在' }, { status: 404 } ) } if (!canManageTranscode(session, material)) { return NextResponse.json( { error: '无权触发该素材的转码任务' }, { status: 403 } ) } // 检查是否为视频素材 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}`) } await enqueueVideoTranscodeJob({ materialId, videoSourceUrl: material.videoSourceUrl, needsCoverImage: !material.videoCoverUrl, forceRetry, }) return NextResponse.json({ message: '转码任务已启动', materialId, status: 'pending', }) } catch (error) { console.error('[Transcode] API error:', error) return NextResponse.json( { error: '转码任务启动失败' }, { status: 500 } ) } } export async function PATCH(req: NextRequest) { try { const session = await getServerSession(authOptions) if (!(await isCurrentSuperAdmin(session))) { return NextResponse.json( { error: '无权限' }, { status: 403 } ) } const body = await req.json().catch(() => ({})) if (body.action !== 'recover') { return NextResponse.json( { error: '不支持的操作' }, { status: 400 } ) } const result = await recoverStaleVideoTranscodeJobs() return NextResponse.json({ message: '已触发转码任务恢复', ...result, }) } catch (error) { console.error('[Transcode] Recovery error:', error) return NextResponse.json( { error: '转码任务恢复失败' }, { status: 500 } ) } }