399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
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<T extends GroupNode>(
|
|
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<T extends GroupNode>(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<T extends GroupNode>(
|
|
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<T extends GroupNode>(
|
|
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<T extends GroupNode>(
|
|
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<T extends GroupNode>(
|
|
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<T extends GroupNode>(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<T extends GroupNode>(
|
|
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<T extends GroupNode>(
|
|
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<T extends GroupNode>(
|
|
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
|
|
);
|
|
}
|