Your Name 1967992de7
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat(video): storyboard task tables and delayed archive flow
2026-03-05 15:58:43 +08:00

245 lines
7.7 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.

import { NextResponse } from "next/server";
import { z } from "zod";
import { getAiModelById } from "@/lib/db/queries";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { ChatSDKError } from "@/lib/errors";
import { requireAdmin } from "@/lib/middleware/admin";
import { getVideoStoryboardConfig } from "@/lib/video-agent/config";
import {
finalizeVideoStoryboardTask,
getVideoStoryboardTaskDetail,
markVideoStoryboardShotCompleted,
markVideoStoryboardShotFailed,
markVideoStoryboardShotRetry,
markVideoStoryboardShotRunning,
syncVideoStoryboardTaskStatus,
} from "@/lib/video-agent/task-store";
const paramsSchema = z.object({
taskId: z.string().uuid(),
});
const bodySchema = z.object({
shotIndex: z.number().int().min(1).max(99).optional(),
retryFailedOnly: z.boolean().optional().default(true),
});
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function extractVideoUrlFromText(text: string): string | null {
const source = String(text || "");
if (!source) return null;
const htmlMatch = source.match(/<video[^>]+src="([^"]+)"/i);
if (htmlMatch?.[1]) return htmlMatch[1];
const markdownMatch = source.match(
/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i
);
if (markdownMatch?.[1]) return markdownMatch[1];
const plainMatch = source.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
return plainMatch?.[1] || null;
}
function normalizeFallbackChain(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const chain = value
.map((item) => String(item || "").trim())
.filter(Boolean);
return Array.from(new Set(chain));
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
try {
await requireAdmin();
const parsedParams = paramsSchema.parse(await params);
const parsedBody = bodySchema.parse(await request.json().catch(() => ({})));
const detail = await getVideoStoryboardTaskDetail(parsedParams.taskId);
if (!detail) {
return NextResponse.json(
{ success: false, error: "任务不存在" },
{ status: 404 }
);
}
if (detail.task.providerType !== "ruxa-video") {
return NextResponse.json(
{ success: false, error: "当前仅支持重跑 ruxa-video 任务" },
{ status: 400 }
);
}
const modelRef = String(detail.task.modelRef || "").trim();
if (!modelRef) {
return NextResponse.json(
{ success: false, error: "任务缺少 modelRef,无法重跑" },
{ status: 400 }
);
}
const selectedModel = await getAiModelById({ id: modelRef }).catch(() => undefined);
const selectedModelId = String(selectedModel?.modelId || "").trim();
const languageModel = await getLanguageModelById(modelRef);
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
return NextResponse.json(
{ success: false, error: "模型不可用或不支持重跑 doGenerate" },
{ status: 400 }
);
}
const config = await getVideoStoryboardConfig().catch(() => null);
const dispatchIntervalSec = Math.max(1, Number(config?.dispatchIntervalSec || 10));
const maxRetry = Math.max(0, Number(config?.maxRetry || 3));
const enableFallback = config?.enableFallback !== false;
const targetShots = detail.shots.filter((shot: any) => {
if (parsedBody.shotIndex) {
return Number(shot.shotIndex) === parsedBody.shotIndex;
}
if (parsedBody.retryFailedOnly) {
return String(shot.status || "") === "failed";
}
return true;
});
if (targetShots.length === 0) {
return NextResponse.json(
{ success: false, error: "没有可重跑的分镜" },
{ status: 400 }
);
}
await finalizeVideoStoryboardTask({
taskId: detail.task.id,
status: "running",
});
for (let index = 0; index < targetShots.length; index += 1) {
const shot = targetShots[index]!;
const shotIndex = Number(shot.shotIndex || 0);
const prompt = String(shot.prompt || "").trim() || "让画面动起来";
const tileImageUrl = String(shot.upscaledImageUrl || shot.tileImageUrl || "").trim();
if (!shotIndex || !tileImageUrl) {
await markVideoStoryboardShotFailed({
taskId: detail.task.id,
shotIndex,
errorMessage: "缺少分镜素材,无法重跑",
});
continue;
}
const fallbackChain = normalizeFallbackChain(shot.fallbackChain);
const candidateModelIds = Array.from(
new Set(
[
String(shot.modelId || "").trim(),
...fallbackChain,
selectedModelId,
].filter(Boolean)
)
);
const modelPool =
enableFallback && candidateModelIds.length > 0
? candidateModelIds
: candidateModelIds.slice(0, 1);
let succeeded = false;
let lastError = "";
for (const candidateModelId of modelPool) {
for (let attempt = 1; attempt <= maxRetry + 1; attempt += 1) {
try {
await markVideoStoryboardShotRunning({
taskId: detail.task.id,
shotIndex,
modelId: candidateModelId,
});
const result = await (languageModel as any).doGenerate({
messages: [
{
role: "user",
content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: tileImageUrl } },
],
},
],
videoParams: { mode: "first-frame" },
overrideModelId: candidateModelId,
});
const text = String(result?.text || "").trim();
const videoUrl = extractVideoUrlFromText(text);
if (!videoUrl) {
throw new Error(text || "未返回视频地址");
}
await markVideoStoryboardShotCompleted({
taskId: detail.task.id,
shotIndex,
modelId: candidateModelId,
videoUrl,
});
succeeded = true;
break;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
await markVideoStoryboardShotRetry({
taskId: detail.task.id,
shotIndex,
errorMessage: lastError,
});
if (attempt <= maxRetry) {
const backoffSec = Math.min(40, Math.pow(2, attempt - 1) * 10);
await sleep(backoffSec * 1000);
}
}
}
if (succeeded) break;
}
if (!succeeded) {
await markVideoStoryboardShotFailed({
taskId: detail.task.id,
shotIndex,
errorMessage: lastError || "重跑失败",
});
}
if (index < targetShots.length - 1 && dispatchIntervalSec > 0) {
await sleep(dispatchIntervalSec * 1000);
}
}
const summary = await syncVideoStoryboardTaskStatus(detail.task.id);
const nextDetail = await getVideoStoryboardTaskDetail(detail.task.id);
return NextResponse.json({
success: true,
summary,
task: nextDetail?.task || null,
shots: nextDetail?.shots || [],
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "重跑失败",
},
{ status: 500 }
);
}
}