import { hasRecoverableCanvasTask } from "./task-recovery"; import { normalizeVideoAnalysisRows } from "./video-story"; 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", "audio", "smart-edit", "video-upscale", ]), 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()), workflow: z .object({ runId: z.string().uuid(), groupId: z.string().min(1).max(180), inputs: z .array( z.object({ output: z.literal("edit-plan").optional(), requestId: z.string().uuid().optional(), node: z.object({ id: z.string().min(1).max(180), title: z.string().optional(), kind: z.string().optional(), inputType: z.string().optional(), draftValues: z.record(z.any()).optional(), }), }) ) .max(30), }) .optional(), 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; updatedAt?: string; attempts?: number; 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("分析没有返回结果,原内容已保留"); if ( mode === "video-analysis" && !normalizeVideoAnalysisRows(data.analysisRows).length ) throw Error("视频解析没有返回有效镜头,原内容已保留"); if ( mode === "director-reconstruct" && (!Array.isArray(data.directorScene?.objects) || !data.directorScene.objects.length || !Array.isArray(data.directorScene?.cameras) || !data.directorScene.cameras.length) ) 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" && mode !== "video-analysis" && ![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; } const appliedStatus = `${task.id}:${task.status}${["smart-edit", "video-upscale"].includes(task.request.kind) ? `:${task.result?.upscale?.stage || task.result?.smartEdit?.stage || ""}` : ""}`; if (draft.appliedTaskStatus === appliedStatus) continue; const nextDraft = { ...draft, taskRequestId: task.requestId, generationTaskId: task.id, appliedTaskStatus: appliedStatus, generationError: undefined as string | undefined, generationProgress: 0, generationStatus: "pending", pendingLabel: task.status === "pending" ? "排队中" : task.result?.upscale?.stage || task.result?.smartEdit?.stage || "生成中", }; 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 === "smart-edit") { if (task.request.params.operation !== "render") { const saved = draft.smartEditDraft; const processing = ["separate", "transcribe"].includes( String(task.request.params.operation) ); const plans = processing && saved?.plans?.length ? saved.plans.map((plan: unknown, i: number) => i === (saved.selectedPlanIndex || 0) ? data.plans?.[0] || plan : plan ) : data.plans; Object.assign(nextDraft, { smartEditPlans: plans, smartEditPlanTaskId: task.id, }); if (processing && saved) Object.assign(nextDraft, { smartEditDraft: { ...saved, plans, planTaskId: task.id }, }); } else Object.assign(nextDraft, { mediaUrl: data.url, url: [data.url], videoMeta: data.metadata, durationSeconds: data.metadata?.durationSeconds, subtitleUrl: data.subtitleUrl, editPlan: data.plan, action: "VIDEO_RESOURCE", mediaType: "video", }); } if (task.request.kind === "audio") Object.assign(nextDraft, { mediaUrl: data.url, url: [data.url], action: "AUDIO_RESOURCE", mediaType: "audio", audioMeta: data.metadata, audioParams: task.request.node.draftValues?.audioParams || task.request.params, }); if (["video", "video-upscale"].includes(task.request.kind)) 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" && task.request.params.mode === "director-reconstruct" ) Object.assign(nextDraft, { directorScene: data.directorScene, reconstructionSummary: data.summary, }); 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" ? "已取消排队,未提交供应商" : "任务失败,请查看任务状态"), }); if (task.status === "interrupted" && hasRecoverableCanvasTask(task)) nextDraft.generationError = "任务查询中断,稍后会自动查询原任务,请勿重复生成。"; subtitle = task.status === "cancelled" ? "已取消" : task.status === "interrupted" ? hasRecoverableCanvasTask(task) ? "等待续查" : "待核实" : "生成失败"; } if ( task.request.kind === "analysis" && task.request.params.mode === "video-analysis" ) { Object.assign(nextDraft, { status: task.status === "succeeded" ? "ready" : isCanvasTaskActive(task.status) ? "loading" : "error", error: nextDraft.generationError, }); if (task.status === "succeeded") { Object.assign(nextDraft, { rows: normalizeVideoAnalysisRows( task.result?.analysisRows, task.result?.framePreviews || [], source.id ), framePreviews: task.result?.framePreviews || [], summary: task.result?.summary || "", }); } } result = result.map((item, i) => i === index ? { ...item, ...(task.status === "succeeded" && task.request.kind !== "analysis" && !( task.request.kind === "smart-edit" && task.request.params.operation !== "render" ) ? { kind: "media", inputType: ["video", "video-upscale"].includes(task.request.kind) || task.request.kind === "smart-edit" ? "video" : task.request.kind === "audio" ? "audio" : "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; }