import { storyTaskAttachmentSchema, mergeStoryTaskResult } from "./story-task-attachment"; import { z } from "zod"; export const canvasTaskRequestSchema = z.object({ requestId: z.string().uuid(), projectId: z.string().uuid(), kind: z.enum(["video", "image", "analysis", "image-operation"]), node: z .object({ id: z.string().min(1).max(180), title: z.string().max(300), x: z.number().finite(), y: z.number().finite(), width: z.number().positive().max(10000), height: z.number().positive().max(10000), kind: z.string().max(30), inputType: z.string().max(30).optional(), draftValues: z.record(z.unknown()).optional(), }) .passthrough(), params: z.record(z.unknown()), attachment: storyTaskAttachmentSchema.optional(), hiddenNodeIds: z.array(z.string()).max(20).optional(), edges: z .array(z.object({ id: z.string(), source: z.string(), target: z.string() })) .max(30) .default([]), }); export type CanvasTaskRequest = z.infer; export type CanvasGenerationTask = { id: string; requestId: string; status: string; createdAt: string; request: CanvasTaskRequest; result?: Record | null; error?: string | null; }; export function isCanvasTaskActive(status: string) { return status === "pending" || status === "running"; } export function canCancelCanvasTask(status: string) { return status === "pending"; } export function requireAnalysisResult(data: Record | undefined, mode: string) { if (!data) throw Error("分析没有返回结果,原内容已保留"); const rows = data.analysisRows || data.shotIdeas; if ( mode === "story-script" && (!Array.isArray(rows) || !rows.length || !rows.some((row) => [row.sceneDescription, row.description, row.imagePrompt].some( (value) => typeof value === "string" && value.trim(), ), )) ) { throw Error("脚本没有返回有效镜头,原内容已保留"); } if ( mode !== "story-script" && ![data.raw, data.summary, ...(data.agentPlan || [])].some( (value) => typeof value === "string" && value.trim(), ) ) { throw Error("分析返回了空内容,原内容已保留"); } return data; } // Stable result IDs and request IDs prevent repeat polling from duplicating images // or an old task from overwriting a later generation on the same node. export function reconcileCanvasTasks< T extends { id: string; draftValues?: Record; [key: string]: any }, >(nodes: T[], tasks: CanvasGenerationTask[], projectRevision: string) { let result = nodes; const latestByNode = new Map(); for (const task of tasks) { const previous = latestByNode.get(task.request.node.id); if (!previous || Date.parse(task.createdAt) >= Date.parse(previous.createdAt)) latestByNode.set(task.request.node.id, task); } for (const task of latestByNode.values()) { const source = task.request.node; const hidden = new Set(task.request.hiddenNodeIds || []); if (hidden.has(source.id)) continue; let index = result.findIndex((node) => node.id === source.id); if (index < 0) { if (Date.parse(task.createdAt) <= Date.parse(projectRevision)) continue; result = [...result, source as unknown as T]; index = result.length - 1; } const node = result[index]; const draft = node.draftValues || {}; if (draft.taskRequestId && draft.taskRequestId !== task.requestId) { const savedTask = tasks.find((item) => item.requestId === draft.taskRequestId); if ( !savedTask || Date.parse(task.createdAt) <= Date.parse(savedTask.createdAt) || Date.parse(task.createdAt) <= Date.parse(projectRevision) ) continue; } if (draft.appliedTaskStatus === `${task.id}:${task.status}`) continue; const nextDraft = { ...draft, taskRequestId: task.requestId, generationTaskId: task.id, appliedTaskStatus: `${task.id}:${task.status}`, generationError: undefined as string | undefined, generationProgress: 0, generationStatus: "pending", pendingLabel: task.status === "pending" ? "排队中" : "生成中", }; let subtitle = nextDraft.pendingLabel; if (task.status === "succeeded") { const data = task.result || {}; Object.assign(nextDraft, { generated: true, generationStatus: "complete", generationProgress: 100, pendingLabel: "", }); subtitle = "生成结果"; if (task.request.kind === "video") Object.assign(nextDraft, { mediaUrl: data.url, url: [data.url], videoMeta: data.metadata }); if (task.request.kind === "image" || task.request.kind === "image-operation") { const urls = (data.images || []) .map((image: any) => image.originalUrl || image.thumbnailUrl) .filter(Boolean); Object.assign(nextDraft, { mediaUrl: urls[0], url: urls, action: draft.action === "IMAGE_GENERATE_NINE_GRID" ? draft.action : "IMAGE_RESOURCE", }); for (let i = 1; i < urls.length; i++) { const id = `${source.id}-result-${task.id}-${i}`; if (!hidden.has(id) && !result.some((item) => item.id === id)) result = [ ...result, { ...node, id, title: `${node.title} ${i + 1}`, y: node.y + i * (node.height + 40), draftValues: { ...nextDraft, mediaUrl: urls[i], url: [urls[i]] }, } as T, ]; } } if (task.request.kind === "analysis") Object.assign(nextDraft, { content: [data.raw || data.summary || data.agentPlan?.join("\n") || ""], ...(Array.isArray(data.analysisRows || data.shotIdeas) ? { rows: data.analysisRows || data.shotIdeas } : {}), }); } else if (["failed", "cancelled", "interrupted"].includes(task.status)) { Object.assign(nextDraft, { generated: false, generationStatus: "failed", pendingLabel: "", generationError: task.error || (task.status === "cancelled" ? "已取消排队,未提交供应商" : "任务失败,请查看任务状态"), }); subtitle = task.status === "cancelled" ? "已取消" : task.status === "interrupted" ? "待核实" : "生成失败"; } result = result.map((item, i) => i === index ? { ...item, ...(task.status === "succeeded" && task.request.kind !== "analysis" ? { kind: "media", inputType: task.request.kind === "video" ? "video" : "image" } : {}), subtitle, draftValues: nextDraft, } : item, ); } for (const task of tasks) { const attachment = task.request.attachment; if ( task.status !== "succeeded" || !attachment || task.request.hiddenNodeIds?.includes(task.request.node.id) || !result.some((node) => node.id === task.request.node.id) ) continue; const targetIndex = result.findIndex((node) => node.id === attachment.nodeId); if (targetIndex < 0) continue; const node = result[targetIndex], draft = node.draftValues || {}; const next = mergeStoryTaskResult(draft, attachment, task.result || {}, task.id); if (next !== draft) result = result.map((item, index) => index === targetIndex ? { ...item, draftValues: next } : item, ); } return result; }