@artflo-ai/artflo-openclaw-plugin 0.0.1

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.
Files changed (59) hide show
  1. package/README.md +102 -0
  2. package/dist/index.js +73 -0
  3. package/dist/logs/ws-traffic-traces/2026-03-27T20-30-00-216Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
  4. package/dist/logs/ws-traffic-traces/2026-03-27T20-30-00-217Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
  5. package/dist/logs/ws-traffic-traces/2026-03-27T20-30-05-727Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
  6. package/dist/logs/ws-traffic-traces/2026-03-27T20-30-30-218Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
  7. package/dist/logs/ws-traffic-traces/2026-03-27T20-30-30-218Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
  8. package/dist/logs/ws-traffic-traces/2026-03-27T20-30-35-728Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
  9. package/dist/logs/ws-traffic-traces/2026-03-27T20-31-00-218Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
  10. package/dist/logs/ws-traffic-traces/2026-03-27T20-31-00-219Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
  11. package/dist/logs/ws-traffic-traces/2026-03-27T20-31-05-729Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
  12. package/dist/logs/ws-traffic-traces/2026-03-27T20-31-30-220Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
  13. package/dist/logs/ws-traffic-traces/2026-03-27T20-31-30-220Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
  14. package/dist/logs/ws-traffic-traces/2026-03-27T20-31-35-729Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
  15. package/dist/logs/ws-traffic-traces/2026-03-27T20-32-00-221Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
  16. package/dist/logs/ws-traffic-traces/2026-03-27T20-32-00-221Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
  17. package/dist/logs/ws-traffic-traces/2026-03-27T20-32-05-730Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
  18. package/dist/logs/ws-traffic-traces/2026-03-27T20-32-30-222Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
  19. package/dist/logs/ws-traffic-traces/2026-03-27T20-32-30-222Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
  20. package/dist/logs/ws-traffic-traces/2026-03-27T20-32-35-731Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
  21. package/dist/logs/ws-traffic-traces/2026-03-27T20-33-00-223Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
  22. package/dist/logs/ws-traffic-traces/2026-03-27T20-33-00-223Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
  23. package/dist/logs/ws-traffic-traces/2026-03-27T20-33-05-732Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
  24. package/dist/logs/ws-traffic-traces/2026-03-27T20-33-30-223Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
  25. package/dist/logs/ws-traffic-traces/2026-03-27T20-33-30-223Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
  26. package/dist/logs/ws-traffic-traces/2026-03-27T20-33-35-734Z+08-lifecycle-heartbeat_ping-2c637466-6c6f-6172-400c-15145e021b0a.json +9 -0
  27. package/dist/logs/ws-traffic-traces/2026-03-27T20-34-00-228Z+08-lifecycle-heartbeat_ping-2b637466-6c6f-6172-470c-151459021b0a.json +9 -0
  28. package/dist/logs/ws-traffic-traces/2026-03-27T20-34-00-229Z+08-lifecycle-heartbeat_ping-2d637466-6c6f-6172-410c-15145f021b0a.json +9 -0
  29. package/dist/src/config.js +57 -0
  30. package/dist/src/constants.js +35 -0
  31. package/dist/src/core/api/api-base.js +12 -0
  32. package/dist/src/core/api/upload-file.js +59 -0
  33. package/dist/src/core/canvas/canvas-session-manager.js +189 -0
  34. package/dist/src/core/canvas/canvas-websocket-client.js +453 -0
  35. package/dist/src/core/canvas/create-canvas.js +37 -0
  36. package/dist/src/core/canvas/types.js +23 -0
  37. package/dist/src/core/canvas/ws-trace.js +42 -0
  38. package/dist/src/core/config/fetch-client-params.js +20 -0
  39. package/dist/src/core/config/fetch-vip-info.js +30 -0
  40. package/dist/src/core/config/model-config-transformer.js +104 -0
  41. package/dist/src/core/executor/element-builders.js +216 -0
  42. package/dist/src/core/executor/execute-plan.js +1221 -0
  43. package/dist/src/core/executor/execution-trace.js +34 -0
  44. package/dist/src/core/layout/layout-service.js +366 -0
  45. package/dist/src/core/plan/analyze-plan-groups.js +71 -0
  46. package/dist/src/core/plan/types.js +1 -0
  47. package/dist/src/core/plan/validate-plan.js +159 -0
  48. package/dist/src/paths.js +16 -0
  49. package/dist/src/services/canvas-session-registry.js +57 -0
  50. package/dist/src/tools/register-tools.js +669 -0
  51. package/dist/src/tools/tool-trace.js +19 -0
  52. package/openclaw.plugin.json +33 -0
  53. package/package.json +42 -0
  54. package/skills/artflo-canvas/SKILL.md +118 -0
  55. package/skills/artflo-canvas/references/graph-rules.md +53 -0
  56. package/skills/artflo-canvas/references/layout-notes.md +31 -0
  57. package/skills/artflo-canvas/references/node-schema.json +948 -0
  58. package/skills/artflo-canvas/references/node-schema.md +188 -0
  59. package/skills/artflo-canvas/references/planning-guide.md +321 -0
@@ -0,0 +1,1221 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { CANVAS_NODE_TYPES, buildEdgeElement, buildNodeElement } from './element-builders.js';
3
+ import { calculateBatchGroupLayout, calculatePlanFlowLayout } from '../layout/layout-service.js';
4
+ import { analyzePlanGroups } from '../plan/analyze-plan-groups.js';
5
+ export async function executePlan(args) {
6
+ const { plan, session, traceWriter } = args;
7
+ const nodeMap = {};
8
+ const createdNodeIds = [];
9
+ const createdEdgeIds = [];
10
+ let executedNodeIds = [];
11
+ let executionCompleted = null;
12
+ const existingElements = session.getElements();
13
+ const existingNodeByRef = buildExistingNodeRefMap(existingElements, plan.planId);
14
+ const existingEdgeKeys = buildExistingEdgeKeySet(existingElements);
15
+ const explicitEdgeTargets = new Set(plan.edges.map((edge) => edge.to));
16
+ const groupAnalysis = analyzePlanGroups(plan);
17
+ await traceWriter?.writeMeta('input', {
18
+ plan,
19
+ existingElementCount: existingElements.length,
20
+ existingNodeRefs: Array.from(existingNodeByRef.keys()),
21
+ });
22
+ const refPositionMap = new Map();
23
+ const batchRefs = new Set(plan.nodes.filter((node) => node.type === 'batch').map((node) => node.ref));
24
+ // Defer nodes that transitively depend on a batch node.
25
+ // A batch node expands dynamically after its upstream (e.g. Refine) completes,
26
+ // so anything downstream of a batch must wait.
27
+ const deferredNodeRefs = new Set();
28
+ {
29
+ // Build adjacency: ref -> set of downstream refs
30
+ const downstreamOf = new Map();
31
+ for (const node of plan.nodes) {
32
+ downstreamOf.set(node.ref, new Set());
33
+ }
34
+ for (const edge of plan.edges) {
35
+ downstreamOf.get(edge.from)?.add(edge.to);
36
+ }
37
+ for (const node of plan.nodes) {
38
+ if (node.dependsOn && downstreamOf.has(node.dependsOn)) {
39
+ downstreamOf.get(node.dependsOn).add(node.ref);
40
+ }
41
+ }
42
+ // BFS from each batch ref to mark all transitive downstream as deferred
43
+ const queue = Array.from(batchRefs);
44
+ for (const batchRef of queue) {
45
+ deferredNodeRefs.add(batchRef);
46
+ }
47
+ while (queue.length > 0) {
48
+ const current = queue.shift();
49
+ const children = downstreamOf.get(current);
50
+ if (!children)
51
+ continue;
52
+ for (const child of children) {
53
+ if (!deferredNodeRefs.has(child)) {
54
+ deferredNodeRefs.add(child);
55
+ queue.push(child);
56
+ }
57
+ }
58
+ }
59
+ }
60
+ const nonDeferredPlan = {
61
+ ...plan,
62
+ nodes: plan.nodes.filter((node) => !deferredNodeRefs.has(node.ref)),
63
+ edges: plan.edges.filter((edge) => !deferredNodeRefs.has(edge.from) && !deferredNodeRefs.has(edge.to)),
64
+ };
65
+ const flowLayout = calculatePlanFlowLayout({
66
+ plan: nonDeferredPlan,
67
+ canvasElements: existingElements,
68
+ });
69
+ for (const [ref, position] of flowLayout.positions.entries()) {
70
+ refPositionMap.set(ref, position);
71
+ }
72
+ await traceWriter?.writeRound('flow-layout', {
73
+ roots: flowLayout.roots,
74
+ subtreeHeights: flowLayout.subtreeHeights,
75
+ positions: Object.fromEntries(flowLayout.positions.entries()),
76
+ });
77
+ for (const batch of groupAnalysis.batches) {
78
+ const batchSourceSpec = plan.nodes.find((node) => node.ref === batch.sourceRef);
79
+ if (batchSourceSpec?.type !== 'batch') {
80
+ continue;
81
+ }
82
+ const batchSourceId = nodeMap[batch.sourceRef];
83
+ const groupSpecs = batch.groups.map((group) => ({
84
+ childCount: group.childRefs.length,
85
+ childType: group.childType,
86
+ layoutDirection: group.layoutDirection,
87
+ }));
88
+ if (groupSpecs.length === 0)
89
+ continue;
90
+ const batchLayout = calculateBatchGroupLayout(session.getElements(), batchSourceId, groupSpecs);
91
+ for (let groupIndex = 0; groupIndex < batch.groups.length; groupIndex += 1) {
92
+ const group = batch.groups[groupIndex];
93
+ const groupLayout = batchLayout.groups[groupIndex];
94
+ if (!groupLayout)
95
+ continue;
96
+ refPositionMap.set(group.headRef, groupLayout.inputPosition);
97
+ for (let childIndex = 0; childIndex < group.childRefs.length; childIndex += 1) {
98
+ if (groupLayout.childPositions[childIndex]) {
99
+ refPositionMap.set(group.childRefs[childIndex], groupLayout.childPositions[childIndex]);
100
+ }
101
+ }
102
+ }
103
+ }
104
+ const materializedNodes = [];
105
+ for (const node of plan.nodes) {
106
+ if (deferredNodeRefs.has(node.ref)) {
107
+ continue;
108
+ }
109
+ const existingNodeId = existingNodeByRef.get(node.ref);
110
+ if (existingNodeId) {
111
+ nodeMap[node.ref] = existingNodeId;
112
+ const nextElement = buildNodeElement({
113
+ id: existingNodeId,
114
+ node,
115
+ sourceNodeId: node.dependsOn ? nodeMap[node.dependsOn] : undefined,
116
+ canvasElements: session.getElements(),
117
+ positionOverride: refPositionMap.get(node.ref),
118
+ planId: plan.planId,
119
+ });
120
+ await session.changeElements([
121
+ {
122
+ id: existingNodeId,
123
+ data: nextElement.data,
124
+ },
125
+ ]);
126
+ materializedNodes.push({
127
+ ref: node.ref,
128
+ id: existingNodeId,
129
+ type: node.type,
130
+ action: 'reused',
131
+ });
132
+ continue;
133
+ }
134
+ const nodeId = randomUUID().replace(/-/g, '');
135
+ nodeMap[node.ref] = nodeId;
136
+ const sourceNodeId = node.dependsOn ? nodeMap[node.dependsOn] : undefined;
137
+ const layoutInputElements = session.getElements();
138
+ const element = buildNodeElement({
139
+ id: nodeId,
140
+ node,
141
+ sourceNodeId,
142
+ canvasElements: layoutInputElements,
143
+ positionOverride: refPositionMap.get(node.ref),
144
+ planId: plan.planId,
145
+ });
146
+ await session.addElements([element]);
147
+ createdNodeIds.push(nodeId);
148
+ materializedNodes.push({
149
+ ref: node.ref,
150
+ id: nodeId,
151
+ type: node.type,
152
+ action: 'created',
153
+ position: element.position ?? null,
154
+ sourceNodeId: sourceNodeId ?? null,
155
+ layoutStrategy: element.__layoutDebug?.strategy ?? null,
156
+ });
157
+ if (node.type === 'input' &&
158
+ sourceNodeId &&
159
+ !explicitEdgeTargets.has(node.ref)) {
160
+ const implicitResult = await tryCreateImplicitInputSourceEdge(sourceNodeId, nodeId, session, existingEdgeKeys);
161
+ if (implicitResult.createdEdgeId) {
162
+ createdEdgeIds.push(implicitResult.createdEdgeId);
163
+ }
164
+ materializedNodes.push({
165
+ ref: node.ref,
166
+ id: nodeId,
167
+ action: 'implicit_source_to_input_edge',
168
+ edgeId: implicitResult.createdEdgeId ?? null,
169
+ });
170
+ }
171
+ }
172
+ await traceWriter?.writeRound('node-materialization', {
173
+ createdNodeIds,
174
+ nodeMap,
175
+ deferredNodeRefs: Array.from(deferredNodeRefs),
176
+ operations: materializedNodes,
177
+ });
178
+ // Backfill nodeMap for raw canvas element IDs referenced in edges.
179
+ // Agents may reference existing canvas elements by their raw ID (not a plan ref).
180
+ // Without this, tryCreateEdgeFromSpec cannot resolve sourceId/targetId.
181
+ {
182
+ const allElements = session.getElements();
183
+ const elementIdSet = new Set(allElements.map((el) => el.id));
184
+ for (const edge of plan.edges) {
185
+ for (const ref of [edge.from, edge.to]) {
186
+ if (!nodeMap[ref] && elementIdSet.has(ref)) {
187
+ nodeMap[ref] = ref;
188
+ }
189
+ }
190
+ }
191
+ }
192
+ const edgeOperations = [];
193
+ for (const edge of plan.edges) {
194
+ if (deferredNodeRefs.has(edge.from) || deferredNodeRefs.has(edge.to)) {
195
+ continue;
196
+ }
197
+ if (shouldSkipRedundantProcessToProcessEdge(edge, plan)) {
198
+ edgeOperations.push({
199
+ ref: edge.ref || `${edge.from}-${edge.to}`,
200
+ action: 'skipped_redundant_process_edge',
201
+ from: edge.from,
202
+ to: edge.to,
203
+ });
204
+ continue;
205
+ }
206
+ const edgeResult = await tryCreateEdgeFromSpec({
207
+ edge,
208
+ plan,
209
+ nodeMap,
210
+ session,
211
+ existingEdgeKeys,
212
+ });
213
+ if (edgeResult.createdEdgeId) {
214
+ createdEdgeIds.push(edgeResult.createdEdgeId);
215
+ edgeOperations.push({
216
+ ref: edge.ref || `${edge.from}-${edge.to}`,
217
+ action: 'created',
218
+ edgeId: edgeResult.createdEdgeId,
219
+ from: edge.from,
220
+ to: edge.to,
221
+ });
222
+ }
223
+ else {
224
+ edgeOperations.push({
225
+ ref: edge.ref || `${edge.from}-${edge.to}`,
226
+ action: 'reused_or_skipped',
227
+ from: edge.from,
228
+ to: edge.to,
229
+ });
230
+ }
231
+ }
232
+ await traceWriter?.writeRound('explicit-edge-materialization', {
233
+ createdEdgeIds,
234
+ operations: edgeOperations,
235
+ });
236
+ const executableBatches = buildExecutableExecutionBatches(plan, plan.nodes
237
+ .filter((node) => isExecutableNodeType(node.type))
238
+ .map((node) => node.ref)
239
+ .filter((ref) => !batchRefs.has(ref)));
240
+ if (executableBatches.length > 0) {
241
+ executionCompleted = true;
242
+ for (const batch of executableBatches) {
243
+ const batchNodeIds = batch
244
+ .map((ref) => nodeMap[ref])
245
+ .filter((id) => Boolean(id));
246
+ await traceWriter?.writeRound('execute-batch-start', {
247
+ refs: batch,
248
+ nodeIds: batchNodeIds,
249
+ });
250
+ if (batchNodeIds.length === 0)
251
+ continue;
252
+ await session.executeNodes(batchNodeIds);
253
+ // Wait with retry: some nodes (e.g. Refine with logo_design) can take
254
+ // much longer than 5 minutes. Retry up to 3 times (total ~15 min).
255
+ let batchCompleted = false;
256
+ for (let attempt = 0; attempt < 3; attempt += 1) {
257
+ batchCompleted = await session.waitForCompletion(batchNodeIds, 300_000);
258
+ if (batchCompleted)
259
+ break;
260
+ // Check if any node actually failed (status 400) vs still running
261
+ const allDone = batchNodeIds.every((id) => {
262
+ const el = session.getElement(id);
263
+ const status = el?.data?.status;
264
+ return status === 3 || status === 400;
265
+ });
266
+ if (allDone) {
267
+ batchCompleted = true;
268
+ break;
269
+ }
270
+ }
271
+ executedNodeIds.push(...batchNodeIds);
272
+ if (!batchCompleted) {
273
+ executionCompleted = false;
274
+ }
275
+ await traceWriter?.writeRound('execute-batch-finish', {
276
+ refs: batch,
277
+ nodeIds: batchNodeIds,
278
+ completed: batchCompleted,
279
+ nodeStates: batchNodeIds.map((id) => ({
280
+ id,
281
+ data: session.getElement(id)?.data ?? null,
282
+ })),
283
+ });
284
+ }
285
+ }
286
+ const batchResults = await executeBatchNodes({
287
+ plan,
288
+ session,
289
+ nodeMap,
290
+ createdNodeIds,
291
+ createdEdgeIds,
292
+ existingNodeByRef,
293
+ existingEdgeKeys,
294
+ traceWriter,
295
+ });
296
+ if (batchResults.executedNodeIds.length > 0) {
297
+ executedNodeIds = [...executedNodeIds, ...batchResults.executedNodeIds];
298
+ executionCompleted =
299
+ executionCompleted === false || batchResults.executionCompleted === false
300
+ ? false
301
+ : executionCompleted === true || batchResults.executionCompleted === true
302
+ ? true
303
+ : executionCompleted;
304
+ }
305
+ return {
306
+ createdNodeIds,
307
+ createdEdgeIds,
308
+ nodeMap,
309
+ executedNodeIds,
310
+ executionCompleted,
311
+ traceDirectory: traceWriter?.directory,
312
+ };
313
+ }
314
+ function isExecutableNodeType(type) {
315
+ return type === 'process' || type === 'refine' || type === 'crop';
316
+ }
317
+ function buildExecutableExecutionBatches(plan, executableRefs) {
318
+ const uniqueExecutableRefs = Array.from(new Set(executableRefs));
319
+ if (uniqueExecutableRefs.length === 0) {
320
+ return [];
321
+ }
322
+ const nodeOrder = new Map();
323
+ plan.nodes.forEach((node, index) => nodeOrder.set(node.ref, index));
324
+ const incomingRefs = buildIncomingRefsMap(plan);
325
+ const executableRefSet = new Set(uniqueExecutableRefs);
326
+ const inDegree = new Map();
327
+ const dependents = new Map();
328
+ for (const ref of uniqueExecutableRefs) {
329
+ inDegree.set(ref, 0);
330
+ dependents.set(ref, new Set());
331
+ }
332
+ for (const ref of uniqueExecutableRefs) {
333
+ const upstreamExecutableRefs = collectUpstreamExecutableDependencies(ref, incomingRefs, executableRefSet);
334
+ inDegree.set(ref, upstreamExecutableRefs.size);
335
+ for (const dependencyRef of upstreamExecutableRefs) {
336
+ dependents.get(dependencyRef)?.add(ref);
337
+ }
338
+ }
339
+ const sortByPlanOrder = (refs) => refs.sort((a, b) => (nodeOrder.get(a) ?? Number.MAX_SAFE_INTEGER) -
340
+ (nodeOrder.get(b) ?? Number.MAX_SAFE_INTEGER));
341
+ let readyRefs = sortByPlanOrder(uniqueExecutableRefs.filter((ref) => (inDegree.get(ref) || 0) === 0));
342
+ const executedRefs = new Set();
343
+ const batches = [];
344
+ while (readyRefs.length > 0) {
345
+ const currentBatch = [...readyRefs];
346
+ batches.push(currentBatch);
347
+ readyRefs = [];
348
+ const nextReadyRefs = new Set();
349
+ for (const ref of currentBatch) {
350
+ executedRefs.add(ref);
351
+ const downstreamRefs = dependents.get(ref);
352
+ if (!downstreamRefs)
353
+ continue;
354
+ for (const downstreamRef of downstreamRefs) {
355
+ const nextInDegree = (inDegree.get(downstreamRef) || 0) - 1;
356
+ inDegree.set(downstreamRef, nextInDegree);
357
+ if (nextInDegree === 0 && !executedRefs.has(downstreamRef)) {
358
+ nextReadyRefs.add(downstreamRef);
359
+ }
360
+ }
361
+ }
362
+ readyRefs = sortByPlanOrder(Array.from(nextReadyRefs));
363
+ }
364
+ if (executedRefs.size < uniqueExecutableRefs.length) {
365
+ const unresolvedRefs = sortByPlanOrder(uniqueExecutableRefs.filter((ref) => !executedRefs.has(ref)));
366
+ unresolvedRefs.forEach((ref) => batches.push([ref]));
367
+ }
368
+ return batches;
369
+ }
370
+ function buildIncomingRefsMap(plan) {
371
+ const incomingRefs = new Map();
372
+ for (const node of plan.nodes) {
373
+ incomingRefs.set(node.ref, new Set());
374
+ }
375
+ for (const edge of plan.edges) {
376
+ incomingRefs.get(edge.to)?.add(edge.from);
377
+ }
378
+ for (const node of plan.nodes) {
379
+ if (node.dependsOn) {
380
+ incomingRefs.get(node.ref)?.add(node.dependsOn);
381
+ }
382
+ }
383
+ return incomingRefs;
384
+ }
385
+ function collectUpstreamExecutableDependencies(targetRef, incomingRefs, executableRefSet) {
386
+ const dependencies = new Set();
387
+ const visitedRefs = new Set();
388
+ const queue = Array.from(incomingRefs.get(targetRef) || []);
389
+ while (queue.length > 0) {
390
+ const currentRef = queue.shift();
391
+ if (!currentRef || currentRef === targetRef || visitedRefs.has(currentRef)) {
392
+ continue;
393
+ }
394
+ visitedRefs.add(currentRef);
395
+ if (executableRefSet.has(currentRef)) {
396
+ dependencies.add(currentRef);
397
+ continue;
398
+ }
399
+ const parentRefs = incomingRefs.get(currentRef);
400
+ if (!parentRefs || parentRefs.size === 0)
401
+ continue;
402
+ for (const parentRef of parentRefs) {
403
+ if (!visitedRefs.has(parentRef)) {
404
+ queue.push(parentRef);
405
+ }
406
+ }
407
+ }
408
+ return dependencies;
409
+ }
410
+ async function executeBatchNodes(args) {
411
+ const { plan, session, nodeMap, createdNodeIds, createdEdgeIds, existingNodeByRef, existingEdgeKeys, traceWriter, } = args;
412
+ const executedNodeIds = [];
413
+ let executionCompleted = null;
414
+ const selectorRefs = new Set(plan.nodes
415
+ .filter((node) => node.type === 'selector')
416
+ .map((node) => node.ref));
417
+ for (const batchNode of plan.nodes.filter((node) => node.type === 'batch')) {
418
+ const sourceRef = batchNode.dependsOn;
419
+ if (!sourceRef) {
420
+ continue;
421
+ }
422
+ const sourceId = nodeMap[sourceRef];
423
+ const sourceElement = sourceId ? session.getElement(sourceId) : undefined;
424
+ if (!sourceId || !sourceElement) {
425
+ continue;
426
+ }
427
+ const outputs = extractBatchOutputs(sourceElement);
428
+ if (outputs.length === 0) {
429
+ await traceWriter?.writeRound('batch-no-outputs', {
430
+ batchRef: batchNode.ref,
431
+ sourceRef,
432
+ sourceId,
433
+ });
434
+ continue;
435
+ }
436
+ const downstreamEdge = plan.edges.find((edge) => edge.from === batchNode.ref);
437
+ const downstreamSelector = downstreamEdge && selectorRefs.has(downstreamEdge.to)
438
+ ? plan.nodes.find((node) => node.ref === downstreamEdge.to)
439
+ : undefined;
440
+ const models = Array.isArray(batchNode.data.models) && batchNode.data.models.length > 0
441
+ ? batchNode.data.models
442
+ : [{ model: batchNode.data.current_model || batchNode.data.model || 'default' }];
443
+ let selectorId;
444
+ const batchProcessIds = [];
445
+ await traceWriter?.writeRound('batch-prepare', {
446
+ batchRef: batchNode.ref,
447
+ sourceRef,
448
+ sourceId,
449
+ outputCount: outputs.length,
450
+ downstreamSelectorRef: downstreamSelector?.ref ?? null,
451
+ models,
452
+ });
453
+ // Pre-calculate layout positions for all batch-expanded nodes.
454
+ // Each output produces 1 input + N process nodes (one per model).
455
+ const batchPositionMap = new Map();
456
+ {
457
+ const groupSpecs = outputs.map(() => ({
458
+ childCount: models.length,
459
+ childType: 'process',
460
+ layoutDirection: 'horizontal',
461
+ }));
462
+ const batchLayout = calculateBatchGroupLayout(session.getElements(), sourceId, groupSpecs);
463
+ for (let outputIdx = 0; outputIdx < outputs.length; outputIdx += 1) {
464
+ const groupResult = batchLayout.groups[outputIdx];
465
+ if (!groupResult)
466
+ continue;
467
+ const inputRef = `${batchNode.ref}_input_${outputs[outputIdx].index}`;
468
+ batchPositionMap.set(inputRef, groupResult.inputPosition);
469
+ let modelIdx = 0;
470
+ for (const modelSpec of models) {
471
+ const processRef = typeof modelSpec === 'string'
472
+ ? `${batchNode.ref}_process_${outputs[outputIdx].index}_${sanitizeRefFragment(modelSpec)}`
473
+ : `${batchNode.ref}_process_${outputs[outputIdx].index}_${sanitizeRefFragment(modelSpec.model)}`;
474
+ if (groupResult.childPositions[modelIdx]) {
475
+ batchPositionMap.set(processRef, groupResult.childPositions[modelIdx]);
476
+ }
477
+ modelIdx += 1;
478
+ }
479
+ }
480
+ }
481
+ for (const output of outputs) {
482
+ const inputRef = `${batchNode.ref}_input_${output.index}`;
483
+ const existingInputId = existingNodeByRef.get(inputRef) ||
484
+ findExistingBatchInputId(session.getElements(), sourceId, output.index);
485
+ const inputId = existingInputId || randomUUID().replace(/-/g, '');
486
+ nodeMap[inputRef] = inputId;
487
+ const inputNode = buildNodeElement({
488
+ id: inputId,
489
+ node: {
490
+ ref: inputRef,
491
+ type: 'input',
492
+ data: {
493
+ prompt: output.prompt,
494
+ },
495
+ dependsOn: sourceRef,
496
+ },
497
+ sourceNodeId: sourceId,
498
+ canvasElements: session.getElements(),
499
+ planId: plan.planId,
500
+ positionOverride: batchPositionMap.get(inputRef),
501
+ });
502
+ if (existingInputId) {
503
+ await session.changeElements([{ id: inputId, data: inputNode.data }]);
504
+ }
505
+ else {
506
+ await session.addElements([inputNode]);
507
+ createdNodeIds.push(inputId);
508
+ }
509
+ const inputTarget = ensureInputTargetHandle(inputNode, sourceElement, sourceId, output.index);
510
+ await session.changeElements([inputTarget.change]);
511
+ const sourceToInput = await tryCreateEdgeFromSpec({
512
+ edge: {
513
+ ref: `${batchNode.ref}:source:${output.index}`,
514
+ from: sourceRef,
515
+ to: inputRef,
516
+ fromHandle: output.index,
517
+ toHandle: inputTarget.handle,
518
+ },
519
+ plan,
520
+ nodeMap,
521
+ session,
522
+ existingEdgeKeys,
523
+ });
524
+ if (sourceToInput.createdEdgeId) {
525
+ createdEdgeIds.push(sourceToInput.createdEdgeId);
526
+ }
527
+ const existingProcessIds = findExistingBatchProcessIds(session.getElements(), inputId);
528
+ let modelIndex = 0;
529
+ for (const modelSpec of models) {
530
+ const processRef = typeof modelSpec === 'string'
531
+ ? `${batchNode.ref}_process_${output.index}_${sanitizeRefFragment(modelSpec)}`
532
+ : `${batchNode.ref}_process_${output.index}_${sanitizeRefFragment(modelSpec.model)}`;
533
+ const processId = existingNodeByRef.get(processRef) ||
534
+ existingProcessIds[modelIndex] ||
535
+ randomUUID().replace(/-/g, '');
536
+ nodeMap[processRef] = processId;
537
+ const processNode = buildNodeElement({
538
+ id: processId,
539
+ node: {
540
+ ref: processRef,
541
+ type: 'process',
542
+ data: {
543
+ ...batchNode.data,
544
+ prompt: output.prompt,
545
+ model: typeof modelSpec === 'string'
546
+ ? modelSpec
547
+ : modelSpec.model,
548
+ current_model: typeof modelSpec === 'string'
549
+ ? modelSpec
550
+ : modelSpec.model,
551
+ ratio: typeof modelSpec === 'object' && modelSpec.ratio
552
+ ? modelSpec.ratio
553
+ : batchNode.data.ratio,
554
+ resolution: typeof modelSpec === 'object' && modelSpec.resolution
555
+ ? modelSpec.resolution
556
+ : batchNode.data.resolution,
557
+ count: typeof modelSpec === 'object' && modelSpec.count
558
+ ? modelSpec.count
559
+ : batchNode.data.count,
560
+ },
561
+ dependsOn: inputRef,
562
+ },
563
+ sourceNodeId: inputId,
564
+ canvasElements: session.getElements(),
565
+ planId: plan.planId,
566
+ positionOverride: batchPositionMap.get(processRef),
567
+ });
568
+ if (existingNodeByRef.has(processRef) || existingProcessIds[modelIndex]) {
569
+ await session.changeElements([
570
+ {
571
+ id: processId,
572
+ data: {
573
+ ...processNode.data,
574
+ status: 0,
575
+ executed: false,
576
+ },
577
+ },
578
+ ]);
579
+ }
580
+ else {
581
+ await session.addElements([processNode]);
582
+ createdNodeIds.push(processId);
583
+ }
584
+ batchProcessIds.push(processId);
585
+ const inputToProcess = await tryCreateEdgeFromSpec({
586
+ edge: {
587
+ ref: `${inputRef}->${processRef}`,
588
+ from: inputRef,
589
+ to: processRef,
590
+ },
591
+ plan,
592
+ nodeMap,
593
+ session,
594
+ existingEdgeKeys,
595
+ });
596
+ if (inputToProcess.createdEdgeId) {
597
+ createdEdgeIds.push(inputToProcess.createdEdgeId);
598
+ }
599
+ modelIndex += 1;
600
+ }
601
+ await traceWriter?.writeRound('batch-output-materialized', {
602
+ batchRef: batchNode.ref,
603
+ outputIndex: output.index,
604
+ inputRef,
605
+ inputId,
606
+ processIds: [...batchProcessIds],
607
+ });
608
+ }
609
+ if (batchProcessIds.length > 0) {
610
+ await traceWriter?.writeRound('batch-run-start', {
611
+ batchRef: batchNode.ref,
612
+ batchProcessIds,
613
+ });
614
+ await session.executeNodes(batchProcessIds);
615
+ let batchCompleted = false;
616
+ for (let attempt = 0; attempt < 3; attempt += 1) {
617
+ batchCompleted = await session.waitForCompletion(batchProcessIds, 300_000);
618
+ if (batchCompleted)
619
+ break;
620
+ const allDone = batchProcessIds.every((id) => {
621
+ const el = session.getElement(id);
622
+ const status = el?.data?.status;
623
+ return status === 3 || status === 400;
624
+ });
625
+ if (allDone) {
626
+ batchCompleted = true;
627
+ break;
628
+ }
629
+ }
630
+ executedNodeIds.push(...batchProcessIds);
631
+ executionCompleted =
632
+ executionCompleted === false || batchCompleted === false
633
+ ? false
634
+ : true;
635
+ await traceWriter?.writeRound('batch-run-finish', {
636
+ batchRef: batchNode.ref,
637
+ batchProcessIds,
638
+ completed: batchCompleted,
639
+ outputs: batchProcessIds.map((id) => ({
640
+ id,
641
+ medias: collectProcessOutputMedias(session.getElement(id)),
642
+ })),
643
+ });
644
+ }
645
+ if (selectorId === undefined && downstreamSelector && batchProcessIds.length > 0) {
646
+ // All batch processes are done — the Selector's upstream is now ready.
647
+ selectorId =
648
+ existingNodeByRef.get(downstreamSelector.ref) ||
649
+ randomUUID().replace(/-/g, '');
650
+ nodeMap[downstreamSelector.ref] = selectorId;
651
+ // Position selector to the right of the rightmost batch process node
652
+ const batchProcessElements = batchProcessIds
653
+ .map((id) => session.getElement(id))
654
+ .filter((el) => el !== undefined);
655
+ const rightmostX = batchProcessElements.reduce((max, el) => {
656
+ const x = (el.position?.x ?? 0) + (el.measured?.width ?? 260);
657
+ return x > max ? x : max;
658
+ }, 0);
659
+ const avgY = batchProcessElements.length > 0
660
+ ? batchProcessElements.reduce((sum, el) => sum + (el.position?.y ?? 0), 0) /
661
+ batchProcessElements.length
662
+ : 0;
663
+ const selectorElement = buildNodeElement({
664
+ id: selectorId,
665
+ node: downstreamSelector,
666
+ sourceNodeId: sourceId,
667
+ canvasElements: session.getElements(),
668
+ positionOverride: { x: rightmostX + 100, y: avgY },
669
+ planId: plan.planId,
670
+ });
671
+ if (existingNodeByRef.has(downstreamSelector.ref)) {
672
+ await session.changeElements([{ id: selectorId, data: selectorElement.data }]);
673
+ }
674
+ else {
675
+ await session.addElements([selectorElement]);
676
+ createdNodeIds.push(selectorId);
677
+ }
678
+ }
679
+ else if (downstreamSelector && nodeMap[downstreamSelector.ref]) {
680
+ selectorId = nodeMap[downstreamSelector.ref];
681
+ }
682
+ if (selectorId && batchProcessIds.length > 0) {
683
+ const selectorElement = session.getElement(selectorId);
684
+ if (selectorElement) {
685
+ for (const processId of batchProcessIds) {
686
+ const processElement = session.getElement(processId);
687
+ const outputMedias = collectProcessOutputMedias(processElement);
688
+ // If process has real output medias, wire them
689
+ if (outputMedias.length > 0) {
690
+ for (const outputMedia of outputMedias) {
691
+ const fromHandle = typeof outputMedia.id === 'string' ? outputMedia.id : undefined;
692
+ const selectorTarget = ensureSelectorTargetHandle(selectorElement, processElement || selectorElement, processId, fromHandle);
693
+ await session.changeElements([selectorTarget.change]);
694
+ // Create edge directly from process to selector using real IDs
695
+ const edgeId = randomUUID().replace(/-/g, '');
696
+ const edgeKey = buildEdgeKey(processId, selectorId, fromHandle, selectorTarget.handle);
697
+ if (!existingEdgeKeys.has(edgeKey)) {
698
+ const edgeElement = buildEdgeElement({
699
+ id: edgeId,
700
+ edge: {
701
+ from: processId,
702
+ to: selectorId,
703
+ fromHandle,
704
+ toHandle: selectorTarget.handle,
705
+ },
706
+ sourceId: processId,
707
+ targetId: selectorId,
708
+ });
709
+ await session.addElements([edgeElement]);
710
+ existingEdgeKeys.add(edgeKey);
711
+ createdEdgeIds.push(edgeId);
712
+ }
713
+ }
714
+ }
715
+ else {
716
+ // No real outputs yet — create placeholder handle
717
+ const selectorTarget = ensureSelectorTargetHandle(selectorElement, processElement || selectorElement, processId, undefined);
718
+ await session.changeElements([selectorTarget.change]);
719
+ const edgeKey = buildEdgeKey(processId, selectorId, undefined, selectorTarget.handle);
720
+ if (!existingEdgeKeys.has(edgeKey)) {
721
+ const edgeId = randomUUID().replace(/-/g, '');
722
+ const edgeElement = buildEdgeElement({
723
+ id: edgeId,
724
+ edge: {
725
+ from: processId,
726
+ to: selectorId,
727
+ toHandle: selectorTarget.handle,
728
+ },
729
+ sourceId: processId,
730
+ targetId: selectorId,
731
+ });
732
+ await session.addElements([edgeElement]);
733
+ existingEdgeKeys.add(edgeKey);
734
+ createdEdgeIds.push(edgeId);
735
+ }
736
+ }
737
+ }
738
+ }
739
+ await traceWriter?.writeRound('batch-selector-updated', {
740
+ batchRef: batchNode.ref,
741
+ selectorId,
742
+ selectorState: session.getElement(selectorId)?.data ?? null,
743
+ });
744
+ }
745
+ }
746
+ return { executedNodeIds, executionCompleted };
747
+ }
748
+ function extractBatchOutputs(sourceElement) {
749
+ const sourceData = asRecord(sourceElement.data);
750
+ const multiOutputs = asRecord(sourceData.multi_outputs);
751
+ const outputKeys = Object.keys(multiOutputs).sort();
752
+ if (outputKeys.length > 0) {
753
+ return outputKeys.map((key) => {
754
+ const output = asRecord(multiOutputs[key]);
755
+ return {
756
+ index: key,
757
+ prompt: typeof output.prompt === 'string' ? output.prompt : '',
758
+ };
759
+ });
760
+ }
761
+ if (typeof sourceData.output_text === 'string' && sourceData.output_text.trim()) {
762
+ const separator = typeof sourceData.separator === 'string' && sourceData.separator
763
+ ? sourceData.separator
764
+ : '#@';
765
+ return sourceData.output_text
766
+ .split(separator)
767
+ .map((part) => part.trim())
768
+ .filter(Boolean)
769
+ .map((prompt, index) => ({
770
+ index: String(index),
771
+ prompt,
772
+ }));
773
+ }
774
+ return [];
775
+ }
776
+ function collectProcessOutputMedias(processElement) {
777
+ if (!processElement?.data) {
778
+ return [];
779
+ }
780
+ const processData = asRecord(processElement.data);
781
+ if (Array.isArray(processData.medias)) {
782
+ return processData.medias
783
+ .map((media) => asRecord(media))
784
+ .filter((media) => typeof media.url === 'string');
785
+ }
786
+ const medias = asRecord(processData.medias);
787
+ const values = Object.values(medias)
788
+ .map((media) => asRecord(media))
789
+ .filter((media) => typeof media.url === 'string');
790
+ if (values.length > 0) {
791
+ return values;
792
+ }
793
+ if (typeof processData.url === 'string' && processData.url) {
794
+ return [
795
+ {
796
+ id: processElement.id,
797
+ name: typeof processData.name === 'string' ? processData.name : 'image',
798
+ type: typeof processData.type === 'string' ? processData.type : 'image',
799
+ url: processData.url,
800
+ width: typeof processData.width === 'number' ? processData.width : 1024,
801
+ height: typeof processData.height === 'number' ? processData.height : 1024,
802
+ },
803
+ ];
804
+ }
805
+ return [];
806
+ }
807
+ function resolveDefaultSourceHandle(sourceElement) {
808
+ if (sourceElement.type === CANVAS_NODE_TYPES.PROCESS ||
809
+ sourceElement.type === CANVAS_NODE_TYPES.CROP ||
810
+ sourceElement.type === CANVAS_NODE_TYPES.IMAGE ||
811
+ sourceElement.type === CANVAS_NODE_TYPES.VIDEO ||
812
+ sourceElement.type === CANVAS_NODE_TYPES.RESULT) {
813
+ const media = resolveSourceMedia(sourceElement);
814
+ return typeof media?.id === 'string' ? media.id : undefined;
815
+ }
816
+ if (sourceElement.type === CANVAS_NODE_TYPES.REFINE) {
817
+ const sourceData = asRecord(sourceElement.data);
818
+ const outputs = asRecord(sourceData.multi_outputs);
819
+ const keys = Object.keys(outputs);
820
+ return keys.length > 0 ? keys[0] : undefined;
821
+ }
822
+ return undefined;
823
+ }
824
+ function normalizeSourceHandle(sourceElement, fromHandle) {
825
+ if (!fromHandle) {
826
+ return fromHandle;
827
+ }
828
+ if (sourceElement.type === CANVAS_NODE_TYPES.SELECTOR) {
829
+ return undefined;
830
+ }
831
+ return fromHandle;
832
+ }
833
+ function shouldSkipRedundantProcessToProcessEdge(edge, plan) {
834
+ const sourceNode = plan.nodes.find((node) => node.ref === edge.from);
835
+ const targetNode = plan.nodes.find((node) => node.ref === edge.to);
836
+ if (sourceNode?.type !== 'process' || targetNode?.type !== 'process') {
837
+ return false;
838
+ }
839
+ return plan.edges.some((firstHop) => {
840
+ if (firstHop.from !== edge.from || firstHop.to === edge.to) {
841
+ return false;
842
+ }
843
+ const middleNode = plan.nodes.find((node) => node.ref === firstHop.to);
844
+ if (middleNode?.type !== 'input') {
845
+ return false;
846
+ }
847
+ return plan.edges.some((secondHop) => secondHop.from === middleNode.ref && secondHop.to === edge.to);
848
+ });
849
+ }
850
+ async function tryCreateEdgeFromSpec(args) {
851
+ const { edge, nodeMap, session, existingEdgeKeys } = args;
852
+ const sourceId = nodeMap[edge.from];
853
+ const targetId = nodeMap[edge.to];
854
+ if (!sourceId || !targetId) {
855
+ return {};
856
+ }
857
+ const sourceElement = session.getElement(sourceId);
858
+ const targetElement = session.getElement(targetId);
859
+ if (!sourceElement || !targetElement) {
860
+ return {};
861
+ }
862
+ let fromHandle = normalizeSourceHandle(sourceElement, edge.fromHandle ?? resolveDefaultSourceHandle(sourceElement));
863
+ let toHandle = edge.toHandle;
864
+ let targetUpdateChange = null;
865
+ if (targetElement.type === CANVAS_NODE_TYPES.SELECTOR && !toHandle) {
866
+ const selectorTarget = ensureSelectorTargetHandle(targetElement, sourceElement, sourceId, fromHandle);
867
+ toHandle = selectorTarget.handle;
868
+ targetUpdateChange = selectorTarget.change;
869
+ }
870
+ if (targetElement.type === CANVAS_NODE_TYPES.INPUT && !toHandle) {
871
+ const inputTarget = ensureInputTargetHandle(targetElement, sourceElement, sourceId, fromHandle);
872
+ toHandle = inputTarget.handle;
873
+ targetUpdateChange = inputTarget.change;
874
+ }
875
+ const edgeKey = buildEdgeKey(sourceId, targetId, fromHandle, toHandle);
876
+ if (existingEdgeKeys.has(edgeKey)) {
877
+ return {};
878
+ }
879
+ if (targetUpdateChange) {
880
+ await session.changeElements([targetUpdateChange]);
881
+ }
882
+ const edgeId = randomUUID().replace(/-/g, '');
883
+ const edgeElement = buildEdgeElement({
884
+ id: edgeId,
885
+ edge: {
886
+ ...edge,
887
+ fromHandle,
888
+ toHandle,
889
+ },
890
+ sourceId,
891
+ targetId,
892
+ });
893
+ await session.addElements([edgeElement]);
894
+ existingEdgeKeys.add(edgeKey);
895
+ return { createdEdgeId: edgeId };
896
+ }
897
+ async function tryCreateImplicitInputSourceEdge(sourceId, targetId, session, existingEdgeKeys) {
898
+ const sourceElement = session.getElement(sourceId);
899
+ const targetElement = session.getElement(targetId);
900
+ if (!sourceElement || !targetElement) {
901
+ return {};
902
+ }
903
+ let fromHandle = normalizeSourceHandle(sourceElement, resolveDefaultSourceHandle(sourceElement));
904
+ const inputTarget = ensureInputTargetHandle(targetElement, sourceElement, sourceId, fromHandle);
905
+ fromHandle = normalizeSourceHandle(sourceElement, fromHandle);
906
+ const edgeKey = buildEdgeKey(sourceId, targetId, fromHandle, inputTarget.handle);
907
+ if (existingEdgeKeys.has(edgeKey)) {
908
+ return {};
909
+ }
910
+ await session.changeElements([inputTarget.change]);
911
+ const edgeId = randomUUID().replace(/-/g, '');
912
+ const edgeElement = buildEdgeElement({
913
+ id: edgeId,
914
+ edge: {
915
+ from: sourceId,
916
+ to: targetId,
917
+ fromHandle,
918
+ toHandle: inputTarget.handle,
919
+ },
920
+ sourceId,
921
+ targetId,
922
+ });
923
+ await session.addElements([edgeElement]);
924
+ existingEdgeKeys.add(edgeKey);
925
+ return { createdEdgeId: edgeId };
926
+ }
927
+ function ensureSelectorTargetHandle(targetElement, sourceElement, sourceId, fromHandle) {
928
+ const targetData = asRecord(targetElement.data);
929
+ const currentMedias = asRecord(targetData.medias);
930
+ const existingHandle = findExistingSelectorHandle(currentMedias, sourceId, fromHandle);
931
+ if (existingHandle) {
932
+ return {
933
+ handle: existingHandle,
934
+ change: {
935
+ id: targetElement.id,
936
+ data: {
937
+ medias: currentMedias,
938
+ selectedId: typeof targetData.selectedId === 'string' && targetData.selectedId.length > 0
939
+ ? targetData.selectedId
940
+ : existingHandle,
941
+ },
942
+ },
943
+ };
944
+ }
945
+ const handle = randomUUID().replace(/-/g, '');
946
+ const sourceMedia = resolveSourceMedia(sourceElement, fromHandle);
947
+ const mediaType = sourceMedia?.type === 'video' ? 'video' : 'image';
948
+ currentMedias[handle] = {
949
+ id: handle,
950
+ name: sourceMedia?.name || mediaType,
951
+ type: mediaType,
952
+ url: sourceMedia?.url || '',
953
+ width: sourceMedia?.width || 1024,
954
+ height: sourceMedia?.height || 1024,
955
+ source: sourceId,
956
+ };
957
+ return {
958
+ handle,
959
+ change: {
960
+ id: targetElement.id,
961
+ data: {
962
+ medias: currentMedias,
963
+ selectedId: typeof targetData.selectedId === 'string' && targetData.selectedId.length > 0
964
+ ? targetData.selectedId
965
+ : handle,
966
+ },
967
+ },
968
+ };
969
+ }
970
+ function ensureInputTargetHandle(targetElement, sourceElement, sourceId, fromHandle) {
971
+ const targetData = asRecord(targetElement.data);
972
+ if (sourceElement.type === CANVAS_NODE_TYPES.REFINE) {
973
+ const currentAddons = asRecord(targetData.addons);
974
+ const existingAddonHandle = findExistingAddonHandle(currentAddons, sourceId, fromHandle);
975
+ if (existingAddonHandle) {
976
+ currentAddons[existingAddonHandle] = {
977
+ ...asRecord(currentAddons[existingAddonHandle]),
978
+ prompt: resolveSourcePrompt(sourceElement, fromHandle),
979
+ };
980
+ return {
981
+ handle: existingAddonHandle,
982
+ change: {
983
+ id: targetElement.id,
984
+ data: {
985
+ addons: currentAddons,
986
+ },
987
+ },
988
+ };
989
+ }
990
+ const handle = randomUUID().replace(/-/g, '');
991
+ currentAddons[handle] = {
992
+ id: handle,
993
+ index: Object.keys(currentAddons).length + 1,
994
+ prompt: resolveSourcePrompt(sourceElement, fromHandle),
995
+ source: sourceId,
996
+ };
997
+ return {
998
+ handle,
999
+ change: {
1000
+ id: targetElement.id,
1001
+ data: {
1002
+ addons: currentAddons,
1003
+ },
1004
+ },
1005
+ };
1006
+ }
1007
+ const sourceMedia = resolveSourceMedia(sourceElement, fromHandle);
1008
+ const mediaType = sourceMedia?.type === 'video' ? 'video' : 'image';
1009
+ const currentMedias = asRecord(targetData.medias);
1010
+ const existingMediaHandle = findExistingInputMediaHandle(currentMedias, sourceId, fromHandle);
1011
+ if (existingMediaHandle) {
1012
+ currentMedias[existingMediaHandle] = {
1013
+ ...asRecord(currentMedias[existingMediaHandle]),
1014
+ name: sourceMedia?.name || mediaType,
1015
+ type: sourceMedia?.type || mediaType,
1016
+ url: sourceMedia?.url || '',
1017
+ width: sourceMedia?.width || 1024,
1018
+ height: sourceMedia?.height || 1024,
1019
+ source: sourceId,
1020
+ sourceHandle: fromHandle || existingMediaHandle,
1021
+ };
1022
+ return {
1023
+ handle: existingMediaHandle,
1024
+ change: {
1025
+ id: targetElement.id,
1026
+ data: {
1027
+ medias: currentMedias,
1028
+ },
1029
+ },
1030
+ };
1031
+ }
1032
+ const handle = randomUUID().replace(/-/g, '');
1033
+ currentMedias[handle] = {
1034
+ id: handle,
1035
+ name: sourceMedia?.name || mediaType,
1036
+ type: sourceMedia?.type || mediaType,
1037
+ url: sourceMedia?.url || '',
1038
+ width: sourceMedia?.width || 1024,
1039
+ height: sourceMedia?.height || 1024,
1040
+ source: sourceId,
1041
+ sourceHandle: fromHandle || handle,
1042
+ };
1043
+ return {
1044
+ handle,
1045
+ change: {
1046
+ id: targetElement.id,
1047
+ data: {
1048
+ medias: currentMedias,
1049
+ },
1050
+ },
1051
+ };
1052
+ }
1053
+ function resolveSourcePrompt(sourceElement, fromHandle) {
1054
+ const sourceData = asRecord(sourceElement.data);
1055
+ if (sourceElement.type === CANVAS_NODE_TYPES.REFINE) {
1056
+ const outputs = asRecord(sourceData.multi_outputs);
1057
+ const output = fromHandle ? asRecord(outputs[fromHandle]) : asRecord(outputs[Object.keys(outputs)[0]]);
1058
+ if (typeof output.prompt === 'string') {
1059
+ return output.prompt;
1060
+ }
1061
+ if (typeof sourceData.output_text === 'string') {
1062
+ return sourceData.output_text;
1063
+ }
1064
+ }
1065
+ if (typeof sourceData.prompt === 'string') {
1066
+ return sourceData.prompt;
1067
+ }
1068
+ return '';
1069
+ }
1070
+ function resolveSourceMedia(sourceElement, fromHandle) {
1071
+ const sourceData = asRecord(sourceElement.data);
1072
+ const mediasValue = sourceData.medias;
1073
+ if (Array.isArray(mediasValue)) {
1074
+ if (fromHandle) {
1075
+ const matched = mediasValue.find((media) => asRecord(media).id === fromHandle);
1076
+ if (matched) {
1077
+ return asRecord(matched);
1078
+ }
1079
+ }
1080
+ return mediasValue.length > 0 ? asRecord(mediasValue[0]) : null;
1081
+ }
1082
+ const medias = asRecord(mediasValue);
1083
+ if (fromHandle && isRecord(medias[fromHandle])) {
1084
+ return asRecord(medias[fromHandle]);
1085
+ }
1086
+ if (typeof sourceData.selectedId === 'string' && isRecord(medias[sourceData.selectedId])) {
1087
+ return asRecord(medias[sourceData.selectedId]);
1088
+ }
1089
+ const keys = Object.keys(medias);
1090
+ if (keys.length > 0 && isRecord(medias[keys[0]])) {
1091
+ return asRecord(medias[keys[0]]);
1092
+ }
1093
+ if (typeof sourceData.url === 'string' && sourceData.url.length > 0) {
1094
+ return {
1095
+ id: fromHandle || randomUUID().replace(/-/g, ''),
1096
+ url: sourceData.url,
1097
+ type: sourceElement.type === CANVAS_NODE_TYPES.VIDEO ? 'video' : 'image',
1098
+ };
1099
+ }
1100
+ return null;
1101
+ }
1102
+ function asRecord(value) {
1103
+ return isRecord(value) ? value : {};
1104
+ }
1105
+ function isRecord(value) {
1106
+ return typeof value === 'object' && value !== null;
1107
+ }
1108
+ function buildExistingNodeRefMap(elements, planId) {
1109
+ const map = new Map();
1110
+ for (const element of elements) {
1111
+ const data = asRecord(element.data);
1112
+ const ref = typeof data.plan_ref === 'string' ? data.plan_ref : undefined;
1113
+ if (!ref)
1114
+ continue;
1115
+ // When planId is provided, only match nodes from the same plan.
1116
+ // This prevents cross-plan ref collisions (e.g. both plans using N1, N2...).
1117
+ if (planId && typeof data.plan_id === 'string' && data.plan_id !== planId) {
1118
+ continue;
1119
+ }
1120
+ map.set(ref, element.id);
1121
+ }
1122
+ return map;
1123
+ }
1124
+ function buildExistingEdgeKeySet(elements) {
1125
+ const set = new Set();
1126
+ for (const element of elements) {
1127
+ const edge = element;
1128
+ if (element.type !== CANVAS_NODE_TYPES.EDGE || !edge.source || !edge.target) {
1129
+ continue;
1130
+ }
1131
+ set.add(buildEdgeKey(edge.source, edge.target, edge.sourceHandle, edge.targetHandle));
1132
+ }
1133
+ return set;
1134
+ }
1135
+ function buildEdgeKey(sourceId, targetId, fromHandle, toHandle) {
1136
+ return [sourceId, targetId, fromHandle || '', toHandle || ''].join('::');
1137
+ }
1138
+ function findExistingBatchInputId(elements, sourceId, outputIndex) {
1139
+ for (const element of elements) {
1140
+ if (element.type !== CANVAS_NODE_TYPES.INPUT) {
1141
+ continue;
1142
+ }
1143
+ const data = asRecord(element.data);
1144
+ const addons = asRecord(data.addons);
1145
+ const addon = Object.values(addons).map(asRecord)[0];
1146
+ if (!addon) {
1147
+ continue;
1148
+ }
1149
+ if (String(addon.index ?? '') !== String(Number(outputIndex) + 1)) {
1150
+ continue;
1151
+ }
1152
+ const hasSourceEdge = elements.some((candidate) => {
1153
+ const edge = candidate;
1154
+ return (candidate.type === CANVAS_NODE_TYPES.EDGE &&
1155
+ edge.source === sourceId &&
1156
+ edge.target === element.id);
1157
+ });
1158
+ if (hasSourceEdge) {
1159
+ return element.id;
1160
+ }
1161
+ }
1162
+ return undefined;
1163
+ }
1164
+ function findExistingBatchProcessIds(elements, inputId) {
1165
+ return elements
1166
+ .filter((element) => {
1167
+ const edge = element;
1168
+ return (element.type === CANVAS_NODE_TYPES.EDGE &&
1169
+ edge.source === inputId &&
1170
+ typeof edge.target === 'string');
1171
+ })
1172
+ .map((edge) => edge.target)
1173
+ .filter((targetId) => {
1174
+ const target = elements.find((element) => element.id === targetId);
1175
+ return Boolean(target &&
1176
+ (target.type === CANVAS_NODE_TYPES.PROCESS ||
1177
+ target.type === CANVAS_NODE_TYPES.REFINE ||
1178
+ target.type === CANVAS_NODE_TYPES.CROP));
1179
+ });
1180
+ }
1181
+ function getExpectedProcessOutputCount(processElement) {
1182
+ if (!processElement) {
1183
+ return 1;
1184
+ }
1185
+ const processData = asRecord(processElement.data);
1186
+ if (Array.isArray(processData.medias) && processData.medias.length > 0) {
1187
+ return processData.medias.length;
1188
+ }
1189
+ const medias = asRecord(processData.medias);
1190
+ if (Object.keys(medias).length > 0) {
1191
+ return Object.keys(medias).length;
1192
+ }
1193
+ if (typeof processData.count === 'number' && processData.count > 1) {
1194
+ return processData.count;
1195
+ }
1196
+ return 1;
1197
+ }
1198
+ function sanitizeRefFragment(value) {
1199
+ return value.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 40) || 'model';
1200
+ }
1201
+ function findExistingSelectorHandle(medias, sourceId, fromHandle) {
1202
+ return Object.entries(medias).find(([, media]) => {
1203
+ const record = asRecord(media);
1204
+ return (record.source === sourceId &&
1205
+ (!fromHandle || record.sourceHandle === fromHandle));
1206
+ })?.[0];
1207
+ }
1208
+ function findExistingAddonHandle(addons, sourceId, fromHandle) {
1209
+ return Object.entries(addons).find(([, addon]) => {
1210
+ const record = asRecord(addon);
1211
+ return (record.source === sourceId &&
1212
+ (!fromHandle || record.sourceHandle === fromHandle || record.id === fromHandle));
1213
+ })?.[0];
1214
+ }
1215
+ function findExistingInputMediaHandle(medias, sourceId, fromHandle) {
1216
+ return Object.entries(medias).find(([, media]) => {
1217
+ const record = asRecord(media);
1218
+ return (record.source === sourceId &&
1219
+ (!fromHandle || record.sourceHandle === fromHandle));
1220
+ })?.[0];
1221
+ }