262 lines
7.5 KiB
TypeScript
262 lines
7.5 KiB
TypeScript
/**
|
||
* 视频转码 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<void> {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|