@uzooai/anime-ai-cli 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/uzoo-anime-cli/SKILL.md +83 -0
- package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +51 -0
- package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
- package/.agents/skills/uzoo-anime-cli/references/commands.md +250 -0
- package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +152 -0
- package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +175 -0
- package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
- package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +123 -0
- package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +95 -0
- package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +94 -0
- package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
- package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +300 -0
- package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +41 -0
- package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +71 -0
- package/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +242 -3
- package/assets/README.md +4 -0
- package/assets/favicon-uzoo.png +0 -0
- package/assets/logo-uzoo.svg +7 -0
- package/docs/CLI/345/275/223/345/211/215/350/214/203/345/233/264/344/270/216/346/234/254/345/234/260/346/211/223/345/214/205-20261103.md +134 -0
- package/docs/CLI/346/216/210/346/235/203/346/216/245/345/217/243/344/270/216/344/273/244/347/211/214/350/207/252/345/212/250/345/210/267/346/226/260/350/256/276/350/256/241.md +540 -0
- package/docs/CLI/347/274/272/345/217/243/346/240/270/345/257/271/344/270/216/346/224/266/345/260/276-20261105.md +65 -0
- package/docs/CLI/347/275/221/351/241/265/344/277/235/345/255/230/350/212/202/347/202/271/347/274/272/345/244/261/344/272/244/346/216/245.md +52 -0
- package/docs/M0-M5/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +89 -0
- package/docs/npm/347/272/277/344/270/212/345/217/221/345/270/203/344/270/216/345/256/211/350/243/205/344/275/277/347/224/250.md +151 -0
- package/docs//344/270/213/350/275/275/346/216/245/345/217/243/351/200/202/351/205/215/344/270/216/351/252/214/346/224/266.md +107 -0
- package/docs//344/270/232/345/212/241/350/203/275/345/212/233/350/241/245/345/205/250/344/270/216Skill/347/273/223/346/236/204/350/247/204/345/210/222.md +311 -0
- package/docs//345/205/250/345/261/200/351/231/204/344/273/266/350/265/204/346/272/220/345/234/260/345/235/200/344/270/216/346/250/241/345/236/213/350/257/273/345/217/226/345/267/256/345/274/202.md +47 -0
- package/docs//345/206/205/351/203/250Windows/345/256/211/350/243/205/344/270/216/345/215/207/347/272/247/351/252/214/346/224/266/346/270/205/345/215/225.md +34 -0
- package/docs//345/233/276/347/211/207/346/212/240/345/233/276/346/216/245/345/205/245/344/270/216/351/252/214/346/224/266-20261104.md +110 -0
- package/docs//345/274/200/345/217/221/350/256/241/345/210/222.md +127 -0
- package/docs//345/274/202/346/255/245/346/212/240/345/233/276CLI/346/235/203/351/231/220/344/272/244/346/216/245-20261105.md +50 -0
- package/docs//345/274/202/346/255/245/346/212/240/345/233/276/347/234/237/345/256/236/351/252/214/346/224/266-20261105.md +44 -0
- package/docs//346/226/260/344/274/232/350/257/235/344/273/273/345/212/241/344/272/244/344/273/243-/351/230/266/346/256/2652A.md +79 -0
- package/docs//346/226/260/351/203/250/347/275/262/347/224/273/345/270/203/345/206/262/347/252/201/351/252/214/346/224/266-20261102.md +61 -0
- package/docs//346/231/256/351/200/232/346/265/217/350/247/210/345/231/250/347/231/273/345/275/225.md +53 -0
- package/docs//346/255/243/345/274/217/347/253/231/345/210/207/346/215/242/344/270/216/351/252/214/346/224/266-20261104.md +90 -0
- package/docs//347/224/273/345/270/203CLI/344/270/213/344/270/200/351/230/266/346/256/265/345/274/200/345/217/221/350/256/241/345/210/222.md +136 -0
- package/docs//347/224/273/345/270/203CLI/351/235/236/350/275/254/347/273/230/345/256/214/345/226/204/350/256/241/345/210/222.md +183 -0
- package/docs//347/224/273/345/270/203/350/203/275/345/212/233/345/245/221/347/272/246/346/240/270/345/257/271.md +59 -0
- package/docs//347/234/237/345/256/236/344/270/232/345/212/241/346/216/245/345/205/245/344/270/216/346/265/213/350/257/225.md +1004 -0
- package/docs//347/264/240/346/235/220/345/205/245/345/272/223/344/270/216/347/224/273/345/270/203/344/277/235/345/255/230/351/227/256/351/242/230/345/256/232/344/275/215.md +52 -0
- package/docs//351/230/266/346/256/2653B/351/252/214/346/224/266/344/272/244/346/216/245.md +44 -0
- package/docs//351/230/266/346/256/265/344/270/200/351/252/214/346/224/266.md +60 -0
- package/docs//351/235/236/350/275/254/347/273/230/346/214/201/347/273/255/345/256/214/345/226/204/344/270/216/351/252/214/346/224/266-20261103.md +76 -0
- package/docs//351/235/236/350/275/254/347/273/230/347/254/254/344/272/214/346/211/271/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +39 -0
- package/docs//351/235/236/350/275/254/347/273/230/350/241/245/345/205/205/346/235/241/344/273/266/351/252/214/346/224/266-20261103.md +113 -0
- package/docs//351/235/236/350/275/254/347/273/230/351/246/226/346/211/271/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +71 -0
- package/examples/matting.json +5 -0
- package/examples/real-business/image-request.template.json +7 -0
- package/examples/real-business/node-patch.template.json +11 -0
- package/extensions/uzoo-cli-login/manifest.json +9 -0
- package/extensions/uzoo-cli-login/popup.css +1 -0
- package/extensions/uzoo-cli-login/popup.html +14 -0
- package/extensions/uzoo-cli-login/popup.js +65 -0
- package/package.json +56 -6
- package/scripts/check-package-resources.mjs +56 -0
- package/scripts/check-release.mjs +19 -0
- package/scripts/pack-local.mjs +33 -0
- package/scripts/release-policy.mjs +22 -0
- package/src/agreement-page.js +62 -0
- package/src/agreement.js +419 -0
- package/src/annotation-media.js +386 -0
- package/src/api.js +168 -0
- package/src/browser-login.js +13 -0
- package/src/callback-page.js +44 -0
- package/src/canvas.js +105 -0
- package/src/cli-authorization.js +122 -0
- package/src/cli.js +459 -0
- package/src/download-name.js +17 -0
- package/src/draft-finalize.js +229 -0
- package/src/generation-graph.js +124 -0
- package/src/generation-input.js +472 -0
- package/src/graph-disconnect.js +89 -0
- package/src/graph.js +431 -0
- package/src/group-edit.js +53 -0
- package/src/group-layout.js +113 -0
- package/src/group-membership.js +126 -0
- package/src/history-add.js +119 -0
- package/src/image-annotation.js +246 -0
- package/src/image-crop.js +283 -0
- package/src/image-matting.js +82 -0
- package/src/image-media.js +89 -0
- package/src/image-output.js +385 -0
- package/src/local-output.js +23 -0
- package/src/material-download.js +55 -0
- package/src/material-save.js +254 -0
- package/src/materials.js +147 -0
- package/src/matting-background.js +73 -0
- package/src/matting-jobs.js +63 -0
- package/src/matting-worker.js +49 -0
- package/src/matting.js +170 -0
- package/src/media-copy.js +53 -0
- package/src/media.js +343 -0
- package/src/model-options.js +76 -0
- package/src/multipart.js +28 -0
- package/src/node-copy.js +95 -0
- package/src/node-delete.js +49 -0
- package/src/node-history.js +231 -0
- package/src/normal-login.js +118 -0
- package/src/package-info.js +8 -0
- package/src/png-alpha.js +60 -0
- package/src/protocol.js +83 -0
- package/src/remake.js +738 -0
- package/src/repaint-archive.js +53 -0
- package/src/repaint-delivery.js +783 -0
- package/src/repaint-export.js +63 -0
- package/src/repaint-graph.js +214 -0
- package/src/repaint-history.js +267 -0
- package/src/repaint-materials.js +601 -0
- package/src/repaint-reverse.js +614 -0
- package/src/repaint-sequence.js +132 -0
- package/src/repaint-settings.js +576 -0
- package/src/repaint-video.js +798 -0
- package/src/repaint.js +326 -0
- package/src/resource.js +110 -0
- package/src/review.js +435 -0
- package/src/service.js +85 -0
- package/src/store.js +100 -0
- package/src/video-clip.js +381 -0
- package/src/video-frame.js +296 -0
- package/src/workflows.js +214 -0
package/src/graph.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { isDeepStrictEqual as same } from 'node:util'
|
|
3
|
+
import { syncGroupMemberships } from './group-membership.js'
|
|
4
|
+
import { scopedCanvas } from './materials.js'
|
|
5
|
+
import { segment, generationExtra, validateGeneration } from './workflows.js'
|
|
6
|
+
import {
|
|
7
|
+
generationModes as modes,
|
|
8
|
+
normalizeMode,
|
|
9
|
+
collectGenerationSources,
|
|
10
|
+
buildGenerationRequest,
|
|
11
|
+
referenceMapping
|
|
12
|
+
} from './generation-input.js'
|
|
13
|
+
export function nodeKind(node) {
|
|
14
|
+
const e = node?.data?.extra || {}
|
|
15
|
+
if (e.repaint?.role === 'split-detail') return 'detail'
|
|
16
|
+
if (e.video_remake || e.clip_redraw) return 'video'
|
|
17
|
+
if (node?.type === 'video_repain_begain') return 'entry'
|
|
18
|
+
if (node?.type?.endsWith('_upload') || e.node_kind === 'asset') return 'asset'
|
|
19
|
+
return node?.type
|
|
20
|
+
}
|
|
21
|
+
export function sourceType(node) {
|
|
22
|
+
const kind = nodeKind(node)
|
|
23
|
+
if (['entry', 'detail'].includes(kind)) return ''
|
|
24
|
+
return kind === 'asset'
|
|
25
|
+
? node.data?.mediaType || node.data?.segment_type || node.type.replace('_upload', '')
|
|
26
|
+
: kind
|
|
27
|
+
}
|
|
28
|
+
export function connectionReason(source, target, nodes, edges, catalog) {
|
|
29
|
+
if (!source || !target) return '节点不存在'
|
|
30
|
+
if (source.id === target.id) return '不能连接自身'
|
|
31
|
+
if (
|
|
32
|
+
['entry', 'detail'].includes(nodeKind(source)) ||
|
|
33
|
+
['entry', 'detail'].includes(nodeKind(target))
|
|
34
|
+
)
|
|
35
|
+
return '该端口仅供真实流程使用'
|
|
36
|
+
if (edges.some((e) => e.source === source.id && e.target === target.id)) return '连线已存在'
|
|
37
|
+
if (edges.some((e) => e.target === source.id && e.source === target.id)) return '反向连线已存在'
|
|
38
|
+
const targetKind = nodeKind(target),
|
|
39
|
+
media = sourceType(source)
|
|
40
|
+
const accepts = {
|
|
41
|
+
text: ['text', 'image', 'video', 'audio'],
|
|
42
|
+
image: ['text', 'image'],
|
|
43
|
+
video: ['text', 'image', 'video', 'audio'],
|
|
44
|
+
audio: ['text']
|
|
45
|
+
}
|
|
46
|
+
if (!accepts[targetKind]?.includes(media)) return '输出与输入类型不兼容'
|
|
47
|
+
if (media === 'audio' && targetKind !== 'video') return '音频仅能连接视频生成节点'
|
|
48
|
+
if (targetKind === 'audio') return ''
|
|
49
|
+
const model = catalog.find(
|
|
50
|
+
(m) => m.label === target.data?.model_name && m.content_type === targetKind
|
|
51
|
+
)
|
|
52
|
+
if (!model || model.member_locked) return '请先选择当前空间可用的目标模型'
|
|
53
|
+
if (targetKind === 'text') {
|
|
54
|
+
if (media === 'image' && model.image_understanding !== true) return '目标文本模型不支持图片理解'
|
|
55
|
+
if (media === 'video' && model.video_understanding !== true) return '目标文本模型不支持视频理解'
|
|
56
|
+
return ''
|
|
57
|
+
}
|
|
58
|
+
const inputs = [
|
|
59
|
+
...edges
|
|
60
|
+
.filter((e) => e.target === target.id)
|
|
61
|
+
.map((e) => sourceType(nodes.find((n) => n.id === e.source))),
|
|
62
|
+
media
|
|
63
|
+
].filter(Boolean)
|
|
64
|
+
const compatible = (model.generation_modes || []).some((mode) => {
|
|
65
|
+
const rule = modes[normalizeMode(mode)]
|
|
66
|
+
return (
|
|
67
|
+
rule &&
|
|
68
|
+
inputs.every((t) => rule[0].includes(t)) &&
|
|
69
|
+
(!rule[1].length || rule[1].some((t) => inputs.includes(t)))
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
if (
|
|
73
|
+
targetKind === 'video' &&
|
|
74
|
+
inputs.includes('audio') &&
|
|
75
|
+
model.audio_reference_requires_visual === true &&
|
|
76
|
+
!inputs.some((t) => t === 'image' || t === 'video')
|
|
77
|
+
)
|
|
78
|
+
return '音频参考需要至少一个图片或视频输入'
|
|
79
|
+
for (const type of ['image', 'video', 'audio']) {
|
|
80
|
+
const max =
|
|
81
|
+
model[`max_${type}_references`] ?? (type === 'image' ? model.max_reference_count : undefined)
|
|
82
|
+
if (Number.isFinite(max) && inputs.filter((t) => t === type).length > max)
|
|
83
|
+
return `超出模型 ${type} 参考数量限制`
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
Number.isFinite(model.max_reference_count) &&
|
|
87
|
+
inputs.filter((t) => t !== 'text').length > model.max_reference_count
|
|
88
|
+
)
|
|
89
|
+
return '超出模型参考素材总数量限制'
|
|
90
|
+
return compatible ? '' : '目标模型不支持当前上游类型组合'
|
|
91
|
+
}
|
|
92
|
+
const groupsOf = (doc) => {
|
|
93
|
+
const groups = doc.meta?.canvas_groups ?? []
|
|
94
|
+
if (!Array.isArray(groups)) throw new Error('分组快照无效')
|
|
95
|
+
return groups
|
|
96
|
+
}
|
|
97
|
+
export async function save(client, doc, changes) {
|
|
98
|
+
if (
|
|
99
|
+
!Number.isSafeInteger(doc.version) ||
|
|
100
|
+
!Array.isArray(doc.nodes) ||
|
|
101
|
+
!Array.isArray(doc.edges) ||
|
|
102
|
+
!doc.meta ||
|
|
103
|
+
!doc.viewport ||
|
|
104
|
+
typeof doc.name !== 'string'
|
|
105
|
+
)
|
|
106
|
+
throw new Error('画布快照不完整')
|
|
107
|
+
const body = {
|
|
108
|
+
version: doc.version,
|
|
109
|
+
name: doc.name,
|
|
110
|
+
viewport: doc.viewport,
|
|
111
|
+
nodes: doc.nodes,
|
|
112
|
+
edges: doc.edges,
|
|
113
|
+
meta: doc.meta,
|
|
114
|
+
...changes
|
|
115
|
+
}
|
|
116
|
+
let result
|
|
117
|
+
try {
|
|
118
|
+
result = await client.raw(
|
|
119
|
+
`/canvas/docs/${segment(doc.id)}`,
|
|
120
|
+
{ method: 'PUT', body },
|
|
121
|
+
{ retry: false }
|
|
122
|
+
)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error.status === 409 || error.apiCode === 3002)
|
|
125
|
+
throw new Error('画布版本冲突;先读回核对,不自动覆盖')
|
|
126
|
+
throw new Error('画布保存结果未确认;先 canvas get 核对,不自动重放', { cause: error })
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
result.id !== doc.id ||
|
|
130
|
+
result.version <= doc.version ||
|
|
131
|
+
!Array.isArray(result.nodes) ||
|
|
132
|
+
!Array.isArray(result.edges)
|
|
133
|
+
)
|
|
134
|
+
throw new Error('保存响应不完整;先读回核对')
|
|
135
|
+
return result
|
|
136
|
+
}
|
|
137
|
+
export function layoutNodes(nodes, mode, origin) {
|
|
138
|
+
if (!['horizontal', 'vertical', 'grid'].includes(mode))
|
|
139
|
+
throw new Error('layout 必须为 horizontal/vertical/grid')
|
|
140
|
+
const cols =
|
|
141
|
+
mode === 'horizontal'
|
|
142
|
+
? nodes.length
|
|
143
|
+
: mode === 'vertical'
|
|
144
|
+
? 1
|
|
145
|
+
: Math.ceil(Math.sqrt(nodes.length))
|
|
146
|
+
const widths = Array(cols).fill(0),
|
|
147
|
+
heights = Array(Math.ceil(nodes.length / cols)).fill(0)
|
|
148
|
+
nodes.forEach((n, i) => {
|
|
149
|
+
widths[i % cols] = Math.max(widths[i % cols], n.width || 300)
|
|
150
|
+
heights[Math.floor(i / cols)] = Math.max(heights[Math.floor(i / cols)], n.height || 260)
|
|
151
|
+
})
|
|
152
|
+
return nodes.map((n, i) => ({
|
|
153
|
+
...n,
|
|
154
|
+
position: {
|
|
155
|
+
x: origin.x + 40 + widths.slice(0, i % cols).reduce((a, b) => a + b + 120, 0),
|
|
156
|
+
y: origin.y + 40 + heights.slice(0, Math.floor(i / cols)).reduce((a, b) => a + b + 80, 0)
|
|
157
|
+
}
|
|
158
|
+
}))
|
|
159
|
+
}
|
|
160
|
+
export async function renameGraphGroup(client, args, confirmed = false) {
|
|
161
|
+
if (!args || typeof args !== 'object' || Array.isArray(args) ||
|
|
162
|
+
Object.keys(args).some(k => !['doc_id', 'space', 'team_uid', 'group_id', 'name', 'expected_version'].includes(k)))
|
|
163
|
+
throw new Error('组重命名输入仅支持doc_id/space/team_uid/group_id/name/expected_version')
|
|
164
|
+
if (typeof args.group_id !== 'string' || !args.group_id.trim() ||
|
|
165
|
+
typeof args.name !== 'string' || !args.name.trim()) throw new Error('分组ID和名称不可为空')
|
|
166
|
+
if (typeof args.doc_id !== 'string' || !args.doc_id.trim()) throw new Error('doc_id必须为非空字符串')
|
|
167
|
+
if (args.expected_version !== undefined && (!Number.isSafeInteger(args.expected_version) || args.expected_version < 1))
|
|
168
|
+
throw new Error('expected_version必须为正整数')
|
|
169
|
+
const doc = await scopedCanvas(client, args.doc_id, args.space, args.team_uid)
|
|
170
|
+
if (!Array.isArray(doc.nodes) || !Array.isArray(doc.edges) ||
|
|
171
|
+
!doc.meta || typeof doc.meta !== 'object' || Array.isArray(doc.meta) ||
|
|
172
|
+
!doc.viewport || typeof doc.viewport !== 'object' || Array.isArray(doc.viewport) ||
|
|
173
|
+
typeof doc.name !== 'string') throw new Error('画布快照不完整,停止重命名')
|
|
174
|
+
if (!Array.isArray(doc.meta.canvas_groups) || doc.meta.canvas_groups.some(g =>
|
|
175
|
+
!g || typeof g !== 'object' || typeof g.id !== 'string' || !g.id ||
|
|
176
|
+
typeof g.label !== 'string' || !Array.isArray(g.nodeIds)) ||
|
|
177
|
+
new Set(doc.meta.canvas_groups.map(g => g.id)).size !== doc.meta.canvas_groups.length)
|
|
178
|
+
throw new Error('分组快照不完整或ID重复')
|
|
179
|
+
const groups = groupsOf(doc), matches = groups.filter(g => g.id === args.group_id)
|
|
180
|
+
if (matches.length !== 1) throw new Error('分组不存在或ID重复')
|
|
181
|
+
const group = matches[0]
|
|
182
|
+
if (group.repaint || group.video_remake || group.clip_redraw) throw new Error('派生流程分组请按专用流程操作')
|
|
183
|
+
if (!Number.isSafeInteger(doc.version) || doc.version < 1) throw new Error('画布版本无效')
|
|
184
|
+
if (args.expected_version !== undefined && doc.version !== args.expected_version)
|
|
185
|
+
throw new Error('画布已变化;重新预览,不自动采用最新版本覆盖')
|
|
186
|
+
const name = args.name.trim()
|
|
187
|
+
const preview = { doc_id: doc.id, group_id: group.id, before_name: group.label, name,
|
|
188
|
+
expected_version: doc.version, changed: name !== group.label, canvas_written: false,
|
|
189
|
+
preserves: ['nodes', 'edges', 'positions', 'references', 'other_groups'], generation_submitted: false }
|
|
190
|
+
if (!confirmed) return preview
|
|
191
|
+
if (args.expected_version === undefined) throw new Error('执行组重命名必须提供预览返回的expected_version')
|
|
192
|
+
if (!preview.changed) return preview
|
|
193
|
+
const saved = await save(client, doc, { meta: { ...doc.meta, canvas_groups:
|
|
194
|
+
groups.map(g => g.id === group.id ? { ...g, label: name } : g) } })
|
|
195
|
+
let current
|
|
196
|
+
try { current = await scopedCanvas(client, args.doc_id, args.space, args.team_uid) }
|
|
197
|
+
catch (cause) { throw new Error('重命名已尝试保存但读回未确认;先canvas get核对,不自动重放', { cause }) }
|
|
198
|
+
const actual = current.meta?.canvas_groups?.filter(g => g?.id === group.id)
|
|
199
|
+
if (!Number.isSafeInteger(current.version) || current.version < saved.version ||
|
|
200
|
+
actual?.length !== 1 || actual[0].label !== name)
|
|
201
|
+
throw new Error('重命名保存后读回不一致;保留当前画布,不自动重放')
|
|
202
|
+
return { ...preview, version: current.version, canvas_written: true, rename_verified: true }
|
|
203
|
+
}
|
|
204
|
+
export async function ungroupGraph(client, args, confirmed = false) {
|
|
205
|
+
if (!args || typeof args !== 'object' || Array.isArray(args) ||
|
|
206
|
+
Object.keys(args).some(k => !['doc_id','space','team_uid','group_id','expected_version'].includes(k)) ||
|
|
207
|
+
!['doc_id','group_id'].every(k => typeof args[k] === 'string' && args[k].trim()))
|
|
208
|
+
throw new Error('拆组输入需要doc_id/space/team_uid/group_id,可选expected_version')
|
|
209
|
+
if (args.expected_version !== undefined && (!Number.isSafeInteger(args.expected_version) || args.expected_version < 1))
|
|
210
|
+
throw new Error('expected_version必须为正整数')
|
|
211
|
+
const doc = await scopedCanvas(client, args.doc_id, args.space, args.team_uid)
|
|
212
|
+
if (!Number.isSafeInteger(doc.version) || doc.version < 1 || !Array.isArray(doc.nodes) ||
|
|
213
|
+
!Array.isArray(doc.edges) || !doc.meta || Array.isArray(doc.meta) || !doc.viewport || typeof doc.name !== 'string')
|
|
214
|
+
throw new Error('画布快照不完整')
|
|
215
|
+
const groups = groupsOf(doc)
|
|
216
|
+
if (groups.some(g => !g || typeof g.id !== 'string' || !Array.isArray(g.nodeIds)) ||
|
|
217
|
+
new Set(groups.map(g=>g.id)).size !== groups.length ||
|
|
218
|
+
new Set(doc.nodes.map(n=>n.id)).size !== doc.nodes.length) throw new Error('分组或节点快照无效')
|
|
219
|
+
const target = groups.find(g=>g.id === args.group_id)
|
|
220
|
+
if (!target) throw new Error('分组不存在;先读回核对,不重复执行')
|
|
221
|
+
const protectedGroup = g => g.repaint || g.video_remake || g.clip_redraw
|
|
222
|
+
if (protectedGroup(target)) throw new Error('派生流程分组不可普通拆组')
|
|
223
|
+
if (args.expected_version !== undefined && args.expected_version !== doc.version) throw new Error('画布已变化;重新预览')
|
|
224
|
+
const state = { nodes: doc.nodes, groups: structuredClone(groups.filter(g=>g.id !== target.id)) }
|
|
225
|
+
syncGroupMemberships(state)
|
|
226
|
+
const changed = state.groups.filter(g=>!same(g,groups.find(old=>old.id===g.id)))
|
|
227
|
+
if (changed.some(protectedGroup)) throw new Error('拆组将影响派生流程组成员,停止')
|
|
228
|
+
const preview = {doc_id:doc.id,group_id:target.id,removed_group_name:target.label,
|
|
229
|
+
node_ids:target.nodeIds,expected_version:doc.version,canvas_written:false,generation_submitted:false,
|
|
230
|
+
membership_changes:changed.map(g=>({group_id:g.id,before:groups.find(old=>old.id===g.id).nodeIds,
|
|
231
|
+
after:g.nodeIds,exited_before:groups.find(old=>old.id===g.id).exitedNodeIds||[],exited_after:g.exitedNodeIds||[]})),
|
|
232
|
+
preserves:['nodes','positions','edges','references']}
|
|
233
|
+
if (!confirmed) return preview
|
|
234
|
+
if (args.expected_version === undefined) throw new Error('执行拆组须提供预览expected_version')
|
|
235
|
+
const meta = {...doc.meta}
|
|
236
|
+
if (state.groups.length) meta.canvas_groups = state.groups
|
|
237
|
+
else delete meta.canvas_groups
|
|
238
|
+
const saved = await save(client,doc,{meta})
|
|
239
|
+
let current
|
|
240
|
+
try { current = await scopedCanvas(client,args.doc_id,args.space,args.team_uid) }
|
|
241
|
+
catch(cause) { throw new Error('拆组已尝试保存,读回未确认;不自动重放',{cause}) }
|
|
242
|
+
if (!Number.isSafeInteger(current.version) || current.version < saved.version ||
|
|
243
|
+
!same(current.meta,meta) || !same(current.nodes,doc.nodes) || !same(current.edges,doc.edges) ||
|
|
244
|
+
!same(current.viewport,doc.viewport) || current.name !== doc.name)
|
|
245
|
+
throw new Error('拆组保存后内容已变化或读回不一致;不自动重放')
|
|
246
|
+
return {...preview,canvas_written:true,ungroup_verified:true,version:current.version}
|
|
247
|
+
}
|
|
248
|
+
export async function graphWrite(client, args) {
|
|
249
|
+
if (!args || Array.isArray(args) || typeof args !== 'object')
|
|
250
|
+
throw new Error('图操作必须为 JSON 对象')
|
|
251
|
+
const fields = [
|
|
252
|
+
'action',
|
|
253
|
+
'doc_id',
|
|
254
|
+
'space',
|
|
255
|
+
'team_uid',
|
|
256
|
+
'node_ids',
|
|
257
|
+
'position',
|
|
258
|
+
'layout',
|
|
259
|
+
'name',
|
|
260
|
+
'group_id',
|
|
261
|
+
'target_node_id',
|
|
262
|
+
'source_node_id'
|
|
263
|
+
]
|
|
264
|
+
if (Object.keys(args).some((k) => !fields.includes(k))) throw new Error('图操作包含不支持的字段')
|
|
265
|
+
const { doc_id: docId, space, team_uid: teamUid } = args
|
|
266
|
+
const doc = await scopedCanvas(client, docId, space, teamUid)
|
|
267
|
+
const groups = groupsOf(doc)
|
|
268
|
+
if (args.action === 'group-create' || args.action === 'layout') {
|
|
269
|
+
if (
|
|
270
|
+
!Array.isArray(args.node_ids) ||
|
|
271
|
+
args.node_ids.length < 2 ||
|
|
272
|
+
new Set(args.node_ids).size !== args.node_ids.length ||
|
|
273
|
+
args.node_ids.some((id) => typeof id !== 'string')
|
|
274
|
+
)
|
|
275
|
+
throw new Error('至少提供两个不重复的 node_ids')
|
|
276
|
+
const selected = args.node_ids.map((id) => doc.nodes.find((n) => n.id === id))
|
|
277
|
+
if (selected.some((n) => !n)) throw new Error('分组成员不存在')
|
|
278
|
+
if (
|
|
279
|
+
selected.some(
|
|
280
|
+
(n) => n.data?.extra?.repaint || n.data?.extra?.video_remake || n.data?.extra?.clip_redraw
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
throw new Error('派生流程节点不能通过普通分组操作重组')
|
|
284
|
+
// Existing groups and workflow layout are not silently reassigned.
|
|
285
|
+
if (groups.some((g) => g.nodeIds?.some((id) => args.node_ids.includes(id))))
|
|
286
|
+
throw new Error('选中节点已有分组;停止以保留原组')
|
|
287
|
+
const origin = args.position || {
|
|
288
|
+
x: Math.min(...selected.map((n) => n.position.x)) - 40,
|
|
289
|
+
y: Math.min(...selected.map((n) => n.position.y)) - 40
|
|
290
|
+
}
|
|
291
|
+
if (!Number.isFinite(origin.x) || !Number.isFinite(origin.y)) throw new Error('position 无效')
|
|
292
|
+
const placed = layoutNodes(selected, args.layout || 'grid', origin)
|
|
293
|
+
const nodes = doc.nodes.map((n) => placed.find((p) => p.id === n.id) || n)
|
|
294
|
+
let group
|
|
295
|
+
if (args.action === 'group-create') {
|
|
296
|
+
if (typeof args.name !== 'string' || !args.name.trim()) throw new Error('分组名称不可为空')
|
|
297
|
+
const id = args.group_id || 'group-' + crypto.randomUUID()
|
|
298
|
+
if (typeof id !== 'string' || groups.some((g) => g.id === id))
|
|
299
|
+
throw new Error('分组 ID 无效或已存在;先读回核对')
|
|
300
|
+
group = {
|
|
301
|
+
id,
|
|
302
|
+
label: args.name,
|
|
303
|
+
nodeIds: args.node_ids,
|
|
304
|
+
position: origin,
|
|
305
|
+
size: {
|
|
306
|
+
width: Math.max(...placed.map((n) => n.position.x + (n.width || 300))) - origin.x + 40,
|
|
307
|
+
height: Math.max(...placed.map((n) => n.position.y + (n.height || 260))) - origin.y + 40
|
|
308
|
+
},
|
|
309
|
+
padding: 40,
|
|
310
|
+
color: '#2c2c2c',
|
|
311
|
+
borderColor: 'rgba(255, 255, 255, 0.28)'
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const saved = await save(client, doc, {
|
|
315
|
+
nodes,
|
|
316
|
+
...(group ? { meta: { ...doc.meta, canvas_groups: [group, ...groups] } } : {})
|
|
317
|
+
})
|
|
318
|
+
return { doc_id: docId, version: saved.version, group, node_ids: args.node_ids }
|
|
319
|
+
}
|
|
320
|
+
if (!['connect', 'group-connect'].includes(args.action)) throw new Error('未知图操作')
|
|
321
|
+
const target = doc.nodes.find((n) => n.id === args.target_node_id)
|
|
322
|
+
if (!target) throw new Error('目标节点不存在')
|
|
323
|
+
const group = args.action === 'group-connect' ? groups.find((g) => g.id === args.group_id) : null
|
|
324
|
+
if (args.action === 'group-connect' && !group) throw new Error('分组不存在')
|
|
325
|
+
if (group?.repaint) throw new Error('转绘流程分组请按专用流程操作')
|
|
326
|
+
const ids = group ? group.nodeIds : [args.source_node_id]
|
|
327
|
+
if (!Array.isArray(ids) || ids.some((id) => !doc.nodes.some((n) => n.id === id)))
|
|
328
|
+
throw new Error('源节点或组成员不存在')
|
|
329
|
+
const catalog = await client.raw(
|
|
330
|
+
'/canvas/models' + (doc.team_uid ? '?team_uid=' + segment(doc.team_uid) : '')
|
|
331
|
+
)
|
|
332
|
+
if (!Array.isArray(catalog.items)) throw new Error('模型目录无效')
|
|
333
|
+
const edges = [...doc.edges],
|
|
334
|
+
connected = [],
|
|
335
|
+
skipped = []
|
|
336
|
+
for (const id of ids) {
|
|
337
|
+
const source = doc.nodes.find((n) => n.id === id)
|
|
338
|
+
const reason = connectionReason(source, target, doc.nodes, edges, catalog.items)
|
|
339
|
+
if (reason) {
|
|
340
|
+
skipped.push({ node_id: id, reason })
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
edges.push({
|
|
344
|
+
id: 'edge-' + crypto.randomUUID(),
|
|
345
|
+
source: id,
|
|
346
|
+
target: target.id,
|
|
347
|
+
sourceHandle: '',
|
|
348
|
+
targetHandle: '',
|
|
349
|
+
type: 'default',
|
|
350
|
+
animated: false
|
|
351
|
+
})
|
|
352
|
+
connected.push(id)
|
|
353
|
+
}
|
|
354
|
+
let nodes = doc.nodes
|
|
355
|
+
if (connected.length && ['image', 'video'].includes(nodeKind(target))) {
|
|
356
|
+
const inputs = edges
|
|
357
|
+
.filter((e) => e.target === target.id)
|
|
358
|
+
.map((e) => sourceType(doc.nodes.find((n) => n.id === e.source)))
|
|
359
|
+
.filter(Boolean)
|
|
360
|
+
const model = catalog.items.find(
|
|
361
|
+
(m) => m.label === target.data?.model_name && m.content_type === nodeKind(target)
|
|
362
|
+
)
|
|
363
|
+
const supported = model.generation_modes.filter(
|
|
364
|
+
(mode) =>
|
|
365
|
+
modes[mode] &&
|
|
366
|
+
inputs.every((t) => modes[mode][0].includes(t)) &&
|
|
367
|
+
(!modes[mode][1].length || modes[mode][1].some((t) => inputs.includes(t)))
|
|
368
|
+
)
|
|
369
|
+
const current = target.data?.extra?.generation_mode
|
|
370
|
+
const mode = supported.includes(current) ? current : supported[0]
|
|
371
|
+
nodes = doc.nodes.map((n) =>
|
|
372
|
+
n.id === target.id
|
|
373
|
+
? { ...n, data: { ...n.data, extra: { ...n.data.extra, generation_mode: mode } } }
|
|
374
|
+
: n
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
const result = connected.length ? await save(client, doc, { edges, nodes }) : doc
|
|
378
|
+
return { doc_id: docId, version: result.version, connected, skipped }
|
|
379
|
+
}
|
|
380
|
+
export async function prepareGeneration(client, { docId, nodeId, space, teamUid }) {
|
|
381
|
+
const doc = await scopedCanvas(client, docId, space, teamUid)
|
|
382
|
+
const node = doc.nodes.find((n) => n.id === nodeId)
|
|
383
|
+
const kind = nodeKind(node)
|
|
384
|
+
if (
|
|
385
|
+
!['text', 'image', 'video'].includes(kind) ||
|
|
386
|
+
node?.data?.extra?.video_remake ||
|
|
387
|
+
node?.data?.extra?.repaint ||
|
|
388
|
+
node?.data?.extra?.clip_redraw
|
|
389
|
+
)
|
|
390
|
+
throw new Error('仅支持普通文本/图片/视频生成;派生流程须用专用编排')
|
|
391
|
+
const models = await client.raw(
|
|
392
|
+
'/canvas/models' + (doc.team_uid ? '?team_uid=' + segment(doc.team_uid) : '')
|
|
393
|
+
)
|
|
394
|
+
const model = models.items?.find(
|
|
395
|
+
(m) => m.label === node.data?.model_name && m.content_type === kind && !m.member_locked
|
|
396
|
+
)
|
|
397
|
+
if (!model) throw new Error('节点缺少当前空间可用模型')
|
|
398
|
+
// Validate actual incoming edges before merging persistent (possibly disconnected) references.
|
|
399
|
+
for (const edge of doc.edges.filter((e) => e.target === nodeId)) {
|
|
400
|
+
const source = doc.nodes.find((n) => n.id === edge.source)
|
|
401
|
+
const reason = connectionReason(
|
|
402
|
+
source,
|
|
403
|
+
node,
|
|
404
|
+
doc.nodes,
|
|
405
|
+
doc.edges.filter((e) => e !== edge && !(e.source === edge.source && e.target === nodeId)),
|
|
406
|
+
models.items
|
|
407
|
+
)
|
|
408
|
+
if (reason) throw new Error(`上游 ${edge.source} 不合规:${reason}`)
|
|
409
|
+
}
|
|
410
|
+
generationExtra(kind, node.data.extra || {}, { fromNode: true })
|
|
411
|
+
if (Object.hasOwn(node.data, 'generate_audio') || Object.hasOwn(node.data, 'generateAudio'))
|
|
412
|
+
throw new Error('前端视频节点没有声音开关,CLI 不支持 generate_audio;请先核对节点参数')
|
|
413
|
+
const inputs = collectGenerationSources(doc, node)
|
|
414
|
+
const { request, sources } = buildGenerationRequest(doc, node, model, inputs)
|
|
415
|
+
validateGeneration(request)
|
|
416
|
+
return {
|
|
417
|
+
prepared: true,
|
|
418
|
+
reference_mapping: referenceMapping(request, sources),
|
|
419
|
+
reference_mapping_scope: 'client_request_before_backend_resolution',
|
|
420
|
+
wallet: { space, team_uid: doc.team_uid || null },
|
|
421
|
+
request,
|
|
422
|
+
source_node_ids: sources.map((source) => source.id),
|
|
423
|
+
references: sources.map(({ id, title, mediaType, url, text }) => ({
|
|
424
|
+
id,
|
|
425
|
+
title,
|
|
426
|
+
mediaType,
|
|
427
|
+
url,
|
|
428
|
+
text
|
|
429
|
+
}))
|
|
430
|
+
}
|
|
431
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {isDeepStrictEqual as same} from 'node:util'
|
|
2
|
+
import {scopedCanvas} from './materials.js'
|
|
3
|
+
import {save} from './graph.js'
|
|
4
|
+
import {syncGroupMemberships} from './group-membership.js'
|
|
5
|
+
import {calculateGroupLayout} from './group-layout.js'
|
|
6
|
+
|
|
7
|
+
export async function editGraphGroup(client,input,action,confirmed=false){
|
|
8
|
+
const fields=['doc_id','space','team_uid','group_id','expected_version',...(action==='move'?['position']:action==='resize'?['position','size']:action==='layout'?['layout']:[])]
|
|
9
|
+
const point=p=>p&&typeof p==='object'&&!Array.isArray(p)&&Number.isFinite(p.x)&&Number.isFinite(p.y)
|
|
10
|
+
if(!['move','resize','layout'].includes(action)||!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!fields.includes(k))||!['doc_id','group_id'].every(k=>typeof input[k]==='string'&&input[k].trim()))throw Error('分组编辑输入无效')
|
|
11
|
+
if(action==='move'&&!point(input.position))throw Error('移动必须指定position')
|
|
12
|
+
if(action==='resize'&&(!input.size||!Number.isFinite(input.size.width)||!Number.isFinite(input.size.height)||input.size.width<120||input.size.height<72||(input.position!==undefined&&!point(input.position))))throw Error('组框size须至少120×72,可选有效position')
|
|
13
|
+
if(action==='layout'&&!['horizontal','vertical','grid'].includes(input.layout))throw Error('layout须为horizontal/vertical/grid')
|
|
14
|
+
if(input.expected_version!==undefined&&(!Number.isSafeInteger(input.expected_version)||input.expected_version<1))throw Error('expected_version无效')
|
|
15
|
+
const doc=await scopedCanvas(client,input.doc_id,input.space,input.team_uid)
|
|
16
|
+
const groups=doc.meta?.canvas_groups
|
|
17
|
+
if(!Number.isSafeInteger(doc.version)||doc.version<1||!Array.isArray(doc.nodes)||!Array.isArray(doc.edges)||!Array.isArray(groups)||!doc.viewport||typeof doc.name!=='string'||new Set(doc.nodes.map(n=>n.id)).size!==doc.nodes.length||new Set(groups.map(g=>g?.id)).size!==groups.length)throw Error('画布或分组快照不完整')
|
|
18
|
+
if(input.expected_version!==undefined&&input.expected_version!==doc.version)throw Error('画布已变化,重新预览')
|
|
19
|
+
const protectedItem=item=>item?.repaint||item?.videoRemake||item?.clipRedraw||item?.draftFinalizeGeneration||item?.video_remake||item?.clip_redraw||item?.draft_finalize||item?.extra?.repaint||item?.extra?.video_remake||item?.extra?.clip_redraw||item?.extra?.draft_finalize
|
|
20
|
+
const old=groups.find(g=>g.id===input.group_id)
|
|
21
|
+
if(!old||protectedItem(old)||!Array.isArray(old.nodeIds)||!point(old.position)||!old.size||!Number.isFinite(old.size.width)||!Number.isFinite(old.size.height))throw Error('普通分组不存在或结构/派生类型不支持')
|
|
22
|
+
const ids=new Set(old.nodeIds),members=doc.nodes.filter(n=>ids.has(n.id))
|
|
23
|
+
if(members.length!==ids.size||members.some(n=>protectedItem(n.data)||!point(n.position)))throw Error('组内成员缺失或派生节点不可普通编辑')
|
|
24
|
+
const state={nodes:structuredClone(doc.nodes),groups:structuredClone(groups)},group=state.groups.find(g=>g.id===old.id)
|
|
25
|
+
if(action==='move'){
|
|
26
|
+
const position={x:Math.round(input.position.x),y:Math.round(input.position.y)},dx=position.x-old.position.x,dy=position.y-old.position.y
|
|
27
|
+
group.position=position
|
|
28
|
+
for(const n of state.nodes)if(ids.has(n.id))n.position={x:Math.round(n.position.x+dx),y:Math.round(n.position.y+dy)}
|
|
29
|
+
}else if(action==='resize'){
|
|
30
|
+
group.size={width:input.size.width,height:input.size.height}
|
|
31
|
+
if(input.position)group.position={x:input.position.x,y:input.position.y}
|
|
32
|
+
}else{
|
|
33
|
+
const layout=calculateGroupLayout(group,members,input.layout)
|
|
34
|
+
if(!layout)throw Error('空组无法重排')
|
|
35
|
+
group.size=layout.size
|
|
36
|
+
for(const n of state.nodes){const p=layout.positions.find(p=>p.id===n.id);if(p)n.position={x:Math.round(p.position.x),y:Math.round(p.position.y)}}
|
|
37
|
+
}
|
|
38
|
+
syncGroupMemberships(state)
|
|
39
|
+
const changedGroups=state.groups.filter(g=>!same(g,groups.find(old=>old.id===g.id)))
|
|
40
|
+
if(changedGroups.some(protectedItem))throw Error('操作会改变派生组归属,停止')
|
|
41
|
+
const meta={...doc.meta,canvas_groups:state.groups}
|
|
42
|
+
const preview={doc_id:doc.id,group_id:old.id,action,expected_version:doc.version,before_group:old,after_group:group,
|
|
43
|
+
node_moves:state.nodes.filter(n=>!same(n.position,doc.nodes.find(s=>s.id===n.id).position)).map(n=>({node_id:n.id,before:doc.nodes.find(s=>s.id===n.id).position,after:n.position})),
|
|
44
|
+
membership_changes:changedGroups.map(g=>({group_id:g.id,before:groups.find(o=>o.id===g.id).nodeIds,after:g.nodeIds,exited_before:groups.find(o=>o.id===g.id).exitedNodeIds||[],exited_after:g.exitedNodeIds||[]})),
|
|
45
|
+
canvas_written:false,generation_submitted:false,changed:!same(state.nodes,doc.nodes)||!same(meta,doc.meta)}
|
|
46
|
+
if(!confirmed)return preview
|
|
47
|
+
if(input.expected_version===undefined)throw Error('执行必须提供预览expected_version')
|
|
48
|
+
if(!preview.changed)return {...preview,group_edit_verified:true}
|
|
49
|
+
const saved=await save(client,doc,{nodes:state.nodes,meta})
|
|
50
|
+
let after;try{after=await scopedCanvas(client,input.doc_id,input.space,input.team_uid)}catch(cause){throw Error('分组保存后读回未确认,不自动重放',{cause})}
|
|
51
|
+
if(!Number.isSafeInteger(after.version)||after.version<saved.version||!same(after.nodes,state.nodes)||!same(after.meta,meta)||!same(after.edges,doc.edges)||!same(after.viewport,doc.viewport)||after.name!==doc.name)throw Error('分组编辑读回不一致,不自动重放')
|
|
52
|
+
return {...preview,canvas_written:true,version:after.version,group_edit_verified:true}
|
|
53
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Independent CLI group layout contract, checked against frontend groupLayout.js.
|
|
2
|
+
const DEFAULT_NODE_WIDTH = 200
|
|
3
|
+
const DEFAULT_NODE_HEIGHT = 100
|
|
4
|
+
export const GROUP_LAYOUT_PADDING = 40
|
|
5
|
+
const DEFAULT_HORIZONTAL_GAP = 120
|
|
6
|
+
const DEFAULT_VERTICAL_GAP = 80
|
|
7
|
+
|
|
8
|
+
function spacing(value, fallback) {
|
|
9
|
+
const number = Number(value)
|
|
10
|
+
return Number.isFinite(number) ? Math.max(number, 0) : fallback
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getNodeSize(node) {
|
|
14
|
+
const width = Number(node?.computedWidth) || Number(node?.width)
|
|
15
|
+
const height = Number(node?.computedHeight) || Number(node?.height)
|
|
16
|
+
return {
|
|
17
|
+
width: Number.isFinite(width) && width > 0 ? width : DEFAULT_NODE_WIDTH,
|
|
18
|
+
height: Number.isFinite(height) && height > 0 ? height : DEFAULT_NODE_HEIGHT,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sortNodes(nodes, mode) {
|
|
23
|
+
return [...nodes].sort((left, right) => {
|
|
24
|
+
const leftX = Number(left?.position?.x) || 0
|
|
25
|
+
const leftY = Number(left?.position?.y) || 0
|
|
26
|
+
const rightX = Number(right?.position?.x) || 0
|
|
27
|
+
const rightY = Number(right?.position?.y) || 0
|
|
28
|
+
if (mode === 'horizontal') return leftX - rightX || leftY - rightY
|
|
29
|
+
return leftY - rightY || leftX - rightX
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function calculateGroupLayout(group, nodes, mode, options = {}) {
|
|
34
|
+
const sourceNodes = Array.isArray(nodes) ? nodes.filter(Boolean) : []
|
|
35
|
+
if (!group || !sourceNodes.length) return null
|
|
36
|
+
if (!['horizontal', 'vertical', 'grid'].includes(mode)) return null
|
|
37
|
+
|
|
38
|
+
const padding = spacing(
|
|
39
|
+
options.padding ?? group.padding ?? GROUP_LAYOUT_PADDING,
|
|
40
|
+
GROUP_LAYOUT_PADDING
|
|
41
|
+
)
|
|
42
|
+
// Horizontal ports need more room for curves than rows need for titles and controls.
|
|
43
|
+
const gapX = spacing(
|
|
44
|
+
options.gapX ?? options.gap ?? DEFAULT_HORIZONTAL_GAP,
|
|
45
|
+
DEFAULT_HORIZONTAL_GAP
|
|
46
|
+
)
|
|
47
|
+
const gapY = spacing(options.gapY ?? options.gap ?? DEFAULT_VERTICAL_GAP, DEFAULT_VERTICAL_GAP)
|
|
48
|
+
const originX = Number(group.position?.x) || 0
|
|
49
|
+
const originY = Number(group.position?.y) || 0
|
|
50
|
+
const orderedNodes = sortNodes(sourceNodes, mode)
|
|
51
|
+
const sizes = orderedNodes.map(getNodeSize)
|
|
52
|
+
const positions = []
|
|
53
|
+
let contentWidth = 0
|
|
54
|
+
let contentHeight = 0
|
|
55
|
+
|
|
56
|
+
if (mode === 'horizontal') {
|
|
57
|
+
let x = originX + padding
|
|
58
|
+
contentHeight = Math.max(...sizes.map((size) => size.height))
|
|
59
|
+
orderedNodes.forEach((node, index) => {
|
|
60
|
+
positions.push({ id: String(node.id), position: { x, y: originY + padding } })
|
|
61
|
+
x += sizes[index].width + gapX
|
|
62
|
+
})
|
|
63
|
+
contentWidth = x - originX - padding - gapX
|
|
64
|
+
} else if (mode === 'vertical') {
|
|
65
|
+
let y = originY + padding
|
|
66
|
+
contentWidth = Math.max(...sizes.map((size) => size.width))
|
|
67
|
+
orderedNodes.forEach((node, index) => {
|
|
68
|
+
positions.push({ id: String(node.id), position: { x: originX + padding, y } })
|
|
69
|
+
y += sizes[index].height + gapY
|
|
70
|
+
})
|
|
71
|
+
contentHeight = y - originY - padding - gapY
|
|
72
|
+
} else {
|
|
73
|
+
const columnCount = Math.ceil(Math.sqrt(orderedNodes.length))
|
|
74
|
+
const rowCount = Math.ceil(orderedNodes.length / columnCount)
|
|
75
|
+
const columnWidths = Array.from({ length: columnCount }, () => 0)
|
|
76
|
+
const rowHeights = Array.from({ length: rowCount }, () => 0)
|
|
77
|
+
sizes.forEach((size, index) => {
|
|
78
|
+
const column = index % columnCount
|
|
79
|
+
const row = Math.floor(index / columnCount)
|
|
80
|
+
columnWidths[column] = Math.max(columnWidths[column], size.width)
|
|
81
|
+
rowHeights[row] = Math.max(rowHeights[row], size.height)
|
|
82
|
+
})
|
|
83
|
+
const columnOffsets = columnWidths.map(
|
|
84
|
+
(_, index) =>
|
|
85
|
+
columnWidths.slice(0, index).reduce((sum, width) => sum + width, 0) + gapX * index
|
|
86
|
+
)
|
|
87
|
+
const rowOffsets = rowHeights.map(
|
|
88
|
+
(_, index) =>
|
|
89
|
+
rowHeights.slice(0, index).reduce((sum, height) => sum + height, 0) + gapY * index
|
|
90
|
+
)
|
|
91
|
+
orderedNodes.forEach((node, index) => {
|
|
92
|
+
const column = index % columnCount
|
|
93
|
+
const row = Math.floor(index / columnCount)
|
|
94
|
+
positions.push({
|
|
95
|
+
id: String(node.id),
|
|
96
|
+
position: {
|
|
97
|
+
x: originX + padding + columnOffsets[column],
|
|
98
|
+
y: originY + padding + rowOffsets[row],
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
contentWidth = columnWidths.reduce((sum, width) => sum + width, 0) + gapX * (columnCount - 1)
|
|
103
|
+
contentHeight = rowHeights.reduce((sum, height) => sum + height, 0) + gapY * (rowCount - 1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
positions,
|
|
108
|
+
size: {
|
|
109
|
+
width: Math.round(contentWidth + padding * 2),
|
|
110
|
+
height: Math.round(contentHeight + padding * 2),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|