/** 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; }; 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(); 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(nodes: T[], edges: CanvasConnection[], target: string): T[] { const seen = new Set(); 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 new 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 new 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 (draft.uploadStatus === "uploading") throw new Error(`上游「${node.title || node.id}」仍在上传,请等待上传完成`); if (draft.uploadStatus === "failed") throw new Error(`上游「${node.title || node.id}」上传失败,请重新选择素材`); if (["pending", "running", "queued", "failed", "cancelled", "canceled", "interrupted"].includes(draft.generationStatus)) throw new Error(`上游「${node.title || node.id}」尚未成功完成,请先完成该节点`); if (node.inputType === "text" || node.kind === "script" || node.kind === "text") { if (!connectedText(node)) throw new 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 new Error(`上游「${node.title || node.id}」尚无可用素材,请先上传或生成`); } } } const inputStart = "\n\n\n"; const inputEnd = "\n"; 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" || nodeType === "frame-analysis" || 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 new Error("部分宫格参考图准备失败,尚未提交生成,请重试;不会使用整张原图代替"); return images; }