245 lines
7.7 KiB
TypeScript
245 lines
7.7 KiB
TypeScript
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 }
|
||
);
|
||
}
|
||
}
|