feat(video): storyboard task tables and delayed archive flow
This commit is contained in:
parent
20898e1ac1
commit
1967992de7
@ -55,10 +55,10 @@ import type { DBMessage } from "@/lib/db/schema";
|
||||
import { generatedImage, message as messageTable } from "@/lib/db/schema";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import { videoArchiver } from "@/lib/storage/video-archiver";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { getVideoStoryboardConfig } from "@/lib/video-agent/config";
|
||||
import { runRuxaNineGridPipeline } from "@/lib/video-agent/pipeline/ruxa-nine-grid-pipeline";
|
||||
import { attachVideoStoryboardTaskMessage } from "@/lib/video-agent/task-store";
|
||||
import type { StoryboardModelCandidate } from "@/lib/video-agent/types";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import type { AppUsage } from "@/lib/usage";
|
||||
@ -421,11 +421,6 @@ function extractVideoUrlFromText(text: string): string | null {
|
||||
return plainMatch?.[1] || null;
|
||||
}
|
||||
|
||||
function replaceVideoUrlInText(text: string, oldUrl: string, newUrl: string): string {
|
||||
if (!text || !oldUrl || !newUrl || oldUrl === newUrl) return text;
|
||||
return text.split(oldUrl).join(newUrl);
|
||||
}
|
||||
|
||||
function isAlreadyArchivedVideoUrl(url: string): boolean {
|
||||
return /\/uploads\//i.test(url);
|
||||
}
|
||||
@ -438,55 +433,46 @@ function scheduleVideoArchive(params: {
|
||||
taskId?: string;
|
||||
metadata?: Record<string, any> | null;
|
||||
}) {
|
||||
const { chatId, messageId, text, temporaryVideoUrl, taskId, metadata } = params;
|
||||
const { chatId, messageId, temporaryVideoUrl, taskId, metadata } = params;
|
||||
if (!temporaryVideoUrl || isAlreadyArchivedVideoUrl(temporaryVideoUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delayHoursRaw = Number(process.env.VIDEO_ARCHIVE_DELAY_HOURS || 48);
|
||||
const delayHours = Number.isFinite(delayHoursRaw)
|
||||
? Math.max(1, Math.min(24 * 30, Math.round(delayHoursRaw)))
|
||||
: 48;
|
||||
const archiveAfterAtMs = Date.now() + delayHours * 60 * 60 * 1000;
|
||||
|
||||
after(async () => {
|
||||
try {
|
||||
const permanentVideoUrl = await videoArchiver.archiveVideo({
|
||||
messageId,
|
||||
temporaryUrl: temporaryVideoUrl,
|
||||
taskId: taskId || messageId,
|
||||
metadata: {
|
||||
duration:
|
||||
typeof metadata?.duration === "number" && Number.isFinite(metadata.duration)
|
||||
? metadata.duration
|
||||
: undefined,
|
||||
resolution:
|
||||
typeof metadata?.resolution === "string" ? metadata.resolution : undefined,
|
||||
ratio: typeof metadata?.ratio === "string" ? metadata.ratio : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const archivedText = replaceVideoUrlInText(text, temporaryVideoUrl, permanentVideoUrl);
|
||||
const nextMetadata = {
|
||||
...(metadata || {}),
|
||||
temporaryVideoUrl,
|
||||
permanentVideoUrl,
|
||||
storageStatus: "archived",
|
||||
archivedAt: Date.now(),
|
||||
storageStatus: "temporary",
|
||||
archiveMode: "delayed_job",
|
||||
archiveAfterAt: archiveAfterAtMs,
|
||||
archiveDelayHours: delayHours,
|
||||
archiveTaskId: taskId || messageId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await updateMessageById({
|
||||
messageId,
|
||||
chatId,
|
||||
parts: [{ type: "text", text: archivedText }],
|
||||
videoStorageStatus: "archived",
|
||||
videoStorageStatus: "temporary",
|
||||
temporaryVideoUrl,
|
||||
permanentVideoUrl,
|
||||
archivedAt: new Date(),
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: nextMetadata as any,
|
||||
});
|
||||
|
||||
console.log("[Chat API] ✅ 视频已归档到永久存储:", {
|
||||
console.log("[Chat API] 🕒 已登记延迟归档视频:", {
|
||||
messageId,
|
||||
permanentVideoUrl,
|
||||
archiveAfterAt: new Date(archiveAfterAtMs).toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Chat API] ❌ 视频归档失败(保留临时地址):", {
|
||||
console.error("[Chat API] ❌ 登记视频延迟归档失败(保留临时地址):", {
|
||||
messageId,
|
||||
temporaryVideoUrl,
|
||||
error,
|
||||
@ -4196,6 +4182,8 @@ export async function POST(request: Request) {
|
||||
"";
|
||||
|
||||
const storyboardResult = await runRuxaNineGridPipeline({
|
||||
userId: session.user.id,
|
||||
chatId: id,
|
||||
modelMessages: modelMessages as any,
|
||||
selectedModelId,
|
||||
selectedModelRef: finalModelId,
|
||||
@ -4294,6 +4282,18 @@ export async function POST(request: Request) {
|
||||
},
|
||||
],
|
||||
});
|
||||
const storyboardTaskId = String((videoMetadata as any)?.taskId || "").trim();
|
||||
if (storyboardTaskId) {
|
||||
await attachVideoStoryboardTaskMessage({
|
||||
taskId: storyboardTaskId,
|
||||
messageId,
|
||||
}).catch((taskAttachError) => {
|
||||
console.warn(
|
||||
"[Chat API] Failed to attach storyboard task with message:",
|
||||
taskAttachError
|
||||
);
|
||||
});
|
||||
}
|
||||
console.log(`[Chat API] ✅ ${currentVideoProvider} video message saved successfully!`);
|
||||
await consumeGeneratedVideoPoints({
|
||||
userId: session.user.id,
|
||||
|
||||
244
app/api/admin/video-storyboard-tasks/[taskId]/rerun/route.ts
Normal file
244
app/api/admin/video-storyboard-tasks/[taskId]/rerun/route.ts
Normal file
@ -0,0 +1,244 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/admin/video-storyboard-tasks/[taskId]/route.ts
Normal file
50
app/api/admin/video-storyboard-tasks/[taskId]/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { getVideoStoryboardTaskDetail } from "@/lib/video-agent/task-store";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
taskId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const parsed = paramsSchema.parse(await params);
|
||||
const detail = await getVideoStoryboardTaskDetail(parsed.taskId);
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "任务不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task: detail.task,
|
||||
shots: detail.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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
61
app/api/admin/video-storyboard-tasks/route.ts
Normal file
61
app/api/admin/video-storyboard-tasks/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { listVideoStoryboardTasks } from "@/lib/video-agent/task-store";
|
||||
|
||||
const querySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).default(20),
|
||||
status: z.string().trim().optional(),
|
||||
providerType: z.string().trim().optional(),
|
||||
chatId: z.string().uuid().optional(),
|
||||
userId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = querySchema.parse({
|
||||
page: searchParams.get("page") || "1",
|
||||
pageSize: searchParams.get("pageSize") || "20",
|
||||
status: searchParams.get("status") || undefined,
|
||||
providerType: searchParams.get("providerType") || undefined,
|
||||
chatId: searchParams.get("chatId") || undefined,
|
||||
userId: searchParams.get("userId") || undefined,
|
||||
});
|
||||
|
||||
const result = await listVideoStoryboardTasks({
|
||||
page: parsed.page,
|
||||
pageSize: parsed.pageSize,
|
||||
status: parsed.status,
|
||||
providerType: parsed.providerType,
|
||||
chatId: parsed.chatId,
|
||||
userId: parsed.userId,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
interface FalVideoConfig {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
@ -152,23 +150,6 @@ function extractVideoUrl(payload: any): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function downloadAndUploadVideo(videoUrl: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(videoUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download video: ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const filename = `fal-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`;
|
||||
const uploadResult = await uploadFile(filename, buffer as any);
|
||||
return uploadResult.url;
|
||||
} catch (error) {
|
||||
console.error("[Fal Video] Failed to upload video to storage, fallback original URL", error);
|
||||
return videoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function formatVideoResponse(videoUrl: string): string {
|
||||
return `<video src="${videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>\n\n[下载视频](${videoUrl})`;
|
||||
}
|
||||
@ -277,8 +258,7 @@ export function createFalVideo({ apiKey, baseURL }: FalVideoConfig) {
|
||||
throw new Error("FAL 未返回视频地址");
|
||||
}
|
||||
|
||||
const hostedVideoUrl = await downloadAndUploadVideo(remoteVideoUrl);
|
||||
const text = formatVideoResponse(hostedVideoUrl);
|
||||
const text = formatVideoResponse(remoteVideoUrl);
|
||||
|
||||
return {
|
||||
text,
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
interface RuxaVideoConfig {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
@ -213,22 +211,6 @@ async function waitForVideoUrl(baseURL: string, apiKey: string, taskId: string)
|
||||
throw new Error("RUXA 视频任务超时,请稍后重试");
|
||||
}
|
||||
|
||||
async function downloadAndUploadVideo(videoUrl: string) {
|
||||
try {
|
||||
const response = await fetch(videoUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download video: ${response.status}`);
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const filename = `ruxa-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`;
|
||||
const uploaded = await uploadFile(filename, buffer as any);
|
||||
return uploaded.url;
|
||||
} catch (error) {
|
||||
console.error("[Ruxa Video] Upload to storage failed, fallback remote URL:", error);
|
||||
return videoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function formatVideoMarkdown(videoUrl: string) {
|
||||
return `<video src="${videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>\n\n[下载视频](${videoUrl})`;
|
||||
}
|
||||
@ -329,8 +311,7 @@ export function createRuxaVideo({ apiKey, baseURL }: RuxaVideoConfig) {
|
||||
throw new Error("RUXA 未返回可用视频地址");
|
||||
}
|
||||
|
||||
const hostedVideoUrl = await downloadAndUploadVideo(remoteVideoUrl);
|
||||
const text = formatVideoMarkdown(hostedVideoUrl);
|
||||
const text = formatVideoMarkdown(remoteVideoUrl);
|
||||
return {
|
||||
text,
|
||||
finishReason: "stop" as const,
|
||||
|
||||
77
lib/db/migrations/0048_add_video_storyboard_tasks.sql
Normal file
77
lib/db/migrations/0048_add_video_storyboard_tasks.sql
Normal file
@ -0,0 +1,77 @@
|
||||
CREATE TABLE IF NOT EXISTS "VideoStoryboardTask" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"chatId" uuid,
|
||||
"messageId" uuid,
|
||||
"providerType" varchar(64) DEFAULT 'ruxa-video' NOT NULL,
|
||||
"modelRef" varchar(128),
|
||||
"sourceImageUrl" text NOT NULL,
|
||||
"isNineGrid" boolean DEFAULT false NOT NULL,
|
||||
"detectionConfidence" integer DEFAULT 0 NOT NULL,
|
||||
"normalizedScript" jsonb,
|
||||
"status" varchar(32) DEFAULT 'pending' NOT NULL,
|
||||
"currentShot" integer DEFAULT 0 NOT NULL,
|
||||
"totalShots" integer DEFAULT 9 NOT NULL,
|
||||
"error" text,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||
"startedAt" timestamp,
|
||||
"completedAt" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "VideoStoryboardShotTask" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"parentTaskId" uuid NOT NULL,
|
||||
"shotIndex" integer NOT NULL,
|
||||
"tileIndex" integer,
|
||||
"tileImageUrl" text,
|
||||
"upscaledImageUrl" text,
|
||||
"prompt" text,
|
||||
"modelId" varchar(128),
|
||||
"fallbackChain" jsonb,
|
||||
"attemptCount" integer DEFAULT 0 NOT NULL,
|
||||
"status" varchar(32) DEFAULT 'pending' NOT NULL,
|
||||
"videoUrl" text,
|
||||
"errorCode" varchar(64),
|
||||
"errorMessage" text,
|
||||
"startedAt" timestamp,
|
||||
"completedAt" timestamp,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "VideoStoryboardTask" ADD CONSTRAINT "VideoStoryboardTask_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "VideoStoryboardTask" ADD CONSTRAINT "VideoStoryboardTask_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "VideoStoryboardTask" ADD CONSTRAINT "VideoStoryboardTask_messageId_Message_v2_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message_v2"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "VideoStoryboardShotTask" ADD CONSTRAINT "VideoStoryboardShotTask_parentTaskId_VideoStoryboardTask_id_fk" FOREIGN KEY ("parentTaskId") REFERENCES "public"."VideoStoryboardTask"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "VideoStoryboardShotTask_parent_shot_uidx"
|
||||
ON "VideoStoryboardShotTask"("parentTaskId", "shotIndex");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "VideoStoryboardTask_status_created_idx"
|
||||
ON "VideoStoryboardTask"("status", "createdAt");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "VideoStoryboardTask_chat_created_idx"
|
||||
ON "VideoStoryboardTask"("chatId", "createdAt");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "VideoStoryboardShotTask_parent_status_idx"
|
||||
ON "VideoStoryboardShotTask"("parentTaskId", "status");
|
||||
@ -583,3 +583,59 @@ export const canvasNode = pgTable("CanvasNode", {
|
||||
});
|
||||
|
||||
export type CanvasNode = InferSelectModel<typeof canvasNode>;
|
||||
|
||||
// 九宫格视频分镜任务主表
|
||||
export const videoStoryboardTask = pgTable("VideoStoryboardTask", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
userId: uuid("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
chatId: uuid("chatId").references(() => chat.id, { onDelete: "cascade" }),
|
||||
messageId: uuid("messageId").references(() => message.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
providerType: varchar("providerType", { length: 64 })
|
||||
.notNull()
|
||||
.default("ruxa-video"),
|
||||
modelRef: varchar("modelRef", { length: 128 }),
|
||||
sourceImageUrl: text("sourceImageUrl").notNull(),
|
||||
isNineGrid: boolean("isNineGrid").notNull().default(false),
|
||||
detectionConfidence: integer("detectionConfidence").notNull().default(0),
|
||||
normalizedScript: jsonb("normalizedScript"),
|
||||
status: varchar("status", { length: 32 }).notNull().default("pending"),
|
||||
currentShot: integer("currentShot").notNull().default(0),
|
||||
totalShots: integer("totalShots").notNull().default(9),
|
||||
error: text("error"),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
startedAt: timestamp("startedAt"),
|
||||
completedAt: timestamp("completedAt"),
|
||||
});
|
||||
|
||||
export type VideoStoryboardTask = InferSelectModel<typeof videoStoryboardTask>;
|
||||
|
||||
// 九宫格视频分镜子任务表
|
||||
export const videoStoryboardShotTask = pgTable("VideoStoryboardShotTask", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
parentTaskId: uuid("parentTaskId")
|
||||
.notNull()
|
||||
.references(() => videoStoryboardTask.id, { onDelete: "cascade" }),
|
||||
shotIndex: integer("shotIndex").notNull(),
|
||||
tileIndex: integer("tileIndex"),
|
||||
tileImageUrl: text("tileImageUrl"),
|
||||
upscaledImageUrl: text("upscaledImageUrl"),
|
||||
prompt: text("prompt"),
|
||||
modelId: varchar("modelId", { length: 128 }),
|
||||
fallbackChain: jsonb("fallbackChain"),
|
||||
attemptCount: integer("attemptCount").notNull().default(0),
|
||||
status: varchar("status", { length: 32 }).notNull().default("pending"),
|
||||
videoUrl: text("videoUrl"),
|
||||
errorCode: varchar("errorCode", { length: 64 }),
|
||||
errorMessage: text("errorMessage"),
|
||||
startedAt: timestamp("startedAt"),
|
||||
completedAt: timestamp("completedAt"),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type VideoStoryboardShotTask = InferSelectModel<typeof videoStoryboardShotTask>;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 视频归档定时任务
|
||||
* 功能:定期查询需要归档的视频并执行归档操作
|
||||
* 触发条件:创建时间超过20分钟且状态为 temporary
|
||||
* 触发条件:创建时间超过配置延迟(默认48小时)且状态为 temporary
|
||||
*/
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
@ -10,20 +10,29 @@ import { and, lte, isNull, eq } from "drizzle-orm";
|
||||
import { videoArchiver } from "@/lib/storage/video-archiver";
|
||||
import type { VideoMetadata } from "@/types/ai";
|
||||
|
||||
const DEFAULT_ARCHIVE_DELAY_HOURS = 48;
|
||||
|
||||
function getArchiveDelayHours() {
|
||||
const raw = Number(process.env.VIDEO_ARCHIVE_DELAY_HOURS || DEFAULT_ARCHIVE_DELAY_HOURS);
|
||||
if (!Number.isFinite(raw)) return DEFAULT_ARCHIVE_DELAY_HOURS;
|
||||
return Math.max(1, Math.min(24 * 30, Math.round(raw)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询需要归档的视频消息
|
||||
* 条件:
|
||||
* 1. 视频状态为 'temporary'(临时)
|
||||
* 2. 创建时间超过20分钟
|
||||
* 2. 创建时间超过归档延迟(默认48小时)
|
||||
* 3. 还没有永久URL
|
||||
*/
|
||||
export async function getVideosToArchive() {
|
||||
const twentyMinutesAgo = new Date(Date.now() - 20 * 60 * 1000);
|
||||
const archiveDelayHours = getArchiveDelayHours();
|
||||
const threshold = new Date(Date.now() - archiveDelayHours * 60 * 60 * 1000);
|
||||
|
||||
return await db.query.message.findMany({
|
||||
where: and(
|
||||
eq(message.videoStorageStatus, "temporary"),
|
||||
lte(message.createdAt, twentyMinutesAgo),
|
||||
lte(message.createdAt, threshold),
|
||||
isNull(message.permanentVideoUrl)
|
||||
),
|
||||
limit: 20, // 每次处理20个,20分钟周期可以处理更多
|
||||
@ -60,7 +69,10 @@ function extractVideoDataFromMessage(msg: any) {
|
||||
* 执行视频归档任务
|
||||
*/
|
||||
export async function runVideoArchiveJob() {
|
||||
console.log("[VideoArchiveJob] ⏰ 开始执行视频归档任务...");
|
||||
const archiveDelayHours = getArchiveDelayHours();
|
||||
console.log(
|
||||
`[VideoArchiveJob] ⏰ 开始执行视频归档任务... (delay=${archiveDelayHours}h)`
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. 查询待归档的视频
|
||||
@ -148,4 +160,3 @@ export async function runVideoArchiveJob() {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,12 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { message } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||
import {
|
||||
toDbS3UploadConfig,
|
||||
uploadFile,
|
||||
uploadFileWithS3Config,
|
||||
} from "@/lib/storage/service";
|
||||
import type { VideoMetadata } from "@/types/ai";
|
||||
|
||||
interface VideoArchiveOptions {
|
||||
@ -51,6 +56,7 @@ export class VideoArchiver {
|
||||
// 5. 更新数据库
|
||||
await this.updateVideoUrls(messageId, {
|
||||
permanentUrl,
|
||||
temporaryUrl,
|
||||
fileSize: videoBuffer.length,
|
||||
...metadata,
|
||||
});
|
||||
@ -115,13 +121,26 @@ export class VideoArchiver {
|
||||
buffer: Buffer,
|
||||
filename: string
|
||||
): Promise<string> {
|
||||
// 直接使用项目已有的 uploadFile 方法
|
||||
// 支持:数据库配置的S3 / 环境变量S3 / Vercel Blob
|
||||
const result = await uploadFile(filename, buffer as any);
|
||||
const defaultStorageConfig = await getDefaultStorageConfig().catch(() => undefined);
|
||||
const defaultS3Config = toDbS3UploadConfig(defaultStorageConfig);
|
||||
|
||||
console.log(`[VideoArchiver] 视频已上传到S3:${result.url}`);
|
||||
if (defaultS3Config) {
|
||||
const uploaded = await uploadFileWithS3Config(
|
||||
filename,
|
||||
buffer as any,
|
||||
defaultS3Config
|
||||
);
|
||||
console.log(
|
||||
`[VideoArchiver] 视频已上传到默认存储:${uploaded.url}`,
|
||||
defaultStorageConfig?.name ? `(config=${defaultStorageConfig.name})` : ""
|
||||
);
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
return result.url;
|
||||
console.warn("[VideoArchiver] 默认存储不可用,回退到通用上传策略");
|
||||
const fallback = await uploadFile(filename, buffer as any);
|
||||
console.log(`[VideoArchiver] 视频已上传(回退路径):${fallback.url}`);
|
||||
return fallback.url;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -153,6 +172,7 @@ export class VideoArchiver {
|
||||
messageId: string,
|
||||
data: {
|
||||
permanentUrl: string;
|
||||
temporaryUrl: string;
|
||||
fileSize: number;
|
||||
duration?: number;
|
||||
resolution?: string;
|
||||
@ -166,14 +186,31 @@ export class VideoArchiver {
|
||||
|
||||
const existingMetadata = (existing?.videoMetadata as VideoMetadata) || {};
|
||||
|
||||
const existingParts = Array.isArray(existing?.parts) ? existing.parts : [];
|
||||
const nextParts = existingParts.map((part: any) => {
|
||||
if (!part || part.type !== "text" || typeof part.text !== "string") {
|
||||
return part;
|
||||
}
|
||||
return {
|
||||
...part,
|
||||
text: part.text.split(data.temporaryUrl).join(data.permanentUrl),
|
||||
};
|
||||
});
|
||||
|
||||
await db
|
||||
.update(message)
|
||||
.set({
|
||||
parts: nextParts as any,
|
||||
permanentVideoUrl: data.permanentUrl,
|
||||
videoStorageStatus: "archived",
|
||||
archivedAt: new Date(),
|
||||
videoMetadata: {
|
||||
...existingMetadata,
|
||||
temporaryVideoUrl: data.temporaryUrl,
|
||||
permanentVideoUrl: data.permanentUrl,
|
||||
storageStatus: "archived",
|
||||
archivedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
fileSize: data.fileSize,
|
||||
duration: data.duration,
|
||||
resolution: data.resolution,
|
||||
@ -186,4 +223,3 @@ export class VideoArchiver {
|
||||
|
||||
// 单例模式
|
||||
export const videoArchiver = new VideoArchiver();
|
||||
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import "server-only";
|
||||
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import {
|
||||
createVideoStoryboardShotTasks,
|
||||
createVideoStoryboardTask,
|
||||
finalizeVideoStoryboardTask,
|
||||
markVideoStoryboardShotCompleted,
|
||||
markVideoStoryboardShotFailed,
|
||||
markVideoStoryboardShotRetry,
|
||||
markVideoStoryboardShotRunning,
|
||||
} from "@/lib/video-agent/task-store";
|
||||
import { runNineGridDetectorSkill } from "@/lib/video-agent/skills/nine-grid-detector";
|
||||
import { runScriptNormalizerSkill } from "@/lib/video-agent/skills/script-normalizer";
|
||||
import { runStoryboardMapperSkill } from "@/lib/video-agent/skills/storyboard-mapper";
|
||||
@ -122,6 +131,8 @@ export async function runRuxaNineGridPipeline(
|
||||
params: RuxaNineGridPipelineParams
|
||||
): Promise<RuxaNineGridPipelineResult> {
|
||||
const {
|
||||
userId,
|
||||
chatId,
|
||||
modelMessages,
|
||||
selectedModelId,
|
||||
selectedModelRef,
|
||||
@ -169,7 +180,7 @@ export async function runRuxaNineGridPipeline(
|
||||
|
||||
const normalizedScript = runScriptNormalizerSkill({
|
||||
scriptText: prompt,
|
||||
totalShots: NINE_GRID_SHOT_COUNT,
|
||||
maxShots: NINE_GRID_SHOT_COUNT,
|
||||
totalDurationSec: Number(videoParams?.duration || 10),
|
||||
});
|
||||
const mappedShots = runStoryboardMapperSkill({
|
||||
@ -181,6 +192,22 @@ export async function runRuxaNineGridPipeline(
|
||||
enableTileUpscale: config.enableTileUpscale,
|
||||
sliceUpscaleMinEdge: config.sliceUpscaleMinEdge,
|
||||
});
|
||||
const tileSummary = detection.tiles.map((tile) => ({
|
||||
index: tile.index,
|
||||
imageUrl: tile.imageUrl,
|
||||
width: tile.width,
|
||||
height: tile.height,
|
||||
}));
|
||||
const shotPlanSummary = preparedShots.map((shot) => ({
|
||||
shotIndex: shot.shotIndex,
|
||||
tileIndex: shot.tileIndex,
|
||||
tileImageUrl: shot.inputImageUrl,
|
||||
prompt: shot.prompt,
|
||||
action: shot.action,
|
||||
camera: shot.camera,
|
||||
mood: shot.mood,
|
||||
durationSec: shot.durationSec,
|
||||
}));
|
||||
|
||||
const resolvedModelCandidates = normalizeModelCandidates({
|
||||
selectedModelId,
|
||||
@ -188,8 +215,30 @@ export async function runRuxaNineGridPipeline(
|
||||
modelCandidates,
|
||||
});
|
||||
|
||||
const taskId = `ruxa-storyboard-${generateUUID()}`;
|
||||
onProgress?.({
|
||||
const fallbackChain = resolvedModelCandidates
|
||||
.map((item) => normalizeText(item.modelId))
|
||||
.filter(Boolean);
|
||||
|
||||
const createdTask = await createVideoStoryboardTask({
|
||||
userId,
|
||||
chatId,
|
||||
providerType: "ruxa-video",
|
||||
modelRef: selectedModelRef,
|
||||
sourceImageUrl: imageUrls[0],
|
||||
isNineGrid: true,
|
||||
detectionConfidence: detection.confidence,
|
||||
normalizedScript: normalizedScript as any,
|
||||
totalShots: preparedShots.length,
|
||||
status: "running",
|
||||
});
|
||||
const taskId = createdTask.id || `ruxa-storyboard-${generateUUID()}`;
|
||||
await createVideoStoryboardShotTasks({
|
||||
parentTaskId: taskId,
|
||||
shots: preparedShots,
|
||||
fallbackChain,
|
||||
});
|
||||
|
||||
await onProgress?.({
|
||||
stage: "start",
|
||||
provider: "ruxa-video",
|
||||
taskId,
|
||||
@ -211,8 +260,38 @@ export async function runRuxaNineGridPipeline(
|
||||
maxRetry: config.maxRetry,
|
||||
dispatchIntervalSec: config.dispatchIntervalSec,
|
||||
enableFallback: config.enableFallback,
|
||||
onProgress,
|
||||
onProgress: async (event) => {
|
||||
if (event.stage === "shot_retry") {
|
||||
await markVideoStoryboardShotRetry({
|
||||
taskId,
|
||||
shotIndex: event.shotIndex,
|
||||
errorMessage: event.error,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
if (event.stage === "shot_completed") {
|
||||
await markVideoStoryboardShotCompleted({
|
||||
taskId,
|
||||
shotIndex: event.shotIndex,
|
||||
modelId: event.modelId,
|
||||
videoUrl: event.videoUrl,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
if (event.stage === "shot_failed") {
|
||||
await markVideoStoryboardShotFailed({
|
||||
taskId,
|
||||
shotIndex: event.shotIndex,
|
||||
errorMessage: event.error,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
await onProgress?.(event);
|
||||
},
|
||||
generateOneShot: async ({ shot, model }) => {
|
||||
await markVideoStoryboardShotRunning({
|
||||
taskId,
|
||||
shotIndex: shot.shotIndex,
|
||||
modelId: model.modelId,
|
||||
}).catch(() => undefined);
|
||||
|
||||
const shotMessages = [
|
||||
{
|
||||
role: "user",
|
||||
@ -257,7 +336,13 @@ export async function runRuxaNineGridPipeline(
|
||||
}));
|
||||
|
||||
if (successShots.length === 0) {
|
||||
onProgress?.({
|
||||
await finalizeVideoStoryboardTask({
|
||||
taskId,
|
||||
status: "failed",
|
||||
error: "all_shots_failed",
|
||||
}).catch(() => undefined);
|
||||
|
||||
await onProgress?.({
|
||||
stage: "failed",
|
||||
provider: "ruxa-video",
|
||||
taskId,
|
||||
@ -276,12 +361,22 @@ export async function runRuxaNineGridPipeline(
|
||||
successCount: 0,
|
||||
failedCount: failedShots.length,
|
||||
confidence: detection.confidence,
|
||||
tiles: tileSummary,
|
||||
shotPlan: shotPlanSummary,
|
||||
normalizedScript,
|
||||
failedShots,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
const finalTaskStatus =
|
||||
sequencer.failedCount > 0 ? "partial_success" : "succeeded";
|
||||
await finalizeVideoStoryboardTask({
|
||||
taskId,
|
||||
status: finalTaskStatus,
|
||||
}).catch(() => undefined);
|
||||
|
||||
await onProgress?.({
|
||||
stage: "completed",
|
||||
provider: "ruxa-video",
|
||||
taskId,
|
||||
@ -308,6 +403,9 @@ export async function runRuxaNineGridPipeline(
|
||||
confidence: detection.confidence,
|
||||
qualityScore: detection.qualityScore,
|
||||
sourceImageUrl: detection.sourceImageUrl,
|
||||
tiles: tileSummary,
|
||||
shotPlan: shotPlanSummary,
|
||||
normalizedScript,
|
||||
successCount: sequencer.successCount,
|
||||
failedCount: sequencer.failedCount,
|
||||
failedShots,
|
||||
|
||||
@ -33,6 +33,21 @@ function splitScriptLines(script: string): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parsePreferredTileIndex(text: string): number | undefined {
|
||||
const source = String(text || "");
|
||||
const patterns = [
|
||||
/第\s*([1-9])\s*(?:张|格|图|镜头)/i,
|
||||
/(?:图|格|镜头|shot)\s*([1-9])/i,
|
||||
/#\s*([1-9])/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const matched = source.match(pattern);
|
||||
const candidate = Number(matched?.[1] || 0);
|
||||
if (candidate >= 1 && candidate <= 9) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inferCamera(text: string) {
|
||||
const source = String(text || "");
|
||||
if (/推进|推近|close[- ]?in|dolly in/i.test(source)) return "轻微推进";
|
||||
@ -68,15 +83,17 @@ function buildDefaultAction(index: number) {
|
||||
|
||||
export function runScriptNormalizerSkill(params: {
|
||||
scriptText: string;
|
||||
totalShots?: number;
|
||||
maxShots?: number;
|
||||
totalDurationSec?: number;
|
||||
}): NormalizedStoryboardScript {
|
||||
const totalShots = Math.max(1, Math.min(20, Math.round(params.totalShots || 9)));
|
||||
const maxShots = Math.max(1, Math.min(20, Math.round(params.maxShots || 9)));
|
||||
const raw = String(params.scriptText || "").trim();
|
||||
const lines = splitScriptLines(raw);
|
||||
const effectiveLines = lines.slice(0, maxShots);
|
||||
const shotCount = Math.max(1, effectiveLines.length);
|
||||
const durationPerShot = Math.max(
|
||||
2,
|
||||
Math.round(Math.max(5, Number(params.totalDurationSec || 10)) / totalShots)
|
||||
Math.round(Math.max(5, Number(params.totalDurationSec || 10)) / shotCount)
|
||||
);
|
||||
|
||||
const normalizedShots: NormalizedStoryboardShot[] = [];
|
||||
@ -84,8 +101,8 @@ export function runScriptNormalizerSkill(params: {
|
||||
const fallbackStyle =
|
||||
language === "en" ? "cinematic realistic style" : "写实电影感";
|
||||
|
||||
for (let index = 1; index <= totalShots; index += 1) {
|
||||
const line = lines[index - 1] || lines[lines.length - 1] || "";
|
||||
for (let index = 1; index <= shotCount; index += 1) {
|
||||
const line = effectiveLines[index - 1] || "";
|
||||
const action = cleanLine(line) || buildDefaultAction(index);
|
||||
normalizedShots.push({
|
||||
index,
|
||||
@ -97,6 +114,7 @@ export function runScriptNormalizerSkill(params: {
|
||||
language === "en"
|
||||
? "jitter, blur, artifacts, low quality"
|
||||
: "抖动, 畸变, 低清, 伪影",
|
||||
preferredTileIndex: parsePreferredTileIndex(line),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -48,8 +48,28 @@ export function runStoryboardMapperSkill(params: {
|
||||
const { script, tiles } = params;
|
||||
if (!tiles.length) return [];
|
||||
|
||||
const usedTileIndexes = new Set<number>();
|
||||
const nextAvailableTile = (startIndex = 1) => {
|
||||
for (let index = Math.max(1, startIndex); index <= tiles.length; index += 1) {
|
||||
if (!usedTileIndexes.has(index)) return index;
|
||||
}
|
||||
for (let index = 1; index <= tiles.length; index += 1) {
|
||||
if (!usedTileIndexes.has(index)) return index;
|
||||
}
|
||||
return Math.max(1, Math.min(tiles.length, startIndex));
|
||||
};
|
||||
|
||||
return script.shots.map((shot, index) => {
|
||||
const tile = tiles[clampIndex(shot.index || index + 1, tiles.length)] || tiles[index] || tiles[0];
|
||||
const preferredTileIndex =
|
||||
shot.preferredTileIndex && shot.preferredTileIndex >= 1 && shot.preferredTileIndex <= tiles.length
|
||||
? shot.preferredTileIndex
|
||||
: shot.index || index + 1;
|
||||
const pickedTileIndex = usedTileIndexes.has(preferredTileIndex)
|
||||
? nextAvailableTile(index + 1)
|
||||
: preferredTileIndex;
|
||||
usedTileIndexes.add(pickedTileIndex);
|
||||
const tile =
|
||||
tiles[clampIndex(pickedTileIndex, tiles.length)] || tiles[index] || tiles[0];
|
||||
const prompt = buildShotPrompt({
|
||||
style: script.globalStyle,
|
||||
action: shot.action,
|
||||
|
||||
@ -36,7 +36,7 @@ export async function runVideoSequencerSkill(params: {
|
||||
maxRetry: number;
|
||||
dispatchIntervalSec: number;
|
||||
enableFallback: boolean;
|
||||
onProgress?: (event: StoryboardProgressEvent) => void;
|
||||
onProgress?: (event: StoryboardProgressEvent) => void | Promise<void>;
|
||||
generateOneShot: (input: {
|
||||
shot: PreparedStoryboardShot;
|
||||
model: StoryboardModelCandidate;
|
||||
@ -86,7 +86,7 @@ export async function runVideoSequencerSkill(params: {
|
||||
} catch (error) {
|
||||
lastError = normalizeErrorMessage(error);
|
||||
if (attempt <= retries) {
|
||||
onProgress?.({
|
||||
await onProgress?.({
|
||||
stage: "shot_retry",
|
||||
provider,
|
||||
taskId,
|
||||
@ -114,7 +114,7 @@ export async function runVideoSequencerSkill(params: {
|
||||
videoUrl: resultUrl,
|
||||
text: resultText,
|
||||
});
|
||||
onProgress?.({
|
||||
await onProgress?.({
|
||||
stage: "shot_completed",
|
||||
provider,
|
||||
taskId,
|
||||
@ -129,7 +129,7 @@ export async function runVideoSequencerSkill(params: {
|
||||
success: false,
|
||||
error: lastError || "video_generation_failed",
|
||||
});
|
||||
onProgress?.({
|
||||
await onProgress?.({
|
||||
stage: "shot_failed",
|
||||
provider,
|
||||
taskId,
|
||||
|
||||
405
lib/video-agent/task-store.ts
Normal file
405
lib/video-agent/task-store.ts
Normal file
@ -0,0 +1,405 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
sql,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
videoStoryboardShotTask,
|
||||
videoStoryboardTask,
|
||||
} from "@/lib/db/schema";
|
||||
import type { PreparedStoryboardShot } from "@/lib/video-agent/types";
|
||||
|
||||
export type VideoStoryboardTaskStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "partial_success"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export type VideoStoryboardShotStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
function toConfidenceInt(confidence: number) {
|
||||
if (!Number.isFinite(confidence)) return 0;
|
||||
return Math.max(0, Math.min(10000, Math.round(confidence * 10000)));
|
||||
}
|
||||
|
||||
export async function createVideoStoryboardTask(params: {
|
||||
userId: string;
|
||||
chatId?: string;
|
||||
providerType: string;
|
||||
modelRef?: string;
|
||||
sourceImageUrl: string;
|
||||
isNineGrid: boolean;
|
||||
detectionConfidence: number;
|
||||
normalizedScript?: Record<string, any> | null;
|
||||
totalShots: number;
|
||||
status?: VideoStoryboardTaskStatus;
|
||||
}) {
|
||||
const now = new Date();
|
||||
const [task] = await db
|
||||
.insert(videoStoryboardTask)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
chatId: params.chatId || null,
|
||||
providerType: params.providerType,
|
||||
modelRef: params.modelRef || null,
|
||||
sourceImageUrl: params.sourceImageUrl,
|
||||
isNineGrid: params.isNineGrid,
|
||||
detectionConfidence: toConfidenceInt(params.detectionConfidence),
|
||||
normalizedScript: params.normalizedScript || null,
|
||||
status: params.status || "running",
|
||||
currentShot: 0,
|
||||
totalShots: Math.max(0, Math.round(params.totalShots)),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
startedAt: now,
|
||||
})
|
||||
.returning();
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function createVideoStoryboardShotTasks(params: {
|
||||
parentTaskId: string;
|
||||
shots: PreparedStoryboardShot[];
|
||||
fallbackChain: string[];
|
||||
}) {
|
||||
const now = new Date();
|
||||
if (!params.shots.length) return [];
|
||||
const inserted = await db
|
||||
.insert(videoStoryboardShotTask)
|
||||
.values(
|
||||
params.shots.map((shot) => ({
|
||||
parentTaskId: params.parentTaskId,
|
||||
shotIndex: shot.shotIndex,
|
||||
tileIndex: shot.tileIndex,
|
||||
tileImageUrl: shot.tileImageUrl,
|
||||
upscaledImageUrl: shot.upscaled ? shot.inputImageUrl : null,
|
||||
prompt: shot.prompt,
|
||||
modelId: null,
|
||||
fallbackChain: params.fallbackChain,
|
||||
attemptCount: 0,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.returning();
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export async function attachVideoStoryboardTaskMessage(params: {
|
||||
taskId: string;
|
||||
messageId: string;
|
||||
}) {
|
||||
await db
|
||||
.update(videoStoryboardTask)
|
||||
.set({
|
||||
messageId: params.messageId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(videoStoryboardTask.id, params.taskId));
|
||||
}
|
||||
|
||||
export async function markVideoStoryboardShotRunning(params: {
|
||||
taskId: string;
|
||||
shotIndex: number;
|
||||
modelId: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(videoStoryboardShotTask)
|
||||
.set({
|
||||
status: "running",
|
||||
modelId: params.modelId,
|
||||
startedAt: now,
|
||||
completedAt: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
updatedAt: now,
|
||||
attemptCount: sql`${videoStoryboardShotTask.attemptCount} + 1`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(videoStoryboardShotTask.parentTaskId, params.taskId),
|
||||
eq(videoStoryboardShotTask.shotIndex, params.shotIndex)
|
||||
)
|
||||
);
|
||||
|
||||
await db
|
||||
.update(videoStoryboardTask)
|
||||
.set({
|
||||
status: "running",
|
||||
currentShot: params.shotIndex,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(videoStoryboardTask.id, params.taskId));
|
||||
}
|
||||
|
||||
export async function markVideoStoryboardShotRetry(params: {
|
||||
taskId: string;
|
||||
shotIndex: number;
|
||||
errorMessage: string;
|
||||
}) {
|
||||
await db
|
||||
.update(videoStoryboardShotTask)
|
||||
.set({
|
||||
status: "running",
|
||||
errorMessage: params.errorMessage.slice(0, 4000),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(videoStoryboardShotTask.parentTaskId, params.taskId),
|
||||
eq(videoStoryboardShotTask.shotIndex, params.shotIndex)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function markVideoStoryboardShotCompleted(params: {
|
||||
taskId: string;
|
||||
shotIndex: number;
|
||||
modelId: string;
|
||||
videoUrl: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(videoStoryboardShotTask)
|
||||
.set({
|
||||
status: "completed",
|
||||
modelId: params.modelId,
|
||||
videoUrl: params.videoUrl,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(videoStoryboardShotTask.parentTaskId, params.taskId),
|
||||
eq(videoStoryboardShotTask.shotIndex, params.shotIndex)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function markVideoStoryboardShotFailed(params: {
|
||||
taskId: string;
|
||||
shotIndex: number;
|
||||
errorMessage: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(videoStoryboardShotTask)
|
||||
.set({
|
||||
status: "failed",
|
||||
errorCode: "shot_failed",
|
||||
errorMessage: params.errorMessage.slice(0, 4000),
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(videoStoryboardShotTask.parentTaskId, params.taskId),
|
||||
eq(videoStoryboardShotTask.shotIndex, params.shotIndex)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function finalizeVideoStoryboardTask(params: {
|
||||
taskId: string;
|
||||
status: VideoStoryboardTaskStatus;
|
||||
error?: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(videoStoryboardTask)
|
||||
.set({
|
||||
status: params.status,
|
||||
error: params.error ? params.error.slice(0, 4000) : null,
|
||||
completedAt:
|
||||
params.status === "succeeded" ||
|
||||
params.status === "partial_success" ||
|
||||
params.status === "failed" ||
|
||||
params.status === "cancelled"
|
||||
? now
|
||||
: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(videoStoryboardTask.id, params.taskId));
|
||||
}
|
||||
|
||||
export async function syncVideoStoryboardTaskStatus(taskId: string) {
|
||||
const rows = await db
|
||||
.select({
|
||||
status: videoStoryboardShotTask.status,
|
||||
})
|
||||
.from(videoStoryboardShotTask)
|
||||
.where(eq(videoStoryboardShotTask.parentTaskId, taskId));
|
||||
|
||||
const total = rows.length;
|
||||
const success = rows.filter((row) => row.status === "completed").length;
|
||||
const failed = rows.filter((row) => row.status === "failed").length;
|
||||
const running = rows.filter((row) => row.status === "running").length;
|
||||
const pending = rows.filter((row) => row.status === "pending").length;
|
||||
|
||||
let status: VideoStoryboardTaskStatus = "pending";
|
||||
if (running > 0) {
|
||||
status = "running";
|
||||
} else if (success === total && total > 0) {
|
||||
status = "succeeded";
|
||||
} else if (failed === total && total > 0) {
|
||||
status = "failed";
|
||||
} else if (success > 0 && failed > 0) {
|
||||
status = "partial_success";
|
||||
} else if (success > 0 && pending > 0) {
|
||||
status = "running";
|
||||
} else if (failed > 0 && pending > 0) {
|
||||
status = "running";
|
||||
}
|
||||
|
||||
const currentShot = await db
|
||||
.select({
|
||||
maxShot: sql<number>`max(${videoStoryboardShotTask.shotIndex})::int`,
|
||||
})
|
||||
.from(videoStoryboardShotTask)
|
||||
.where(
|
||||
and(
|
||||
eq(videoStoryboardShotTask.parentTaskId, taskId),
|
||||
inArray(videoStoryboardShotTask.status, ["running", "completed", "failed"])
|
||||
)
|
||||
);
|
||||
|
||||
await db
|
||||
.update(videoStoryboardTask)
|
||||
.set({
|
||||
status,
|
||||
currentShot: Number(currentShot[0]?.maxShot || 0),
|
||||
completedAt:
|
||||
status === "succeeded" || status === "partial_success" || status === "failed"
|
||||
? new Date()
|
||||
: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(videoStoryboardTask.id, taskId));
|
||||
|
||||
return { total, success, failed, status };
|
||||
}
|
||||
|
||||
export async function getVideoStoryboardTaskDetail(taskId: string) {
|
||||
const taskRows = await db
|
||||
.select()
|
||||
.from(videoStoryboardTask)
|
||||
.where(eq(videoStoryboardTask.id, taskId))
|
||||
.limit(1);
|
||||
const task = taskRows[0];
|
||||
if (!task) return null;
|
||||
|
||||
const shots = await db
|
||||
.select()
|
||||
.from(videoStoryboardShotTask)
|
||||
.where(eq(videoStoryboardShotTask.parentTaskId, taskId))
|
||||
.orderBy(asc(videoStoryboardShotTask.shotIndex));
|
||||
|
||||
return { task, shots };
|
||||
}
|
||||
|
||||
export async function listVideoStoryboardTasks(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: string;
|
||||
providerType?: string;
|
||||
chatId?: string;
|
||||
userId?: string;
|
||||
}) {
|
||||
const conditions: SQL[] = [];
|
||||
if (params.status) {
|
||||
conditions.push(eq(videoStoryboardTask.status, params.status));
|
||||
}
|
||||
if (params.providerType) {
|
||||
conditions.push(eq(videoStoryboardTask.providerType, params.providerType));
|
||||
}
|
||||
if (params.chatId) {
|
||||
conditions.push(eq(videoStoryboardTask.chatId, params.chatId));
|
||||
}
|
||||
if (params.userId) {
|
||||
conditions.push(eq(videoStoryboardTask.userId, params.userId));
|
||||
}
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const page = Math.max(1, Math.round(params.page || 1));
|
||||
const pageSize = Math.max(1, Math.min(200, Math.round(params.pageSize || 20)));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const [countRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(videoStoryboardTask)
|
||||
.where(whereClause);
|
||||
|
||||
const tasks = await db
|
||||
.select()
|
||||
.from(videoStoryboardTask)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(videoStoryboardTask.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return {
|
||||
total: Number(countRow?.count || 0),
|
||||
page,
|
||||
pageSize,
|
||||
tasks: [] as Array<any>,
|
||||
};
|
||||
}
|
||||
|
||||
const taskIds = tasks.map((item) => item.id);
|
||||
const shotRows = await db
|
||||
.select({
|
||||
parentTaskId: videoStoryboardShotTask.parentTaskId,
|
||||
status: videoStoryboardShotTask.status,
|
||||
})
|
||||
.from(videoStoryboardShotTask)
|
||||
.where(inArray(videoStoryboardShotTask.parentTaskId, taskIds));
|
||||
|
||||
const summaryMap = new Map<
|
||||
string,
|
||||
{ totalShots: number; completedShots: number; failedShots: number; runningShots: number }
|
||||
>();
|
||||
for (const row of shotRows) {
|
||||
const summary =
|
||||
summaryMap.get(row.parentTaskId) ||
|
||||
{ totalShots: 0, completedShots: 0, failedShots: 0, runningShots: 0 };
|
||||
summary.totalShots += 1;
|
||||
if (row.status === "completed") summary.completedShots += 1;
|
||||
if (row.status === "failed") summary.failedShots += 1;
|
||||
if (row.status === "running") summary.runningShots += 1;
|
||||
summaryMap.set(row.parentTaskId, summary);
|
||||
}
|
||||
|
||||
return {
|
||||
total: Number(countRow?.count || 0),
|
||||
page,
|
||||
pageSize,
|
||||
tasks: tasks.map((task) => ({
|
||||
...task,
|
||||
summary: summaryMap.get(task.id) || {
|
||||
totalShots: 0,
|
||||
completedShots: 0,
|
||||
failedShots: 0,
|
||||
runningShots: 0,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@ -7,6 +7,7 @@ export type NormalizedStoryboardShot = {
|
||||
camera: string;
|
||||
mood: string;
|
||||
negativePrompt: string;
|
||||
preferredTileIndex?: number;
|
||||
};
|
||||
|
||||
export type NormalizedStoryboardScript = {
|
||||
@ -126,6 +127,8 @@ export type StoryboardProgressEvent =
|
||||
};
|
||||
|
||||
export type RuxaNineGridPipelineParams = {
|
||||
userId: string;
|
||||
chatId: string;
|
||||
modelMessages: Array<{ role: string; content: any }>;
|
||||
selectedModelId: string;
|
||||
selectedModelRef: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user