feat(video): storyboard task tables and delayed archive flow
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-05 15:58:43 +08:00
parent 20898e1ac1
commit 1967992de7
16 changed files with 1143 additions and 103 deletions

View File

@ -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,

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@ -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,

View File

@ -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,

View 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");

View File

@ -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>;

View File

@ -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() {
};
}
}

View File

@ -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();

View File

@ -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,

View File

@ -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),
});
}

View File

@ -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,

View File

@ -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,

View 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,
},
})),
};
}

View File

@ -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;