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
);
}