209 lines
7.5 KiB
TypeScript
209 lines
7.5 KiB
TypeScript
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<typeof canvasTaskRequestSchema>;
|
|
export type CanvasGenerationTask = {
|
|
id: string;
|
|
requestId: string;
|
|
status: string;
|
|
createdAt: string;
|
|
request: CanvasTaskRequest;
|
|
result?: Record<string, any> | 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<string, any> | 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<string, any>; [key: string]: any },
|
|
>(nodes: T[], tasks: CanvasGenerationTask[], projectRevision: string) {
|
|
let result = nodes;
|
|
const latestByNode = new Map<string, CanvasGenerationTask>();
|
|
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;
|
|
}
|