2026-05-25 16:05:01 +08:00

245 lines
6.4 KiB
TypeScript

/**
* 视频转码 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 }
)
}
}