diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 12bca3478..318324de8 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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 | 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, diff --git a/app/api/admin/video-storyboard-tasks/[taskId]/rerun/route.ts b/app/api/admin/video-storyboard-tasks/[taskId]/rerun/route.ts new file mode 100644 index 000000000..880fc5dbb --- /dev/null +++ b/app/api/admin/video-storyboard-tasks/[taskId]/rerun/route.ts @@ -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(/]+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 } + ); + } +} diff --git a/app/api/admin/video-storyboard-tasks/[taskId]/route.ts b/app/api/admin/video-storyboard-tasks/[taskId]/route.ts new file mode 100644 index 000000000..b290cb7ae --- /dev/null +++ b/app/api/admin/video-storyboard-tasks/[taskId]/route.ts @@ -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 } + ); + } +} diff --git a/app/api/admin/video-storyboard-tasks/route.ts b/app/api/admin/video-storyboard-tasks/route.ts new file mode 100644 index 000000000..6f64c5cea --- /dev/null +++ b/app/api/admin/video-storyboard-tasks/route.ts @@ -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 } + ); + } +} diff --git a/lib/ai/providers/fal-video.ts b/lib/ai/providers/fal-video.ts index 7bb30e5e7..95241f821 100644 --- a/lib/ai/providers/fal-video.ts +++ b/lib/ai/providers/fal-video.ts @@ -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 { - 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 `\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, diff --git a/lib/ai/providers/ruxa-video.ts b/lib/ai/providers/ruxa-video.ts index eea8a1b6b..a100f7f88 100644 --- a/lib/ai/providers/ruxa-video.ts +++ b/lib/ai/providers/ruxa-video.ts @@ -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 `\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, diff --git a/lib/db/migrations/0048_add_video_storyboard_tasks.sql b/lib/db/migrations/0048_add_video_storyboard_tasks.sql new file mode 100644 index 000000000..d2b2d9b6b --- /dev/null +++ b/lib/db/migrations/0048_add_video_storyboard_tasks.sql @@ -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"); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index eb58361c7..4a2e5282a 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -583,3 +583,59 @@ export const canvasNode = pgTable("CanvasNode", { }); export type CanvasNode = InferSelectModel; + +// 九宫格视频分镜任务主表 +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; + +// 九宫格视频分镜子任务表 +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; diff --git a/lib/jobs/video-archive-job.ts b/lib/jobs/video-archive-job.ts index be459233a..fa27ce6d8 100644 --- a/lib/jobs/video-archive-job.ts +++ b/lib/jobs/video-archive-job.ts @@ -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() { }; } } - diff --git a/lib/storage/video-archiver.ts b/lib/storage/video-archiver.ts index ae5df1f11..525e59490 100644 --- a/lib/storage/video-archiver.ts +++ b/lib/storage/video-archiver.ts @@ -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 { - // 直接使用项目已有的 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(); - diff --git a/lib/video-agent/pipeline/ruxa-nine-grid-pipeline.ts b/lib/video-agent/pipeline/ruxa-nine-grid-pipeline.ts index bb17da795..6ba654550 100644 --- a/lib/video-agent/pipeline/ruxa-nine-grid-pipeline.ts +++ b/lib/video-agent/pipeline/ruxa-nine-grid-pipeline.ts @@ -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 { 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, diff --git a/lib/video-agent/skills/script-normalizer.ts b/lib/video-agent/skills/script-normalizer.ts index ec6a56f8f..88f41b082 100644 --- a/lib/video-agent/skills/script-normalizer.ts +++ b/lib/video-agent/skills/script-normalizer.ts @@ -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), }); } diff --git a/lib/video-agent/skills/storyboard-mapper.ts b/lib/video-agent/skills/storyboard-mapper.ts index ef8131e43..edda1bea5 100644 --- a/lib/video-agent/skills/storyboard-mapper.ts +++ b/lib/video-agent/skills/storyboard-mapper.ts @@ -48,8 +48,28 @@ export function runStoryboardMapperSkill(params: { const { script, tiles } = params; if (!tiles.length) return []; + const usedTileIndexes = new Set(); + 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, diff --git a/lib/video-agent/skills/video-sequencer.ts b/lib/video-agent/skills/video-sequencer.ts index f4440fb0b..380c5aa4f 100644 --- a/lib/video-agent/skills/video-sequencer.ts +++ b/lib/video-agent/skills/video-sequencer.ts @@ -36,7 +36,7 @@ export async function runVideoSequencerSkill(params: { maxRetry: number; dispatchIntervalSec: number; enableFallback: boolean; - onProgress?: (event: StoryboardProgressEvent) => void; + onProgress?: (event: StoryboardProgressEvent) => void | Promise; 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, diff --git a/lib/video-agent/task-store.ts b/lib/video-agent/task-store.ts new file mode 100644 index 000000000..af0fbe716 --- /dev/null +++ b/lib/video-agent/task-store.ts @@ -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 | 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`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`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, + }; + } + + 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, + }, + })), + }; +} diff --git a/lib/video-agent/types.ts b/lib/video-agent/types.ts index ab5606a2b..bac5cda30 100644 --- a/lib/video-agent/types.ts +++ b/lib/video-agent/types.ts @@ -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;