117 lines
6.4 KiB
TypeScript
117 lines
6.4 KiB
TypeScript
/** Directed canvas edges are live inputs, not a cache of earlier request URLs. */
|
|
export type CanvasConnection = { id: string; source: string; target: string };
|
|
export type ConnectionNode = {
|
|
id: string;
|
|
title?: string;
|
|
kind?: string;
|
|
inputType?: string;
|
|
draftValues?: Record<string, any>;
|
|
};
|
|
|
|
export function connectionError(nodes: ConnectionNode[], edges: CanvasConnection[], source: string, target: string) {
|
|
if (source === target) return "不能连接到同一个节点";
|
|
const from = nodes.find(node => node.id === source), to = nodes.find(node => node.id === target);
|
|
if (!from || !to) return "连接的节点已不存在";
|
|
if ([from, to].some(node => node.kind === "group" || node.draftValues?.action === "TOOLBOX_TEMPLATE_GROUP"))
|
|
return "请连接分组内的具体节点,分组框不是素材输入";
|
|
if (edges.some(edge => edge.source === source && edge.target === target)) return "这两个节点已经连接";
|
|
const pending = [target], seen = new Set<string>();
|
|
while (pending.length) {
|
|
const id = pending.pop()!;
|
|
if (id === source) return "这条连线会形成循环依赖,请调整连接方向";
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
pending.push(...edges.filter(edge => edge.source === id).map(edge => edge.target));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function connectedInputNodes<T extends ConnectionNode>(nodes: T[], edges: CanvasConnection[], target: string): T[] {
|
|
const seen = new Set<string>();
|
|
return edges.filter(edge => edge.target === target).flatMap(edge => {
|
|
if (seen.has(edge.source)) return [];
|
|
seen.add(edge.source);
|
|
const node = nodes.find(item => item.id === edge.source);
|
|
if (!node) throw Error("上游节点已删除,请移除失效连线后重试");
|
|
// Also reject cycles restored from an older saved canvas.
|
|
const remaining = edges.filter(item => item.id !== edge.id);
|
|
const error = connectionError(nodes, remaining, edge.source, target);
|
|
if (error && !error.includes("已经连接")) throw Error(error);
|
|
return [node];
|
|
});
|
|
}
|
|
|
|
export function connectedText(node: ConnectionNode) {
|
|
if (node.inputType !== "text" && node.kind !== "script" && node.kind !== "text") return "";
|
|
const content = node.draftValues?.content;
|
|
if (typeof content === "string") return content.trim();
|
|
if (Array.isArray(content)) return content.filter(value => typeof value === "string").join("\n").trim();
|
|
const rows = node.draftValues?.rows;
|
|
return Array.isArray(rows) ? rows.map(row => [row.shot, row.sceneDescription, row.dialogue, row.imagePrompt, row.motionPrompt].filter(Boolean).join(" · ")).join("\n").trim() : "";
|
|
}
|
|
|
|
export function assertConnectedInputsReady(nodes: ConnectionNode[]) {
|
|
for (const node of nodes) {
|
|
const draft = node.draftValues || {};
|
|
if (["pending", "running", "queued", "failed", "cancelled", "canceled", "interrupted"].includes(draft.generationStatus))
|
|
throw Error(`上游「${node.title || node.id}」尚未成功完成,请先完成该节点`);
|
|
if (node.inputType === "text" || node.kind === "script" || node.kind === "text") {
|
|
if (!connectedText(node)) throw Error(`上游「${node.title || node.id}」没有文本内容`);
|
|
} else if (["image", "video", "audio"].includes(node.inputType || "")) {
|
|
// sourceUrl/poster is an input preview; it is never proof of a generated output.
|
|
const output = draft.mediaUrl || draft.resultImage || draft.url?.[0] || draft.thumbnailUrl;
|
|
if (!output) throw Error(`上游「${node.title || node.id}」尚无可用素材,请先上传或生成`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const inputStart = "\n\n<canvas-connected-text>\n";
|
|
const inputEnd = "\n</canvas-connected-text>";
|
|
export function withConnectedText(prompt: string, nodes: ConnectionNode[]) {
|
|
// Remove only a section created by this resolver, so editing/disconnecting an
|
|
// upstream text node replaces its previous content on a subsequent run.
|
|
const start = prompt.indexOf(inputStart);
|
|
const base = start >= 0 && prompt.endsWith(inputEnd) ? prompt.slice(0, start) : prompt;
|
|
const text = nodes.map(connectedText).filter(Boolean).join("\n\n");
|
|
return text ? `${base.trim()}${inputStart}${text}${inputEnd}` : base.trim();
|
|
}
|
|
|
|
export function connectedImageUrls(lists: string[][], extra: string[] = []) {
|
|
// Keep edge order and identical URLs in separate slots (first/last frame).
|
|
return [...lists.flat(), ...extra].map(url => url.trim()).filter(Boolean);
|
|
}
|
|
|
|
|
|
export function connectionTypeError(source: ConnectionNode, target: ConnectionNode) {
|
|
const type = source.inputType || source.draftValues?.mediaType || (source.kind === "text" || source.kind === "script" ? "text" : "");
|
|
const draft = target.draftValues || {};
|
|
const key = draft.functionKey || draft.params?.functionKey || draft.targetPreset;
|
|
const nodeType = draft.nodeType;
|
|
if (source.kind === "material-effect" || source.kind === "material-role")
|
|
return target.inputType === "video" && nodeType !== "smart-edit" ? null : "角色和视频特效素材只能连接到视频生成节点";
|
|
const allowed = nodeType === "smart-edit" || nodeType === "video-clip" || draft.action === "VIDEO_HD_UPSCALE" ? ["video"]
|
|
: target.inputType === "image" ? ["image", "text"]
|
|
: target.inputType === "video" ? (key === "audio-to-video" ? ["image", "text", "audio"] : ["image", "text"])
|
|
: target.inputType === "audio" ? ["text"]
|
|
: target.inputType === "text" || target.kind === "script" || target.kind === "text" ? ["image", "video", "text"]
|
|
: null;
|
|
return allowed && !allowed.includes(type) ? "此节点不能消费该类型的输入,请连接匹配的素材或文本节点" : null;
|
|
}
|
|
|
|
|
|
export function videoInputLimitError(mode: string, count: number) {
|
|
if (mode === "text-to-video" && count) return "已连接图片,请切换图生视频或全能参考模式后生成";
|
|
if (mode === "first-frame" && count > 1) return "首帧模式只能使用 1 张图片,请移除多余连线或切换模式";
|
|
if (mode === "first-last-frame" && count > 2) return "首尾帧模式只能使用 2 张图片,请移除多余连线或切换模式";
|
|
if (mode === "reference-images" && count > 8) return "全能参考最多使用 8 张图片,请减少输入";
|
|
return null;
|
|
}
|
|
|
|
|
|
export function requireResolvedReferenceImages(urls: string[], expectedCount: number) {
|
|
const images = urls.map(url => url.trim());
|
|
if (images.length !== expectedCount || images.some(url => !url))
|
|
throw Error("部分宫格参考图准备失败,尚未提交生成,请重试;不会使用整张原图代替");
|
|
return images;
|
|
}
|