import { connectionError } from "./connections"; import type { CanvasConnection, ConnectionNode } from "./connections"; export type GroupNode = ConnectionNode & { x: number; y: number; width: number; height: number; title: string; }; export const isCanvasGroup = (node: ConnectionNode) => node.draftValues?.action === "TOOLBOX_TEMPLATE_GROUP"; export const parentGroup = (node: ConnectionNode) => String(node.draftValues?.parentGroupId || ""); export function groupMembers( nodes: T[], groupId: string, deep = true ): T[] { const result: T[] = [], seen = new Set([groupId]); const visit = (id: string) => { for (const node of nodes.filter((n) => parentGroup(n) === id)) { if (seen.has(node.id)) throw Error("编组包含循环成员关系"); seen.add(node.id); result.push(node); if (deep && isCanvasGroup(node)) visit(node.id); } }; visit(groupId); return result; } export function groupBounds(nodes: GroupNode[]) { if (!nodes.length) return { x: 0, y: 0, width: 400, height: 260 }; const x = Math.min(...nodes.map((n) => n.x)) - 32, y = Math.min(...nodes.map((n) => n.y)) - 48; return { x, y, width: Math.max(...nodes.map((n) => n.x + n.width)) - x + 32, height: Math.max(...nodes.map((n) => n.y + n.height)) - y + 32, }; } export function selectedRoots(nodes: T[], ids: string[]) { const selected = new Set(ids); return nodes.filter( (node) => selected.has(node.id) && !nodes.some( (group) => selected.has(group.id) && isCanvasGroup(group) && groupMembers(nodes, group.id).some((member) => member.id === node.id) ) ); } export function makeCanvasGroup( nodes: T[], ids: string[], id: string, title = "未命名编组" ) { const members = selectedRoots(nodes, ids); if (members.length < 2) throw Error("请至少选择两个节点进行编组"); const parents = new Set(members.map(parentGroup)), parent = parents.size === 1 ? parentGroup(members[0]) : ""; const group = { id, kind: "model", title, ...groupBounds(members), accent: "#a0a0a0", draftValues: { action: "TOOLBOX_TEMPLATE_GROUP", nodeType: "group", parentGroupId: parent, groupColor: "#a0a0a0", groupVersion: 1, }, }; const selected = new Set(members.map((n) => n.id)); return [ ...nodes.map((n) => selected.has(n.id) ? { ...n, draftValues: { ...n.draftValues, parentGroupId: id } } : n ), group as unknown as T, ]; } export function ungroupCanvasNodes( nodes: T[], groupId: string ) { const group = nodes.find((n) => n.id === groupId); if (!group || !isCanvasGroup(group)) throw Error("未找到编组"); return nodes .filter((n) => n.id !== groupId) .map((n) => parentGroup(n) === groupId ? { ...n, draftValues: { ...n.draftValues, parentGroupId: parentGroup(group), }, } : n ); } export function moveCanvasSelection( nodes: T[], ids: string[], dx: number, dy: number ) { const moving = new Set(ids); for (const id of ids) if (nodes.some((n) => n.id === id && isCanvasGroup(n))) for (const child of groupMembers(nodes, id)) moving.add(child.id); let result = nodes.map((n) => moving.has(n.id) ? { ...n, x: n.x + dx, y: n.y + dy } : n ); // Expand stationary ancestor frames when a member is dragged outside them. for (let pass = 0; pass < nodes.length; pass++) { let changed = false; result = result.map((group) => { if (!isCanvasGroup(group) || moving.has(group.id)) return group; const children = groupMembers(result, group.id, false); if (!children.length) return group; const bound = groupBounds(children), x = Math.min(group.x, bound.x), y = Math.min(group.y, bound.y); const width = Math.max(group.x + group.width, bound.x + bound.width) - x, height = Math.max(group.y + group.height, bound.y + bound.height) - y; if ( x === group.x && y === group.y && width === group.width && height === group.height ) return group; changed = true; return { ...group, x, y, width, height }; }); if (!changed) break; } return result; } export function layoutCanvasGroup( nodes: T[], groupId: string, layout: "grid" | "horizontal" | "vertical" ) { const group = nodes.find((n) => n.id === groupId); if (!group) throw Error("编组不存在"); const members = groupMembers(nodes, groupId, false); const cols = layout === "horizontal" ? members.length : layout === "vertical" ? 1 : Math.max(1, Math.ceil(Math.sqrt(members.length))); const cellWidth = Math.max(180, ...members.map((n) => n.width)) + 48, cellHeight = Math.max(120, ...members.map((n) => n.height)) + 64; let result = nodes; for (const [i, member] of members.entries()) result = moveCanvasSelection( result, [member.id], group.x + 32 + (i % cols) * cellWidth - member.x, group.y + 48 + Math.floor(i / cols) * cellHeight - member.y ); const bounds = groupBounds(groupMembers(result, groupId, false)); return result.map((n) => (n.id === groupId ? { ...n, ...bounds } : n)); } export function migrateLegacyGroups(nodes: T[]) { // Old preset frames stored geometry but no membership. Infer once on load. const groups = nodes.filter( (n) => isCanvasGroup(n) && !n.draftValues?.groupVersion ); return nodes.map((n) => { if (isCanvasGroup(n)) return { ...n, draftValues: { ...n.draftValues, groupVersion: 1 } }; if (parentGroup(n)) return n; const enclosing = groups .filter( (g) => n.x >= g.x && n.y >= g.y && n.x + n.width <= g.x + g.width && n.y + n.height <= g.y + g.height ) .sort((a, b) => a.width * a.height - b.width * b.height)[0]; return enclosing ? { ...n, draftValues: { ...n.draftValues, parentGroupId: enclosing.id } } : n; }); } export function exportCanvasGroup( nodes: T[], edges: CanvasConnection[], groupId: string ) { const group = nodes.find((n) => n.id === groupId); if (!group) throw Error("编组不存在"); let all = [group, ...groupMembers(nodes, groupId)]; const ids = new Set(all.map((n) => n.id)); const externalIds = [ ...new Set( edges .filter((e) => ids.has(e.target) && !ids.has(e.source)) .map((e) => e.source) ), ]; const inputs = externalIds.map((id, index) => { const source = nodes.find((n) => n.id === id); if ( !source || !["image", "video", "audio", "text"].includes(source.inputType || "") ) throw Error("请先将组外角色、特效等上游节点加入编组,再保存模板"); return { id, title: `输入:${source.title}`.slice(0, 120), kind: "input", inputType: source.inputType, x: group.x + 32, y: group.y + 48 + index * 210, width: 240, height: 160, accent: "#aaaaaa", draftValues: { action: `${source.inputType!.toUpperCase()}_RESOURCE`, parentGroupId: group.id, mediaType: source.inputType, url: [], content: [], generated: false, generationStatus: "idle", templateInput: true, }, } as unknown as T; }); if (inputs.length) { all = all.map((n) => n.id === group.id ? { ...n, width: n.width + 280, height: Math.max(n.height, inputs.length * 210 + 80), } : { ...n, x: n.x + 280 } ); all.push(...inputs); for (const input of inputs) ids.add(input.id); } return { version: 1, name: group.title, nodes: all.map((n) => ({ ...n, draftValues: { ...n.draftValues, params: n.draftValues?.params ? { ...n.draftValues.params, taskId: undefined, sourceNodeId: undefined, } : undefined, ...(n.id === groupId ? { parentGroupId: "" } : {}), generationTaskId: undefined, taskRequestId: undefined, appliedTaskStatus: undefined, generationStatus: n.draftValues?.mediaUrl || n.draftValues?.content ? "complete" : "idle", generationError: undefined, pendingLabel: undefined, }, })), edges: edges.filter((e) => ids.has(e.source) && ids.has(e.target)), }; } export function instantiateCanvasGroup( template: { nodes: T[]; edges: CanvasConnection[] }, id: () => string, x: number, y: number ) { if ( !template || !Array.isArray(template.nodes) || !template.nodes.length || template.nodes.length > 100 ) throw Error("编组模板节点数量无效"); if ( !Array.isArray(template.edges) || template.edges.length > 300 || new Set(template.nodes.map((n) => n.id)).size !== template.nodes.length || template.nodes.some( (n) => !n.id || ![n.x, n.y, n.width, n.height].every(Number.isFinite) || n.width <= 0 || n.height <= 0 || n.width > 10000 || n.height > 10000 ) ) throw Error("编组模板数据无效"); for (const node of template.nodes) { if ( parentGroup(node) && !template.nodes.some( (n) => n.id === parentGroup(node) && isCanvasGroup(n) ) ) throw Error("编组模板成员关系无效"); if (isCanvasGroup(node)) groupMembers(template.nodes, node.id); } for (const edge of template.edges) { const error = connectionError( template.nodes, template.edges.filter((e) => e !== edge), edge.source, edge.target ); if (error) throw Error(error); } const map = new Map(template.nodes.map((n) => [n.id, id()])); const root = template.nodes.find((n) => isCanvasGroup(n) && !parentGroup(n)); if (!root) throw Error("编组模板缺少根组"); if ( groupMembers(template.nodes, root.id).length !== template.nodes.length - 1 ) throw Error("模板必须只有一个完整根组"); const nodes = template.nodes.map((n) => ({ ...n, id: map.get(n.id)!, x: n.x + x - root.x, y: n.y + y - root.y, draftValues: { ...n.draftValues, parentGroupId: map.get(parentGroup(n)) || "", shotNodeIds: n.draftValues?.shotNodeIds ?.map((key: string) => map.get(key)) .filter(Boolean), sourceNodeId: map.get(n.draftValues?.sourceNodeId) || undefined, sourceVideoNodeId: map.get(n.draftValues?.sourceVideoNodeId) || undefined, }, })); return { nodes, edges: template.edges .map((e) => ({ ...e, id: id(), source: map.get(e.source)!, target: map.get(e.target)!, })) .filter((e) => e.source && e.target), groupId: map.get(root.id)!, }; } export function assignGroupMembers( nodes: T[], ids: string[], groupId: string ) { if (groupId && !nodes.some((n) => n.id === groupId && isCanvasGroup(n))) throw Error("编组不存在"); const roots = selectedRoots(nodes, ids); if ( roots.some( (n) => n.id === groupId || (isCanvasGroup(n) && groupMembers(nodes, n.id).some((child) => child.id === groupId)) ) ) throw Error("不能将编组加入自身或自己的子组"); const selected = new Set(roots.map((n) => n.id)); return moveCanvasSelection( nodes.map((n) => selected.has(n.id) ? { ...n, draftValues: { ...n.draftValues, parentGroupId: groupId } } : n ), [], 0, 0 ); }