WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

262 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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