chenpei 4e89b57798 feat(upload): optimize upload UI and performance
- Expand batch upload list area and optimize preview image layout
- Improve upload speed tracking and display (server-side & client-side)
- Reduce network detection log noise
- Optimize Docker build process (caching, env validation)
- Add shadow environment deployment support
2026-01-28 15:07:50 +08:00

211 lines
6.3 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 - 完整版本(支持自动恢复)
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { transcodeQueue, cleanupTranscodeResidues } from '@/lib/video-transcode-queue';
import { executeTranscodeTask, enqueueVideoTranscodeTask } from '@/lib/video-transcode-task';
export const dynamic = 'force-dynamic';
export const maxDuration = 3600; // 1小时
// 恢复标志:确保只执行一次
let recoveryChecked = false;
/**
* R1修复:懒加载恢复中断的转码任务
* - 优先从新的 VideoTranscodeProgress 表恢复(持久化队列状态)
* - 降级到旧的 Material.videoProcessingStatus 恢复(向后兼容)
*/
async function ensureRecoveryCompleted(): Promise<void> {
if (recoveryChecked) {
return;
}
recoveryChecked = true;
try {
console.log('[Transcode] Starting recovery check...');
// R1修复:优先使用新的持久化队列恢复
let recovered = await transcodeQueue.recoverOnStartup(executeTranscodeTask);
// 如果新表没有数据,降级到旧方法(向后兼容)
if (recovered === 0) {
console.log('[Transcode] No tasks in new progress table, falling back to legacy recovery...');
const { recovered: legacyRecovered, requeued } = await transcodeQueue.recoverInterruptedTasks(
executeTranscodeTask,
cleanupTranscodeResidues
);
recovered = legacyRecovered + requeued;
}
console.log(`[Transcode] Recovery completed: ${recovered} tasks recovered`);
} catch (error) {
console.error('[Transcode] Recovery failed:', error);
// 恢复失败不影响正常流程,重置标志以便下次重试
recoveryChecked = false;
}
}
// GET - 查询转码状态
export async function GET(request: NextRequest) {
// 懒加载恢复:第一次调用时自动恢复中断的任务(包括 GET 请求)
await ensureRecoveryCompleted();
try {
const { searchParams } = new URL(request.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,
transcodeLastError: true,
transcodeAttempts: true,
} as any,
});
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
);
}
// 获取转码队列状态
const queueStatus = transcodeQueue.getStatus();
const isProcessing = queueStatus.processingIds.includes(materialId);
const waitingIndex = queueStatus.waiting.findIndex(id => id === materialId);
const waitingTotal = queueStatus.waiting.length;
return NextResponse.json({
materialId: material.id,
status: material.videoProcessingStatus || 'pending',
hasPreview540: !!material.videoPreview540Url,
hasClip: !!material.videoPreviewClipUrl,
hasCover: !!material.videoCoverUrl,
duration: material.videoDuration,
attempts: material.transcodeAttempts || 0,
lastError: material.transcodeLastError,
progress: isProcessing ? queueStatus.progress.get(materialId) || null : null,
stage: isProcessing ? queueStatus.stages.get(materialId) || null : null,
queue: {
waitingIndex: waitingIndex >= 0 ? waitingIndex + 1 : null,
waitingTotal: waitingTotal,
processingIds: queueStatus.processingIds,
maxConcurrent: queueStatus.maxConcurrent,
},
});
} catch (error) {
console.error('[Transcode] GET error:', error);
return NextResponse.json(
{ error: '查询转码状态失败' },
{ status: 500 }
);
}
}
// POST - 启动转码任务
export async function POST(request: NextRequest) {
// 懒加载恢复:第一次调用时自动恢复中断的任务
await ensureRecoveryCompleted();
try {
const { materialId, forceRetry } = await request.json();
if (!materialId) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
);
}
const material = await prisma.material.findFirst({
where: { id: materialId, deletedAt: null },
select: {
id: true,
videoSourceUrl: true,
videoCoverUrl: true,
videoProcessingStatus: true,
materialType: true,
} as any,
});
if (!material) {
return NextResponse.json(
{ error: '素材不存在或已被删除' },
{ status: 404 }
);
}
// 检查是否为视频类型
const videoTypes = ['视频', 'VIDEO', '主图视频', '安装视频', '种草视频'];
if (!videoTypes.includes((material as any).materialType)) {
return NextResponse.json(
{ error: '此素材不是视频类型' },
{ status: 400 }
);
}
// 检查是否有源视频
if (!(material as any).videoSourceUrl) {
return NextResponse.json(
{ error: '未找到源视频文件' },
{ status: 400 }
);
}
// 检查转码状态
if ((material as any).videoProcessingStatus === 'processing' && !forceRetry) {
return NextResponse.json(
{ error: '转码任务正在进行中', materialId },
{ status: 409 }
);
}
if ((material as any).videoProcessingStatus === 'done' && !forceRetry) {
return NextResponse.json(
{ message: '转码已完成', materialId },
{ status: 200 }
);
}
// 判断是否需要生成封面图(用户未上传时才生成)
const needsCoverImage = !(material as any).videoCoverUrl;
// 加入转码队列
await transcodeQueue.add(
materialId,
(material as any).videoSourceUrl,
needsCoverImage,
executeTranscodeTask
);
console.log(`[Transcode] Task queued for material ${materialId}`);
return NextResponse.json({
message: '转码任务已启动',
materialId,
status: 'processing',
queueStatus: transcodeQueue.getStatus(),
});
} catch (error) {
console.error('[Transcode] POST error:', error);
return NextResponse.json(
{ error: '转码任务启动失败' },
{ status: 500 }
);
}
}