@field/cascade 0.10.0 → 0.11.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.md +20 -3
- package/CHANGELOG.md +187 -0
- package/README.md +1 -2
- package/dist/assets/{CodeEditor-DSZBpzdy.js → CodeEditor-4O7FQ__R.js} +4 -4
- package/dist/assets/{CodePanel-sY6sZa7B.js → CodePanel-BU-autmq.js} +2 -2
- package/dist/assets/{CustomNodeDialog-BRKnqZhp.js → CustomNodeDialog-BRpDfOV5.js} +1 -1
- package/dist/assets/{OpenGraphDialog-ZsgL-iwr.js → OpenGraphDialog-BwbRlC0X.js} +1 -1
- package/dist/assets/{ProjectSettings-Cw17nNe3.js → ProjectSettings-DLOXLxcA.js} +1 -1
- package/dist/assets/{SettingsDialog-p25h3tj0.js → SettingsDialog-Bz8JxISH.js} +1 -1
- package/dist/assets/{VersionHistoryDialog-DzFLUotB.js → VersionHistoryDialog-D5Z0nJ5-.js} +1 -1
- package/dist/assets/{core-BICrEt0y.js → core-Yqi0Ni61.js} +1 -1
- package/dist/assets/{cssMode-D7TXw57I.js → cssMode-CxUzc4vp.js} +1 -1
- package/dist/assets/{definitions-DUww2Rt-.js → definitions-GreEMXLV.js} +1 -1
- package/dist/assets/{editor.api-DvkCO3ko.js → editor.api-BNFb25CX.js} +1 -1
- package/dist/assets/{freemarker2-BA2lxsF1.js → freemarker2-0ZDnWxeM.js} +1 -1
- package/dist/assets/geo-u0n_jOsD.js +1 -0
- package/dist/assets/{handlebars-BcDiyPd8.js → handlebars-Cy18MoaD.js} +1 -1
- package/dist/assets/{html-C7O4TyhX.js → html-B-2Dx7nq.js} +1 -1
- package/dist/assets/{htmlMode-WaDGF3Mj.js → htmlMode-DUAc9B_d.js} +1 -1
- package/dist/assets/{image-DR4oeyJB.js → image-cAa-Nu9O.js} +1 -1
- package/dist/assets/{index-DyvDC6pS.js → index-5ZtDAuy2.js} +42 -41
- package/dist/assets/{javascript-CpIEK7tz.js → javascript-DeQsDqP5.js} +1 -1
- package/dist/assets/{jsonMode-B2FltW0f.js → jsonMode-BiFwJVSk.js} +1 -1
- package/dist/assets/{liquid-CN6FFHZE.js → liquid-BLoHH68a.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-QhV9XtLp.js → lspLanguageFeatures-CNFgm1Kv.js} +1 -1
- package/dist/assets/math-DioIQOPJ.js +1 -0
- package/dist/assets/{mdx-DE89pafV.js → mdx-CFXQVNfs.js} +1 -1
- package/dist/assets/pop-DC_TQG9t.js +1 -0
- package/dist/assets/{python-ByTllTB9.js → python-Cf7S7p2E.js} +1 -1
- package/dist/assets/{razor-USYlRRCE.js → razor-CMHBcG-g.js} +1 -1
- package/dist/assets/{tsMode-BXpN8iUz.js → tsMode-BEih0GDj.js} +1 -1
- package/dist/assets/{typescript-DInBjGQQ.js → typescript-COEKYD_a.js} +1 -1
- package/dist/assets/{workers-C7Ye5B7D.js → workers-BNhAhMNn.js} +1 -1
- package/dist/assets/{xml-U0Uocqyo.js → xml-CPiMpnPl.js} +1 -1
- package/dist/assets/{yaml-CBw_PVYf.js → yaml-Bgp1ITLR.js} +1 -1
- package/dist/cli/Graph-ZJJTRQTM.js +2975 -0
- package/dist/cli/chunk-2KXB47FP.js +115 -0
- package/dist/cli/chunk-35D343DC.js +1447 -0
- package/dist/cli/chunk-36P5K3SD.js +631 -0
- package/dist/cli/chunk-3JTJHF7V.js +267 -0
- package/dist/cli/chunk-4UDFYF5G.js +132 -0
- package/dist/cli/chunk-53NMBJAO.js +35 -0
- package/dist/cli/chunk-A4OAMOZH.js +310 -0
- package/dist/cli/chunk-BIJO5OFS.js +71 -0
- package/dist/cli/chunk-BP7UKF36.js +376 -0
- package/dist/cli/chunk-CKS2FQWR.js +89 -0
- package/dist/cli/chunk-CO22XHNG.js +593 -0
- package/dist/cli/chunk-D6BUOHUG.js +634 -0
- package/dist/cli/chunk-DEQSEFFH.js +153 -0
- package/dist/cli/chunk-DUIUFVU2.js +241 -0
- package/dist/cli/chunk-EUIAC3U3.js +3006 -0
- package/dist/cli/chunk-FCDXJ6EK.js +2208 -0
- package/dist/cli/chunk-GFS6W3C5.js +308 -0
- package/dist/cli/chunk-HFHWYL55.js +139 -0
- package/dist/cli/chunk-HSNOBIVT.js +263 -0
- package/dist/cli/chunk-ICH7N5PN.js +26 -0
- package/dist/cli/chunk-J7VK7MNF.js +418 -0
- package/dist/cli/chunk-KH4MUBKF.js +1551 -0
- package/dist/cli/chunk-MIIEFUGG.js +623 -0
- package/dist/cli/chunk-OWWBYLHC.js +136 -0
- package/dist/cli/chunk-POMMNKZZ.js +672 -0
- package/dist/cli/chunk-QBBKRNGY.js +90 -0
- package/dist/cli/chunk-QQPA2ZHH.js +273 -0
- package/dist/cli/chunk-RLCAJBVR.js +224 -0
- package/dist/cli/chunk-RQZ4SX5R.js +1125 -0
- package/dist/cli/chunk-RUU3YWXU.js +99 -0
- package/dist/cli/chunk-RXDIMXN5.js +98 -0
- package/dist/cli/chunk-SGJAET2L.js +197 -0
- package/dist/cli/chunk-VVGSLVSF.js +151 -0
- package/dist/cli/chunk-WD62JIP6.js +118 -0
- package/dist/cli/chunk-WIH4TEIS.js +104 -0
- package/dist/cli/chunk-WKURDVYP.js +1119 -0
- package/dist/cli/chunk-WPUDGEFY.js +20 -0
- package/dist/cli/chunk-XWDSKG7N.js +1301 -0
- package/dist/cli/chunk-YAT5Y25U.js +121 -0
- package/dist/cli/chunk-ZL4JTG5Y.js +37 -0
- package/dist/cli/cliCommands-UQSFGEHF.js +3464 -0
- package/dist/cli/commands-Q23M26QM.js +1292 -0
- package/dist/cli/definitions-HF2FTHYU.js +32 -0
- package/dist/cli/doctorCommand-B7BPQMN6.js +76 -0
- package/dist/cli/edit-JHCPTQFT.js +28 -0
- package/dist/cli/editorCommands-UIF5YLUZ.js +33 -0
- package/dist/cli/encode-HAUZTYPT.js +17 -0
- package/dist/cli/frames-LSAUBZ7P.js +16 -0
- package/dist/cli/geo-4RNK3QVN.js +43 -0
- package/dist/cli/graphCommands-6BMAKGQE.js +184 -0
- package/dist/cli/graphFile-VPSQ6OF7.js +286 -0
- package/dist/cli/headlessCanvas-5XLDZT7M.js +15 -0
- package/dist/cli/helpCommand-3VRE3KVO.js +155 -0
- package/dist/cli/index.js +100 -30756
- package/dist/cli/math-ZRMLKU2D.js +16 -0
- package/dist/cli/mcp-ZLEEMYGF.js +164 -0
- package/dist/cli/parity-KINARCP2.js +328 -0
- package/dist/cli/pop-USDFXB7H.js +39 -0
- package/dist/cli/project-K3QIPGBB.js +12 -0
- package/dist/cli/projectDefinition-DMTZN2NE.js +27 -0
- package/dist/cli/projectRuntime-Z5PJOH47.js +48 -0
- package/dist/cli/registerStandardNodes-S2VLMF6J.js +3081 -0
- package/dist/cli/render-YURAPPUI.js +436 -0
- package/dist/cli/runCommand-PI764LEG.js +198 -0
- package/dist/cli/runner-RA6UKEPD.js +37 -0
- package/dist/cli/runtime/image.ts +277 -0
- package/dist/cli/runtime/stage.ts +4 -1
- package/dist/cli/stageRunner-KUNIMQKJ.js +10 -0
- package/dist/cli/verbs-RCE3K4VQ.js +19 -0
- package/dist/cli/webBuild-GT4PZT5D.js +37 -0
- package/dist/contracts/documentFormat.d.ts +18 -0
- package/dist/contracts/documentFormat.js +195 -0
- package/dist/contracts/editor.d.ts +17 -0
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/image.d.ts +141 -0
- package/dist/image.js +127 -0
- package/dist/index.html +1 -1
- package/dist/player/editor.js +32 -7
- package/dist/runtime/builtins/headless.d.ts +19 -0
- package/dist/runtime/builtins/headless.js +25 -0
- package/dist/runtime/builtins/image/contactSheet.d.ts +226 -0
- package/dist/runtime/builtins/image/contactSheet.js +199 -0
- package/dist/runtime/builtins/image/index.d.ts +9 -0
- package/dist/runtime/builtins/image/index.js +9 -0
- package/dist/stage.js +4 -1
- package/doc/CLI_REFERENCE.md +73 -2
- package/doc/EDITOR_EXPORT.md +11 -2
- package/doc/MCP.md +17 -1
- package/doc/NODE_REFERENCE.md +142 -0
- package/doc/PROJECT_AUTHORING.md +127 -7
- package/package.json +16 -4
- package/dist/assets/ExportDialog-C7dVGua4.js +0 -358
- package/dist/assets/ExportDialog-DOq0flMV.css +0 -1
- package/dist/assets/geo-C2RHOVHC.js +0 -1
- package/dist/assets/math-DaDEgWKt.js +0 -1
- package/dist/assets/pop-pLH7L0PW.js +0 -1
|
@@ -0,0 +1,2975 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadEmbeddedModule,
|
|
3
|
+
loadProjectModule
|
|
4
|
+
} from "./chunk-2KXB47FP.js";
|
|
5
|
+
import {
|
|
6
|
+
moduleNodeFunction
|
|
7
|
+
} from "./chunk-RLCAJBVR.js";
|
|
8
|
+
import "./chunk-53NMBJAO.js";
|
|
9
|
+
import "./chunk-SGJAET2L.js";
|
|
10
|
+
import {
|
|
11
|
+
Node,
|
|
12
|
+
canConnect,
|
|
13
|
+
getNodeClass,
|
|
14
|
+
getNodeDisplayName,
|
|
15
|
+
isColorValue,
|
|
16
|
+
isStandardLibraryNode,
|
|
17
|
+
normalizeColor,
|
|
18
|
+
normalizeType,
|
|
19
|
+
packagePathToType
|
|
20
|
+
} from "./chunk-FCDXJ6EK.js";
|
|
21
|
+
import "./chunk-RUU3YWXU.js";
|
|
22
|
+
import {
|
|
23
|
+
bundleSchemasCompatible,
|
|
24
|
+
unknownFieldMessage
|
|
25
|
+
} from "./chunk-GFS6W3C5.js";
|
|
26
|
+
import "./chunk-HFHWYL55.js";
|
|
27
|
+
import {
|
|
28
|
+
parseBundleFieldPath,
|
|
29
|
+
remoteBundlePath
|
|
30
|
+
} from "./chunk-CO22XHNG.js";
|
|
31
|
+
import "./chunk-A4OAMOZH.js";
|
|
32
|
+
import "./chunk-J7VK7MNF.js";
|
|
33
|
+
import "./chunk-BIJO5OFS.js";
|
|
34
|
+
|
|
35
|
+
// src/nodes/annotations/Annotation.ts
|
|
36
|
+
var Annotation = class extends Node {
|
|
37
|
+
constructor(id, type, graph) {
|
|
38
|
+
super(id, type, graph);
|
|
39
|
+
// Marker property for reliable type checking (survives HMR and serialization)
|
|
40
|
+
this.isAnnotation = true;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Annotations always have exactly one output at index 0
|
|
44
|
+
*/
|
|
45
|
+
getOutput() {
|
|
46
|
+
return this.getOutputPort(0);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Annotations don't execute - override to no-op
|
|
50
|
+
*/
|
|
51
|
+
async execute() {
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// src/nodes/annotations/Image.ts
|
|
56
|
+
var ImageAnnotation = class extends Annotation {
|
|
57
|
+
constructor(id, graph) {
|
|
58
|
+
super(id, "Image", graph);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// src/nodes/annotations/Text.ts
|
|
63
|
+
var TextAnnotation = class extends Annotation {
|
|
64
|
+
constructor(id, graph) {
|
|
65
|
+
super(id, "Text", graph);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// src/nodes/annotations/Group.ts
|
|
70
|
+
var GroupAnnotation = class extends Annotation {
|
|
71
|
+
constructor(id, graph) {
|
|
72
|
+
super(id, "Group", graph);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// src/nodes/annotations/Line.ts
|
|
77
|
+
var LineAnnotation = class extends Annotation {
|
|
78
|
+
constructor(id, graph) {
|
|
79
|
+
super(id, "Line", graph);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// src/nodes/annotations/Polyline.ts
|
|
84
|
+
var PolylineAnnotation = class extends Annotation {
|
|
85
|
+
constructor(id, graph) {
|
|
86
|
+
super(id, "Polyline", graph);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// src/engine/AssetManager.ts
|
|
91
|
+
var BrowserAssetLoader = class {
|
|
92
|
+
async loadImage(path) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
if (typeof Image === "undefined") {
|
|
95
|
+
reject(new Error("Image API not available (not in browser environment)"));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const img = new Image();
|
|
99
|
+
img.onload = () => resolve(img);
|
|
100
|
+
img.onerror = () => reject(new Error(`Failed to load image: ${path}`));
|
|
101
|
+
img.src = path;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async loadJSON(path) {
|
|
105
|
+
if (typeof fetch === "undefined") {
|
|
106
|
+
throw new Error("fetch API not available (not in browser environment)");
|
|
107
|
+
}
|
|
108
|
+
const response = await fetch(path);
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
throw new Error(`Failed to load JSON: ${path} - ${response.statusText}`);
|
|
111
|
+
}
|
|
112
|
+
return response.json();
|
|
113
|
+
}
|
|
114
|
+
async loadText(path) {
|
|
115
|
+
if (typeof fetch === "undefined") {
|
|
116
|
+
throw new Error("fetch API not available (not in browser environment)");
|
|
117
|
+
}
|
|
118
|
+
const response = await fetch(path);
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error(`Failed to load text: ${path} - ${response.statusText}`);
|
|
121
|
+
}
|
|
122
|
+
return response.text();
|
|
123
|
+
}
|
|
124
|
+
async loadBinary(path) {
|
|
125
|
+
if (typeof fetch === "undefined") {
|
|
126
|
+
throw new Error("fetch API not available (not in browser environment)");
|
|
127
|
+
}
|
|
128
|
+
const response = await fetch(path);
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
throw new Error(`Failed to load binary: ${path} - ${response.statusText}`);
|
|
131
|
+
}
|
|
132
|
+
return response.arrayBuffer();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
var AssetManager = class {
|
|
136
|
+
constructor(projectRoot = "", loader) {
|
|
137
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
138
|
+
this.projectRoot = projectRoot;
|
|
139
|
+
this.loader = loader ?? new BrowserAssetLoader();
|
|
140
|
+
}
|
|
141
|
+
async load(path) {
|
|
142
|
+
if (this.cache.has(path)) {
|
|
143
|
+
return this.cache.get(path);
|
|
144
|
+
}
|
|
145
|
+
const absolutePath = this.resolve(path);
|
|
146
|
+
const type = this.getAssetType(path);
|
|
147
|
+
let data;
|
|
148
|
+
switch (type) {
|
|
149
|
+
case "image":
|
|
150
|
+
data = await this.loader.loadImage(absolutePath);
|
|
151
|
+
break;
|
|
152
|
+
case "json":
|
|
153
|
+
data = await this.loader.loadJSON(absolutePath);
|
|
154
|
+
break;
|
|
155
|
+
case "text":
|
|
156
|
+
data = await this.loader.loadText(absolutePath);
|
|
157
|
+
break;
|
|
158
|
+
default:
|
|
159
|
+
throw new Error(`Unsupported asset type: ${type}`);
|
|
160
|
+
}
|
|
161
|
+
const asset = {
|
|
162
|
+
id: this.generateId(path),
|
|
163
|
+
path,
|
|
164
|
+
type,
|
|
165
|
+
data,
|
|
166
|
+
size: 0,
|
|
167
|
+
metadata: {}
|
|
168
|
+
};
|
|
169
|
+
this.cache.set(path, asset);
|
|
170
|
+
return asset;
|
|
171
|
+
}
|
|
172
|
+
resolve(path) {
|
|
173
|
+
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
174
|
+
return path;
|
|
175
|
+
}
|
|
176
|
+
if (path.startsWith("/")) {
|
|
177
|
+
return path;
|
|
178
|
+
}
|
|
179
|
+
return `${this.projectRoot}${path}`;
|
|
180
|
+
}
|
|
181
|
+
getAssetType(path) {
|
|
182
|
+
const ext = path.split(".").pop()?.toLowerCase();
|
|
183
|
+
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext || "")) return "image";
|
|
184
|
+
if (["mp3", "wav", "ogg", "m4a"].includes(ext || "")) return "audio";
|
|
185
|
+
if (["mp4", "webm", "mov"].includes(ext || "")) return "video";
|
|
186
|
+
if (ext === "json") return "json";
|
|
187
|
+
if (["txt", "md", "csv"].includes(ext || "")) return "text";
|
|
188
|
+
return "binary";
|
|
189
|
+
}
|
|
190
|
+
generateId(path) {
|
|
191
|
+
return path.replace(/[^a-zA-Z0-9]/g, "_");
|
|
192
|
+
}
|
|
193
|
+
getAsset(path) {
|
|
194
|
+
return this.cache.get(path);
|
|
195
|
+
}
|
|
196
|
+
clearCache() {
|
|
197
|
+
this.cache.clear();
|
|
198
|
+
}
|
|
199
|
+
removeAsset(path) {
|
|
200
|
+
this.cache.delete(path);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Reload an asset from its path (useful for hot reload)
|
|
204
|
+
*/
|
|
205
|
+
async reloadAsset(path) {
|
|
206
|
+
this.cache.delete(path);
|
|
207
|
+
return this.load(path);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Reload all cached assets (useful for hot reload)
|
|
211
|
+
*/
|
|
212
|
+
async reloadAll() {
|
|
213
|
+
const paths = Array.from(this.cache.keys());
|
|
214
|
+
this.cache.clear();
|
|
215
|
+
await Promise.all(paths.map((path) => this.load(path).catch((err) => {
|
|
216
|
+
console.warn(`Failed to reload asset ${path}:`, err);
|
|
217
|
+
})));
|
|
218
|
+
}
|
|
219
|
+
list() {
|
|
220
|
+
return Array.from(this.cache.values());
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// src/engine/PackageManager.ts
|
|
225
|
+
var PackageManager = class {
|
|
226
|
+
constructor() {
|
|
227
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
228
|
+
this.loadingPromises = /* @__PURE__ */ new Map();
|
|
229
|
+
this.cdnBaseUrl = "https://esm.sh";
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Load an NPM package from CDN
|
|
233
|
+
* @param packageName Package name (e.g., 'three', 'lodash', '@tensorflow/tfjs')
|
|
234
|
+
* @param version Optional version (defaults to 'latest')
|
|
235
|
+
* @returns The loaded module
|
|
236
|
+
*/
|
|
237
|
+
async load(packageName, version = "latest") {
|
|
238
|
+
const key = `${packageName}@${version}`;
|
|
239
|
+
if (this.cache.has(key)) {
|
|
240
|
+
return this.cache.get(key);
|
|
241
|
+
}
|
|
242
|
+
if (this.loadingPromises.has(key)) {
|
|
243
|
+
return this.loadingPromises.get(key);
|
|
244
|
+
}
|
|
245
|
+
const loadPromise = this.loadPackage(key, packageName, version);
|
|
246
|
+
this.loadingPromises.set(key, loadPromise);
|
|
247
|
+
try {
|
|
248
|
+
const module = await loadPromise;
|
|
249
|
+
this.cache.set(key, module);
|
|
250
|
+
return module;
|
|
251
|
+
} catch (error) {
|
|
252
|
+
this.loadingPromises.delete(key);
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async loadPackage(key, packageName, version) {
|
|
257
|
+
const versionPart = version === "latest" ? "" : `@${version}`;
|
|
258
|
+
const url = `${this.cdnBaseUrl}/${packageName}${versionPart}`;
|
|
259
|
+
try {
|
|
260
|
+
const module = await import(
|
|
261
|
+
/* @vite-ignore */
|
|
262
|
+
url
|
|
263
|
+
);
|
|
264
|
+
if (module.default && typeof module.default === "object") {
|
|
265
|
+
return module.default;
|
|
266
|
+
}
|
|
267
|
+
if (module.default !== void 0) {
|
|
268
|
+
return module.default;
|
|
269
|
+
}
|
|
270
|
+
return module;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Failed to load package ${key}: ${error.message || "Unknown error"}
|
|
274
|
+
URL: ${url}`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Search for packages in the NPM registry
|
|
280
|
+
* @param query Search query
|
|
281
|
+
* @param limit Maximum number of results (default: 20)
|
|
282
|
+
* @returns Array of package search results
|
|
283
|
+
*/
|
|
284
|
+
async search(query, limit = 20) {
|
|
285
|
+
try {
|
|
286
|
+
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=${limit}`;
|
|
287
|
+
const response = await fetch(url, {
|
|
288
|
+
method: "GET",
|
|
289
|
+
headers: {
|
|
290
|
+
"Accept": "application/json"
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
if (!response.ok) {
|
|
294
|
+
if (response.status === 0 || response.status === 403 || response.status === 404) {
|
|
295
|
+
console.warn("NPM registry search may be blocked by CORS. Using fallback.");
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`Search failed: ${response.statusText}`);
|
|
299
|
+
}
|
|
300
|
+
const data = await response.json();
|
|
301
|
+
return data.objects?.map((result) => ({
|
|
302
|
+
name: result.package.name,
|
|
303
|
+
version: result.package.version,
|
|
304
|
+
description: result.package.description,
|
|
305
|
+
keywords: result.package.keywords || [],
|
|
306
|
+
author: result.package.author,
|
|
307
|
+
homepage: result.package.links?.homepage,
|
|
308
|
+
repository: result.package.links?.repository ? { url: result.package.links.repository } : void 0
|
|
309
|
+
})) || [];
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (error.message?.includes("CORS") || error.message?.includes("Failed to fetch")) {
|
|
312
|
+
console.warn("NPM registry search unavailable:", error.message);
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`Failed to search packages: ${error.message || "Unknown error"}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Get package information from NPM registry
|
|
320
|
+
* @param packageName Package name
|
|
321
|
+
* @returns Package metadata
|
|
322
|
+
*/
|
|
323
|
+
async getPackageInfo(packageName) {
|
|
324
|
+
try {
|
|
325
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
|
|
326
|
+
const response = await fetch(url);
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
if (response.status === 404) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
throw new Error(`Failed to fetch package info: ${response.statusText}`);
|
|
332
|
+
}
|
|
333
|
+
const data = await response.json();
|
|
334
|
+
const latestVersion = data["dist-tags"]?.latest || Object.keys(data.versions).pop();
|
|
335
|
+
const versionData = data.versions[latestVersion];
|
|
336
|
+
return {
|
|
337
|
+
name: data.name,
|
|
338
|
+
version: latestVersion,
|
|
339
|
+
description: versionData.description,
|
|
340
|
+
keywords: versionData.keywords || [],
|
|
341
|
+
author: versionData.author,
|
|
342
|
+
homepage: data.homepage || versionData.homepage,
|
|
343
|
+
repository: versionData.repository
|
|
344
|
+
};
|
|
345
|
+
} catch (error) {
|
|
346
|
+
throw new Error(`Failed to get package info: ${error.message || "Unknown error"}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Clear the package cache
|
|
351
|
+
*/
|
|
352
|
+
clearCache() {
|
|
353
|
+
this.cache.clear();
|
|
354
|
+
this.loadingPromises.clear();
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Remove a specific package from cache
|
|
358
|
+
*/
|
|
359
|
+
removeFromCache(packageName, version) {
|
|
360
|
+
if (version) {
|
|
361
|
+
const key = `${packageName}@${version}`;
|
|
362
|
+
this.cache.delete(key);
|
|
363
|
+
this.loadingPromises.delete(key);
|
|
364
|
+
} else {
|
|
365
|
+
for (const key of this.cache.keys()) {
|
|
366
|
+
if (key.startsWith(`${packageName}@`)) {
|
|
367
|
+
this.cache.delete(key);
|
|
368
|
+
this.loadingPromises.delete(key);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Get list of cached packages
|
|
375
|
+
*/
|
|
376
|
+
getCachedPackages() {
|
|
377
|
+
return Array.from(this.cache.keys());
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// src/engine/ModuleResolver.ts
|
|
382
|
+
var ModuleResolver = class {
|
|
383
|
+
constructor(config) {
|
|
384
|
+
this.config = config;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Set file system functions for reading external files
|
|
388
|
+
* These are injected to allow different implementations (browser vs Node.js)
|
|
389
|
+
*/
|
|
390
|
+
setFileSystemFunctions(readFn, existsFn) {
|
|
391
|
+
this.fileReadFn = readFn;
|
|
392
|
+
this.fileExistsFn = existsFn;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Resolve a module path to its source code and metadata
|
|
396
|
+
*/
|
|
397
|
+
async resolve(modulePath) {
|
|
398
|
+
if (modulePath.startsWith("cascade.")) {
|
|
399
|
+
return this.resolveStdlib(modulePath);
|
|
400
|
+
}
|
|
401
|
+
if (modulePath.startsWith("local.")) {
|
|
402
|
+
return this.resolveEmbedded(modulePath);
|
|
403
|
+
}
|
|
404
|
+
for (const pkg of this.config.projectPackages) {
|
|
405
|
+
if (modulePath.startsWith(`${pkg.alias}.`)) {
|
|
406
|
+
return this.resolveProject(modulePath, pkg);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (this.config.embeddedModules.has(modulePath)) {
|
|
410
|
+
return this.resolveEmbedded(modulePath);
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Resolve a standard library module
|
|
416
|
+
* Class-based stdlib nodes don't have editable code
|
|
417
|
+
*/
|
|
418
|
+
resolveStdlib(modulePath) {
|
|
419
|
+
const NodeClass = getNodeClass(modulePath);
|
|
420
|
+
if (!NodeClass) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
const source = {
|
|
424
|
+
type: "stdlib",
|
|
425
|
+
module: modulePath
|
|
426
|
+
};
|
|
427
|
+
const parts = modulePath.split(".");
|
|
428
|
+
const displayPath = `<cascade>/${parts.slice(1).join("/")}.ts`;
|
|
429
|
+
return {
|
|
430
|
+
source,
|
|
431
|
+
code: "",
|
|
432
|
+
// Class-based nodes don't have editable code
|
|
433
|
+
displayPath,
|
|
434
|
+
editable: false
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Resolve an embedded module
|
|
439
|
+
*/
|
|
440
|
+
resolveEmbedded(modulePath) {
|
|
441
|
+
const embedded = this.config.embeddedModules.get(modulePath);
|
|
442
|
+
if (!embedded) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
const source = {
|
|
446
|
+
type: "embedded",
|
|
447
|
+
module: modulePath,
|
|
448
|
+
code: embedded.code,
|
|
449
|
+
history: embedded.history || []
|
|
450
|
+
};
|
|
451
|
+
return {
|
|
452
|
+
source,
|
|
453
|
+
code: embedded.code,
|
|
454
|
+
displayPath: "<embedded>",
|
|
455
|
+
editable: true
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Resolve a project module from an external file
|
|
460
|
+
*/
|
|
461
|
+
async resolveProject(modulePath, pkg) {
|
|
462
|
+
const relativeParts = modulePath.replace(`${pkg.alias}.`, "").split(".");
|
|
463
|
+
const filePath = `${pkg.path}/${relativeParts.join("/")}.ts`;
|
|
464
|
+
const external = this.config.externalModules.get(modulePath);
|
|
465
|
+
let code = null;
|
|
466
|
+
let status = "synced";
|
|
467
|
+
if (this.fileReadFn && this.fileExistsFn) {
|
|
468
|
+
const fullPath = this.resolvePath(filePath);
|
|
469
|
+
const exists = await this.fileExistsFn(fullPath);
|
|
470
|
+
if (exists) {
|
|
471
|
+
code = await this.fileReadFn(fullPath);
|
|
472
|
+
if (code !== null) {
|
|
473
|
+
if (external && external.cachedCode !== code) {
|
|
474
|
+
status = "modified-external";
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
status = "missing";
|
|
479
|
+
code = external?.cachedCode || null;
|
|
480
|
+
}
|
|
481
|
+
} else if (external) {
|
|
482
|
+
code = external.cachedCode;
|
|
483
|
+
}
|
|
484
|
+
if (code === null) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
const source = {
|
|
488
|
+
type: "project",
|
|
489
|
+
module: modulePath,
|
|
490
|
+
file: filePath,
|
|
491
|
+
cachedCode: code,
|
|
492
|
+
lastSync: (/* @__PURE__ */ new Date()).toISOString(),
|
|
493
|
+
status
|
|
494
|
+
};
|
|
495
|
+
return {
|
|
496
|
+
source,
|
|
497
|
+
code,
|
|
498
|
+
displayPath: filePath,
|
|
499
|
+
editable: true
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Resolve a relative path to an absolute path
|
|
504
|
+
*/
|
|
505
|
+
resolvePath(relativePath) {
|
|
506
|
+
if (!this.config.basePath) {
|
|
507
|
+
return relativePath;
|
|
508
|
+
}
|
|
509
|
+
if (relativePath.startsWith("./")) {
|
|
510
|
+
return `${this.config.basePath}/${relativePath.slice(2)}`;
|
|
511
|
+
}
|
|
512
|
+
if (relativePath.startsWith("/")) {
|
|
513
|
+
return relativePath;
|
|
514
|
+
}
|
|
515
|
+
return `${this.config.basePath}/${relativePath}`;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Determine the source type for a given module path
|
|
519
|
+
*/
|
|
520
|
+
getSourceType(modulePath) {
|
|
521
|
+
if (modulePath.startsWith("cascade.")) {
|
|
522
|
+
return "stdlib";
|
|
523
|
+
}
|
|
524
|
+
if (modulePath.startsWith("local.")) {
|
|
525
|
+
return "embedded";
|
|
526
|
+
}
|
|
527
|
+
for (const pkg of this.config.projectPackages) {
|
|
528
|
+
if (modulePath.startsWith(`${pkg.alias}.`)) {
|
|
529
|
+
return "project";
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (this.config.embeddedModules.has(modulePath)) {
|
|
533
|
+
return "embedded";
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Create a new embedded module with the given code
|
|
539
|
+
* Use this when creating custom nodes from scratch
|
|
540
|
+
*/
|
|
541
|
+
createEmbeddedModule(modulePath, code, author = "user") {
|
|
542
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
543
|
+
const initialVersion = {
|
|
544
|
+
code,
|
|
545
|
+
timestamp: now,
|
|
546
|
+
author
|
|
547
|
+
};
|
|
548
|
+
const module = {
|
|
549
|
+
code,
|
|
550
|
+
history: [initialVersion],
|
|
551
|
+
created: now,
|
|
552
|
+
modified: now
|
|
553
|
+
};
|
|
554
|
+
this.config.embeddedModules.set(modulePath, module);
|
|
555
|
+
return { modulePath, module };
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Create a new embedded module from stdlib (duplicate)
|
|
559
|
+
*/
|
|
560
|
+
duplicateToEmbedded(stdlibPath, newName) {
|
|
561
|
+
const resolution = this.resolveStdlib(stdlibPath);
|
|
562
|
+
if (!resolution) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
const typeName = stdlibPath.split(".").pop() || "Custom";
|
|
566
|
+
const baseName = newName || typeName;
|
|
567
|
+
let modulePath = `local.${baseName}`;
|
|
568
|
+
let counter = 1;
|
|
569
|
+
while (this.config.embeddedModules.has(modulePath)) {
|
|
570
|
+
modulePath = `local.${baseName}${counter}`;
|
|
571
|
+
counter++;
|
|
572
|
+
}
|
|
573
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
574
|
+
const initialVersion = {
|
|
575
|
+
code: resolution.code,
|
|
576
|
+
timestamp: now,
|
|
577
|
+
author: "user"
|
|
578
|
+
};
|
|
579
|
+
const module = {
|
|
580
|
+
code: resolution.code,
|
|
581
|
+
history: [initialVersion],
|
|
582
|
+
created: now,
|
|
583
|
+
modified: now
|
|
584
|
+
};
|
|
585
|
+
this.config.embeddedModules.set(modulePath, module);
|
|
586
|
+
return { modulePath, module };
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Create an external file from an embedded module (extract)
|
|
590
|
+
*/
|
|
591
|
+
async extractToProject(embeddedPath, pkg, fileName) {
|
|
592
|
+
const embedded = this.config.embeddedModules.get(embeddedPath);
|
|
593
|
+
if (!embedded) {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
const filePath = `${pkg.path}/${fileName}.ts`;
|
|
597
|
+
const modulePath = `${pkg.alias}.${fileName.replace(/\//g, ".")}`;
|
|
598
|
+
const external = {
|
|
599
|
+
file: filePath,
|
|
600
|
+
cachedCode: embedded.code,
|
|
601
|
+
lastSync: (/* @__PURE__ */ new Date()).toISOString()
|
|
602
|
+
};
|
|
603
|
+
this.config.externalModules.set(modulePath, external);
|
|
604
|
+
return { modulePath, filePath };
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Update embedded module code and record in history
|
|
608
|
+
*/
|
|
609
|
+
updateEmbeddedCode(modulePath, newCode, author = "user", prompt) {
|
|
610
|
+
const embedded = this.config.embeddedModules.get(modulePath);
|
|
611
|
+
if (!embedded) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
615
|
+
const version = {
|
|
616
|
+
code: newCode,
|
|
617
|
+
timestamp: now,
|
|
618
|
+
author,
|
|
619
|
+
prompt
|
|
620
|
+
};
|
|
621
|
+
embedded.history.push(version);
|
|
622
|
+
embedded.code = newCode;
|
|
623
|
+
embedded.modified = now;
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Restore embedded module to a previous version
|
|
628
|
+
*/
|
|
629
|
+
restoreVersion(modulePath, versionIndex) {
|
|
630
|
+
const embedded = this.config.embeddedModules.get(modulePath);
|
|
631
|
+
if (!embedded || versionIndex < 0 || versionIndex >= embedded.history.length) {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
const version = embedded.history[versionIndex];
|
|
635
|
+
embedded.code = version.code;
|
|
636
|
+
embedded.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
637
|
+
embedded.history.push({
|
|
638
|
+
code: version.code,
|
|
639
|
+
timestamp: embedded.modified,
|
|
640
|
+
author: "user"
|
|
641
|
+
});
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Get all registered project packages
|
|
646
|
+
*/
|
|
647
|
+
getProjectPackages() {
|
|
648
|
+
return [...this.config.projectPackages];
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Add a project package
|
|
652
|
+
*/
|
|
653
|
+
addProjectPackage(pkg) {
|
|
654
|
+
const existing = this.config.projectPackages.find((p) => p.alias === pkg.alias);
|
|
655
|
+
if (existing) {
|
|
656
|
+
existing.path = pkg.path;
|
|
657
|
+
} else {
|
|
658
|
+
this.config.projectPackages.push(pkg);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Remove a project package
|
|
663
|
+
*/
|
|
664
|
+
removeProjectPackage(alias) {
|
|
665
|
+
const index = this.config.projectPackages.findIndex((p) => p.alias === alias);
|
|
666
|
+
if (index >= 0) {
|
|
667
|
+
this.config.projectPackages.splice(index, 1);
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Sync an external module with its file
|
|
674
|
+
*/
|
|
675
|
+
async syncExternalModule(modulePath) {
|
|
676
|
+
const external = this.config.externalModules.get(modulePath);
|
|
677
|
+
if (!external || !this.fileReadFn || !this.fileExistsFn) {
|
|
678
|
+
return "missing";
|
|
679
|
+
}
|
|
680
|
+
const fullPath = this.resolvePath(external.file);
|
|
681
|
+
const exists = await this.fileExistsFn(fullPath);
|
|
682
|
+
if (!exists) {
|
|
683
|
+
return "missing";
|
|
684
|
+
}
|
|
685
|
+
const code = await this.fileReadFn(fullPath);
|
|
686
|
+
if (code === null) {
|
|
687
|
+
return "missing";
|
|
688
|
+
}
|
|
689
|
+
external.cachedCode = code;
|
|
690
|
+
external.lastSync = (/* @__PURE__ */ new Date()).toISOString();
|
|
691
|
+
return "synced";
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Export configuration for serialization
|
|
695
|
+
*/
|
|
696
|
+
exportConfig() {
|
|
697
|
+
return {
|
|
698
|
+
projectPackages: [...this.config.projectPackages],
|
|
699
|
+
embeddedModules: Object.fromEntries(this.config.embeddedModules),
|
|
700
|
+
externalModules: Object.fromEntries(this.config.externalModules)
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Import configuration from deserialization
|
|
705
|
+
*/
|
|
706
|
+
importConfig(config) {
|
|
707
|
+
if (config.projectPackages) {
|
|
708
|
+
this.config.projectPackages = [...config.projectPackages];
|
|
709
|
+
}
|
|
710
|
+
if (config.embeddedModules) {
|
|
711
|
+
this.config.embeddedModules = new Map(Object.entries(config.embeddedModules));
|
|
712
|
+
}
|
|
713
|
+
if (config.externalModules) {
|
|
714
|
+
this.config.externalModules = new Map(Object.entries(config.externalModules));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
function createModuleResolver(basePath) {
|
|
719
|
+
return new ModuleResolver({
|
|
720
|
+
projectPackages: [],
|
|
721
|
+
embeddedModules: /* @__PURE__ */ new Map(),
|
|
722
|
+
externalModules: /* @__PURE__ */ new Map(),
|
|
723
|
+
basePath
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/engine/GraphValidator.ts
|
|
728
|
+
var GraphValidator = class {
|
|
729
|
+
/**
|
|
730
|
+
* Detect cycles in the graph using DFS
|
|
731
|
+
* Returns array of node IDs involved in cycles
|
|
732
|
+
*/
|
|
733
|
+
static detectCycles(graph) {
|
|
734
|
+
const cycles = [];
|
|
735
|
+
const visited = /* @__PURE__ */ new Set();
|
|
736
|
+
const recStack = /* @__PURE__ */ new Set();
|
|
737
|
+
const path = [];
|
|
738
|
+
const dfs = (nodeId) => {
|
|
739
|
+
if (recStack.has(nodeId)) {
|
|
740
|
+
const cycleStart = path.indexOf(nodeId);
|
|
741
|
+
if (cycleStart !== -1) {
|
|
742
|
+
cycles.push([...path.slice(cycleStart), nodeId]);
|
|
743
|
+
}
|
|
744
|
+
return true;
|
|
745
|
+
}
|
|
746
|
+
if (visited.has(nodeId)) {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
visited.add(nodeId);
|
|
750
|
+
recStack.add(nodeId);
|
|
751
|
+
path.push(nodeId);
|
|
752
|
+
const node = graph.getNode(nodeId);
|
|
753
|
+
if (node) {
|
|
754
|
+
for (const output of node.outputs) {
|
|
755
|
+
for (const conn of output.connections) {
|
|
756
|
+
dfs(conn.to.nodeId);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
recStack.delete(nodeId);
|
|
761
|
+
path.pop();
|
|
762
|
+
return false;
|
|
763
|
+
};
|
|
764
|
+
const nodes = graph.nodes;
|
|
765
|
+
for (const node of nodes) {
|
|
766
|
+
if (!visited.has(node.id)) {
|
|
767
|
+
dfs(node.id);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return cycles;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Check if adding a connection would create a cycle
|
|
774
|
+
*/
|
|
775
|
+
static wouldCreateCycle(graph, fromNodeId, toNodeId) {
|
|
776
|
+
if (fromNodeId === toNodeId) {
|
|
777
|
+
return true;
|
|
778
|
+
}
|
|
779
|
+
const visited = /* @__PURE__ */ new Set();
|
|
780
|
+
const hasPath = (startId, targetId) => {
|
|
781
|
+
if (startId === targetId) return true;
|
|
782
|
+
if (visited.has(startId)) return false;
|
|
783
|
+
visited.add(startId);
|
|
784
|
+
const node = graph.getNode(startId);
|
|
785
|
+
if (!node) return false;
|
|
786
|
+
for (const output of node.outputs) {
|
|
787
|
+
for (const conn of output.connections) {
|
|
788
|
+
if (hasPath(conn.to.nodeId, targetId)) {
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return false;
|
|
794
|
+
};
|
|
795
|
+
return hasPath(toNodeId, fromNodeId);
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Perform topological sort of nodes
|
|
799
|
+
* Returns sorted node IDs, or throws if cycles exist
|
|
800
|
+
*/
|
|
801
|
+
static topologicalSort(graph) {
|
|
802
|
+
const cycles = this.detectCycles(graph);
|
|
803
|
+
if (cycles.length > 0) {
|
|
804
|
+
throw new Error(
|
|
805
|
+
`Graph contains cycles: ${cycles.map((c) => c.join(" -> ")).join(", ")}`
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
const sorted = [];
|
|
809
|
+
const visited = /* @__PURE__ */ new Set();
|
|
810
|
+
const tempMark = /* @__PURE__ */ new Set();
|
|
811
|
+
const visit = (nodeId) => {
|
|
812
|
+
if (tempMark.has(nodeId)) {
|
|
813
|
+
throw new Error(`Cycle detected involving node ${nodeId}`);
|
|
814
|
+
}
|
|
815
|
+
if (visited.has(nodeId)) {
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
tempMark.add(nodeId);
|
|
819
|
+
const node = graph.getNode(nodeId);
|
|
820
|
+
if (node) {
|
|
821
|
+
for (const input of node.inputs) {
|
|
822
|
+
for (const conn of input.connections) {
|
|
823
|
+
visit(conn.from.nodeId);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
tempMark.delete(nodeId);
|
|
828
|
+
visited.add(nodeId);
|
|
829
|
+
sorted.push(nodeId);
|
|
830
|
+
};
|
|
831
|
+
const nodes = graph.nodes;
|
|
832
|
+
for (const node of nodes) {
|
|
833
|
+
if (!visited.has(node.id)) {
|
|
834
|
+
visit(node.id);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return sorted;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Validate a connection before it's added
|
|
841
|
+
*/
|
|
842
|
+
static validateConnection(graph, fromNodeId, toNodeId, fromPortId, toPortId) {
|
|
843
|
+
const fromNode = graph.getNode(fromNodeId);
|
|
844
|
+
const toNode = graph.getNode(toNodeId);
|
|
845
|
+
if (!fromNode || !toNode) {
|
|
846
|
+
return {
|
|
847
|
+
type: "invalid_connection",
|
|
848
|
+
message: `Invalid connection: node not found`,
|
|
849
|
+
nodeIds: [fromNodeId, toNodeId]
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
const fromPort = fromNode.outputs.find((p) => p.id === fromPortId);
|
|
853
|
+
const toPort = toNode.inputs.find((p) => p.id === toPortId);
|
|
854
|
+
if (!fromPort || !toPort) {
|
|
855
|
+
return {
|
|
856
|
+
type: "invalid_connection",
|
|
857
|
+
message: `Invalid connection: port not found`,
|
|
858
|
+
nodeIds: [fromNodeId, toNodeId]
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (fromPort.portType !== toPort.portType) {
|
|
862
|
+
return {
|
|
863
|
+
type: "invalid_connection",
|
|
864
|
+
message: `Port type mismatch: cannot connect ${fromPort.portType} to ${toPort.portType}`,
|
|
865
|
+
nodeIds: [fromNodeId, toNodeId]
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
if (this.wouldCreateCycle(graph, fromNodeId, toNodeId)) {
|
|
869
|
+
return {
|
|
870
|
+
type: "cycle",
|
|
871
|
+
message: `Connection would create a cycle: ${fromNodeId} -> ${toNodeId}`,
|
|
872
|
+
nodeIds: [fromNodeId, toNodeId]
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Validate the entire graph
|
|
879
|
+
*/
|
|
880
|
+
static validateGraph(graph) {
|
|
881
|
+
const errors = [];
|
|
882
|
+
const warnings = [];
|
|
883
|
+
const cycles = this.detectCycles(graph);
|
|
884
|
+
if (cycles.length > 0) {
|
|
885
|
+
cycles.forEach((cycle) => {
|
|
886
|
+
errors.push({
|
|
887
|
+
type: "cycle",
|
|
888
|
+
message: `Cycle detected: ${cycle.join(" -> ")}`,
|
|
889
|
+
nodeIds: cycle
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
for (const conn of graph.connections) {
|
|
894
|
+
const fromNode = graph.getNode(conn.from.nodeId);
|
|
895
|
+
const toNode = graph.getNode(conn.to.nodeId);
|
|
896
|
+
if (!fromNode || !toNode) {
|
|
897
|
+
errors.push({
|
|
898
|
+
type: "invalid_connection",
|
|
899
|
+
message: `Connection references non-existent node`,
|
|
900
|
+
connectionId: conn.id,
|
|
901
|
+
nodeIds: [conn.from.nodeId, conn.to.nodeId]
|
|
902
|
+
});
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
const fromPort = fromNode.outputs.find((p) => p.id === conn.from.portId);
|
|
906
|
+
const toPort = toNode.inputs.find((p) => p.id === conn.to.portId);
|
|
907
|
+
if (!fromPort || !toPort) {
|
|
908
|
+
errors.push({
|
|
909
|
+
type: "invalid_connection",
|
|
910
|
+
message: `Connection references non-existent port`,
|
|
911
|
+
connectionId: conn.id,
|
|
912
|
+
nodeIds: [conn.from.nodeId, conn.to.nodeId]
|
|
913
|
+
});
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
if (fromPort.portType !== toPort.portType) {
|
|
917
|
+
errors.push({
|
|
918
|
+
type: "invalid_connection",
|
|
919
|
+
message: `Port type mismatch in connection`,
|
|
920
|
+
connectionId: conn.id,
|
|
921
|
+
nodeIds: [conn.from.nodeId, conn.to.nodeId]
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
const connectedNodes = /* @__PURE__ */ new Set();
|
|
926
|
+
for (const conn of graph.connections) {
|
|
927
|
+
connectedNodes.add(conn.from.nodeId);
|
|
928
|
+
connectedNodes.add(conn.to.nodeId);
|
|
929
|
+
}
|
|
930
|
+
const nodes = graph.nodes;
|
|
931
|
+
const orphanedNodes = nodes.filter((n) => {
|
|
932
|
+
const hasNoConnections = !connectedNodes.has(n.id);
|
|
933
|
+
const isEntryPoint = n.inputs.every((p) => p.connections.length === 0);
|
|
934
|
+
return hasNoConnections && !isEntryPoint;
|
|
935
|
+
});
|
|
936
|
+
if (orphanedNodes.length > 0) {
|
|
937
|
+
warnings.push({
|
|
938
|
+
type: "orphaned_node",
|
|
939
|
+
message: `Found ${orphanedNodes.length} orphaned node(s) with no connections`,
|
|
940
|
+
nodeIds: orphanedNodes.map((n) => n.id)
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
const entryPoints = nodes.filter(
|
|
944
|
+
(node) => node.inputs.every((p) => p.connections.length === 0)
|
|
945
|
+
);
|
|
946
|
+
if (entryPoints.length === 0 && nodes.length > 0) {
|
|
947
|
+
warnings.push({
|
|
948
|
+
type: "invalid_entry_point",
|
|
949
|
+
message: "No entry points found (all nodes have input connections)"
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
return {
|
|
953
|
+
valid: errors.length === 0,
|
|
954
|
+
errors,
|
|
955
|
+
warnings
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
// src/nodes/CookScheduler.ts
|
|
961
|
+
var COOK_SIGNAL_KEY = "__cascadeCookSignal";
|
|
962
|
+
var CookScheduler = class {
|
|
963
|
+
constructor(graph, debounceMs = 50) {
|
|
964
|
+
this.graph = graph;
|
|
965
|
+
this.debounceMs = debounceMs;
|
|
966
|
+
this.timer = null;
|
|
967
|
+
this.activeRun = null;
|
|
968
|
+
this.rerunRequested = false;
|
|
969
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
970
|
+
this.idleResolvers = /* @__PURE__ */ new Set();
|
|
971
|
+
this.backgroundAbort = null;
|
|
972
|
+
/** Set when something is invalidated while a run is active: the pass in
|
|
973
|
+
* progress stops after the node it is on, so the edit is cooked next rather
|
|
974
|
+
* than after everything that happened to be queued before it. */
|
|
975
|
+
this.invalidatedDuringRun = false;
|
|
976
|
+
/** Nodes on display, by id, counted because two Viewers can show one node. */
|
|
977
|
+
this.watched = /* @__PURE__ */ new Map();
|
|
978
|
+
this._status = {
|
|
979
|
+
phase: "idle",
|
|
980
|
+
pass: 0,
|
|
981
|
+
superseded: 0,
|
|
982
|
+
total: 0,
|
|
983
|
+
completed: 0,
|
|
984
|
+
currentNode: null,
|
|
985
|
+
startedAt: null,
|
|
986
|
+
elapsed: 0
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
get status() {
|
|
990
|
+
return this._status;
|
|
991
|
+
}
|
|
992
|
+
get isRunning() {
|
|
993
|
+
return this.activeRun !== null;
|
|
994
|
+
}
|
|
995
|
+
subscribe(listener) {
|
|
996
|
+
this.listeners.add(listener);
|
|
997
|
+
listener(this._status);
|
|
998
|
+
return () => this.listeners.delete(listener);
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Declare that a node is on display, so it and everything it depends on cook
|
|
1002
|
+
* before anything else in a pass. Returns the release.
|
|
1003
|
+
*
|
|
1004
|
+
* Viewer-first order: a pass used to run strictly in topological order, so a
|
|
1005
|
+
* sink nobody was looking at (an export, a plot plan) could sit in front of
|
|
1006
|
+
* the node on screen. With a node watched, its upstream closure goes first
|
|
1007
|
+
* and the unwatched remainder runs after it in the same pass, still in
|
|
1008
|
+
* topological order. The closure is upstream-closed, so the order stays
|
|
1009
|
+
* valid. With nothing watched — the CLI, a test, cook-all — nothing changes.
|
|
1010
|
+
*/
|
|
1011
|
+
watch(node) {
|
|
1012
|
+
const id = node.id;
|
|
1013
|
+
this.watched.set(id, (this.watched.get(id) ?? 0) + 1);
|
|
1014
|
+
let released = false;
|
|
1015
|
+
return () => {
|
|
1016
|
+
if (released) return;
|
|
1017
|
+
released = true;
|
|
1018
|
+
const count = (this.watched.get(id) ?? 1) - 1;
|
|
1019
|
+
if (count <= 0) this.watched.delete(id);
|
|
1020
|
+
else this.watched.set(id, count);
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
/** The ids of nodes currently on display. */
|
|
1024
|
+
get watchedNodeIds() {
|
|
1025
|
+
return new Set(this.watched.keys());
|
|
1026
|
+
}
|
|
1027
|
+
/** @internal Called by Node whenever its sole cook state changes. */
|
|
1028
|
+
notifyNodeStateChange() {
|
|
1029
|
+
this.emit();
|
|
1030
|
+
}
|
|
1031
|
+
markStale(node, origin = "self") {
|
|
1032
|
+
if (origin === "self") node.markSelfDirty();
|
|
1033
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1034
|
+
const visit = (candidate) => {
|
|
1035
|
+
if (visited.has(candidate.id)) return;
|
|
1036
|
+
visited.add(candidate.id);
|
|
1037
|
+
if (candidate.cookState !== "stale") candidate.invalidateCook();
|
|
1038
|
+
for (const output of candidate.outputs) {
|
|
1039
|
+
for (const connection of output.connections) {
|
|
1040
|
+
const downstream = this.graph.getNode(connection.to.nodeId);
|
|
1041
|
+
if (downstream) visit(downstream);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
visit(node);
|
|
1046
|
+
if (this.activeRun) this.invalidatedDuringRun = true;
|
|
1047
|
+
if (this.graph.getNode(node.id) === node) this.schedule();
|
|
1048
|
+
}
|
|
1049
|
+
markDownstreamStale(node) {
|
|
1050
|
+
for (const output of node.outputs) {
|
|
1051
|
+
for (const connection of output.connections) {
|
|
1052
|
+
const downstream = this.graph.getNode(connection.to.nodeId);
|
|
1053
|
+
if (downstream) this.markStale(downstream, "upstream");
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
schedule() {
|
|
1058
|
+
this.preemptBackground("interactive work arrived");
|
|
1059
|
+
if (this.activeRun) {
|
|
1060
|
+
this.rerunRequested = true;
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (this.timer) clearTimeout(this.timer);
|
|
1064
|
+
const total = this.graph.nodes.filter((node) => node.cookState === "stale" || node.cookState === "queued").length;
|
|
1065
|
+
this.setStatus({ phase: "scheduled", total, completed: 0, currentNode: null, pass: 0, superseded: 0, startedAt: null, elapsed: 0 });
|
|
1066
|
+
this.timer = setTimeout(() => {
|
|
1067
|
+
this.timer = null;
|
|
1068
|
+
void this.flushScheduled();
|
|
1069
|
+
}, this.debounceMs);
|
|
1070
|
+
}
|
|
1071
|
+
async flush(entryNode) {
|
|
1072
|
+
await this.flushInternal(entryNode, true);
|
|
1073
|
+
}
|
|
1074
|
+
async flushScheduled() {
|
|
1075
|
+
await this.flushInternal(void 0, false);
|
|
1076
|
+
}
|
|
1077
|
+
async flushInternal(entryNode, retryErrors) {
|
|
1078
|
+
this.preemptBackground("interactive work arrived");
|
|
1079
|
+
if (this.timer) {
|
|
1080
|
+
clearTimeout(this.timer);
|
|
1081
|
+
this.timer = null;
|
|
1082
|
+
}
|
|
1083
|
+
if (retryErrors) this.prepareErrorRetry(entryNode);
|
|
1084
|
+
if (this.activeRun) {
|
|
1085
|
+
this.rerunRequested = true;
|
|
1086
|
+
await this.activeRun;
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
this.activeRun = this.run(entryNode).finally(() => {
|
|
1090
|
+
this.activeRun = null;
|
|
1091
|
+
});
|
|
1092
|
+
await this.activeRun;
|
|
1093
|
+
}
|
|
1094
|
+
/** Explicit execution retries failed nodes once; debounced invalidation does not. */
|
|
1095
|
+
prepareErrorRetry(entryNode) {
|
|
1096
|
+
const allowed = entryNode ? this.collectDownstream(entryNode) : null;
|
|
1097
|
+
for (const node of this.graph.nodes) {
|
|
1098
|
+
if (node.cookState === "error" && (!allowed || allowed.has(node.id))) {
|
|
1099
|
+
node.invalidateCook();
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
async whenIdle() {
|
|
1104
|
+
if (!this.activeRun && !this.timer) return;
|
|
1105
|
+
await new Promise((resolve) => this.idleResolvers.add(resolve));
|
|
1106
|
+
}
|
|
1107
|
+
dispose() {
|
|
1108
|
+
this.preemptBackground("the graph went away");
|
|
1109
|
+
if (this.timer) clearTimeout(this.timer);
|
|
1110
|
+
this.timer = null;
|
|
1111
|
+
this.listeners.clear();
|
|
1112
|
+
for (const resolve of this.idleResolvers) resolve();
|
|
1113
|
+
this.idleResolvers.clear();
|
|
1114
|
+
}
|
|
1115
|
+
async run(entryNode) {
|
|
1116
|
+
let restrictTo = entryNode ? this.collectDownstream(entryNode) : null;
|
|
1117
|
+
let pass = 0;
|
|
1118
|
+
let supersededCount = 0;
|
|
1119
|
+
let cutOffCount = 0;
|
|
1120
|
+
do {
|
|
1121
|
+
this.rerunRequested = false;
|
|
1122
|
+
this.invalidatedDuringRun = false;
|
|
1123
|
+
const stale = this.graph.nodes.filter(
|
|
1124
|
+
(node) => (node.cookState === "stale" || node.cookState === "queued") && (!restrictTo || restrictTo.has(node.id))
|
|
1125
|
+
);
|
|
1126
|
+
restrictTo = null;
|
|
1127
|
+
if (stale.length === 0) break;
|
|
1128
|
+
const staleIds = new Set(stale.map((node) => node.id));
|
|
1129
|
+
const ordered = this.graph.getTopologicalOrder().map((id) => this.graph.getNode(id)).filter((node) => !!node && staleIds.has(node.id));
|
|
1130
|
+
const orderedIds = new Set(ordered.map((node) => node.id));
|
|
1131
|
+
for (const node of stale) if (!orderedIds.has(node.id)) ordered.push(node);
|
|
1132
|
+
this.viewerFirst(ordered);
|
|
1133
|
+
for (const node of ordered) node.setCookState("queued");
|
|
1134
|
+
const startedAt = performance.now();
|
|
1135
|
+
pass += 1;
|
|
1136
|
+
this.setStatus({
|
|
1137
|
+
phase: "cooking",
|
|
1138
|
+
total: ordered.length,
|
|
1139
|
+
completed: 0,
|
|
1140
|
+
currentNode: null,
|
|
1141
|
+
pass,
|
|
1142
|
+
superseded: supersededCount,
|
|
1143
|
+
startedAt,
|
|
1144
|
+
elapsed: 0
|
|
1145
|
+
});
|
|
1146
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
1147
|
+
const notReady = this.graph.pendingConnectionTargets();
|
|
1148
|
+
let superseded = false;
|
|
1149
|
+
for (const node of ordered) {
|
|
1150
|
+
if (notReady.has(node.id)) {
|
|
1151
|
+
node.setCookState("stale");
|
|
1152
|
+
blocked.add(node.id);
|
|
1153
|
+
this.setStatus({
|
|
1154
|
+
...this._status,
|
|
1155
|
+
completed: this._status.completed + 1,
|
|
1156
|
+
currentNode: null,
|
|
1157
|
+
elapsed: performance.now() - startedAt
|
|
1158
|
+
});
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
const upstreamBlocked = node.inputs.some((input) => input.connections.some(
|
|
1162
|
+
(connection) => staleIds.has(connection.from.nodeId) && (blocked.has(connection.from.nodeId) || this.graph.getNode(connection.from.nodeId)?.cookState === "error")
|
|
1163
|
+
));
|
|
1164
|
+
if (upstreamBlocked) {
|
|
1165
|
+
node.setCookState("stale");
|
|
1166
|
+
blocked.add(node.id);
|
|
1167
|
+
this.setStatus({
|
|
1168
|
+
...this._status,
|
|
1169
|
+
completed: this._status.completed + 1,
|
|
1170
|
+
currentNode: null,
|
|
1171
|
+
elapsed: performance.now() - startedAt
|
|
1172
|
+
});
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (node.canSkipCook()) {
|
|
1176
|
+
node.skipCook();
|
|
1177
|
+
cutOffCount += 1;
|
|
1178
|
+
this.setStatus({
|
|
1179
|
+
...this._status,
|
|
1180
|
+
completed: this._status.completed + 1,
|
|
1181
|
+
currentNode: null,
|
|
1182
|
+
cutOff: cutOffCount,
|
|
1183
|
+
elapsed: performance.now() - startedAt
|
|
1184
|
+
});
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
node.setCookState("cooking");
|
|
1188
|
+
this.setStatus({ ...this._status, currentNode: node, elapsed: performance.now() - startedAt });
|
|
1189
|
+
await this.executeInteractive(node);
|
|
1190
|
+
if (node.cookState === "error") blocked.add(node.id);
|
|
1191
|
+
if (node.cookState === "stale") {
|
|
1192
|
+
blocked.add(node.id);
|
|
1193
|
+
superseded = true;
|
|
1194
|
+
supersededCount += 1;
|
|
1195
|
+
}
|
|
1196
|
+
this.setStatus({
|
|
1197
|
+
...this._status,
|
|
1198
|
+
completed: this._status.completed + 1,
|
|
1199
|
+
currentNode: null,
|
|
1200
|
+
superseded: supersededCount,
|
|
1201
|
+
elapsed: performance.now() - startedAt
|
|
1202
|
+
});
|
|
1203
|
+
if (this.invalidatedDuringRun) {
|
|
1204
|
+
this.rerunRequested = true;
|
|
1205
|
+
break;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
if (superseded) this.rerunRequested = true;
|
|
1209
|
+
} while (this.rerunRequested);
|
|
1210
|
+
this.setStatus({ phase: "idle", total: 0, completed: 0, currentNode: null, pass: 0, superseded: 0, startedAt: null, elapsed: 0 });
|
|
1211
|
+
for (const resolve of this.idleResolvers) resolve();
|
|
1212
|
+
this.idleResolvers.clear();
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Cook one node with its signal published as the ambient cook signal, so a
|
|
1216
|
+
* stage it starts can be withdrawn when the cook is superseded even if the
|
|
1217
|
+
* node never passes `context.signal` along. Safe as a single global because
|
|
1218
|
+
* this lane runs one node at a time; restored rather than cleared in case a
|
|
1219
|
+
* host nests a run.
|
|
1220
|
+
*/
|
|
1221
|
+
async executeInteractive(node) {
|
|
1222
|
+
const globals = globalThis;
|
|
1223
|
+
const previous = globals[COOK_SIGNAL_KEY];
|
|
1224
|
+
const read = () => node.inFlightCookSignal;
|
|
1225
|
+
globals[COOK_SIGNAL_KEY] = read;
|
|
1226
|
+
try {
|
|
1227
|
+
await node.execute();
|
|
1228
|
+
} finally {
|
|
1229
|
+
if (globals[COOK_SIGNAL_KEY] === read) globals[COOK_SIGNAL_KEY] = previous;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
// ============ The background lane ============
|
|
1233
|
+
/**
|
|
1234
|
+
* Work that must never delay the thing being looked at.
|
|
1235
|
+
*
|
|
1236
|
+
* Marcus's rule for an auto-updating series, 2026-09-09: *"i want the series
|
|
1237
|
+
* to auto-update ... however the single live node has priority."* That cannot
|
|
1238
|
+
* be expressed in the interactive queue, which is one FIFO with no priority
|
|
1239
|
+
* in it — measured by MW-OBSERVATORY-ART as 91 of 91 downstream cooks queued
|
|
1240
|
+
* behind their upstream with zero overlap and nothing ever superseded. A
|
|
1241
|
+
* series auto-updating through that queue would sit in front of the next
|
|
1242
|
+
* edit: twelve instances at ~220 ms each is 2.6 seconds between Marcus and
|
|
1243
|
+
* the slider he is dragging.
|
|
1244
|
+
*
|
|
1245
|
+
* So background is a second lane with three properties, and all three are
|
|
1246
|
+
* load-bearing:
|
|
1247
|
+
*
|
|
1248
|
+
* - it **starts only when the graph is quiet** — `phase === 'idle'` plus a
|
|
1249
|
+
* settling interval, because otherwise a drag becomes a chain of
|
|
1250
|
+
* started-and-cancelled passes, which is worse than not starting;
|
|
1251
|
+
* - it is **abandoned the moment interactive work arrives**, at `schedule`
|
|
1252
|
+
* rather than at the run, so the debounce window is not spent on it;
|
|
1253
|
+
* - it is abandoned **through a signal the work can see**, so an expensive
|
|
1254
|
+
* task can bail before committing rather than after. That is the fault
|
|
1255
|
+
* this lane exists to avoid rather than relocate: an abandoned cook on
|
|
1256
|
+
* `cloud-volumes` had already encoded and uploaded a 1.55 MB PNG before
|
|
1257
|
+
* anything noticed it was superseded.
|
|
1258
|
+
*
|
|
1259
|
+
* **The trap for whoever builds the first client.** Mutating the graph from
|
|
1260
|
+
* inside a background task — which is what applying a parameter set does —
|
|
1261
|
+
* reaches `markStale`, and `markStale` calls `schedule`, which preempts the
|
|
1262
|
+
* background lane. A task that invalidates the graph therefore cancels
|
|
1263
|
+
* itself. The way out is to drive nodes directly rather than through the
|
|
1264
|
+
* interactive queue, not to weaken the preemption; the preemption is the
|
|
1265
|
+
* only thing making the lane honest.
|
|
1266
|
+
*/
|
|
1267
|
+
async runBackground(task, options = {}) {
|
|
1268
|
+
this.preemptBackground("superseded by a newer background task");
|
|
1269
|
+
await this.whenSettled(options.settleMs ?? this.debounceMs * 4);
|
|
1270
|
+
const abort = new AbortController();
|
|
1271
|
+
this.backgroundAbort = abort;
|
|
1272
|
+
try {
|
|
1273
|
+
await task(abort.signal);
|
|
1274
|
+
return abort.signal.aborted ? "preempted" : "completed";
|
|
1275
|
+
} finally {
|
|
1276
|
+
if (this.backgroundAbort === abort) this.backgroundAbort = null;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
/** Whether background work is in flight. Distinct from `isRunning`, which is
|
|
1280
|
+
* the interactive lane — a panel showing one as the other would report the
|
|
1281
|
+
* series as though Marcus's own edit were still cooking. */
|
|
1282
|
+
get isRunningBackground() {
|
|
1283
|
+
return this.backgroundAbort !== null;
|
|
1284
|
+
}
|
|
1285
|
+
/** @internal Abandon the background lane. Public so a client can stand down
|
|
1286
|
+
* without waiting for an invalidation to do it. */
|
|
1287
|
+
preemptBackground(reason) {
|
|
1288
|
+
const abort = this.backgroundAbort;
|
|
1289
|
+
if (!abort) return;
|
|
1290
|
+
this.backgroundAbort = null;
|
|
1291
|
+
if (!abort.signal.aborted) abort.abort(new Error(reason));
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Resolve once the graph has been idle for `settleMs` without interruption.
|
|
1295
|
+
*
|
|
1296
|
+
* Deliberately not `whenIdle()` plus a timer: `whenIdle` resolves the instant
|
|
1297
|
+
* the queue drains, and a drag drains it between every keystroke. The
|
|
1298
|
+
* settling interval is what turns *momentarily idle* into *finished*, and it
|
|
1299
|
+
* restarts whenever work reappears — so under sustained invalidation this
|
|
1300
|
+
* never resolves, which is correct. Background work has no business starting
|
|
1301
|
+
* while the graph is still moving.
|
|
1302
|
+
*/
|
|
1303
|
+
whenSettled(settleMs) {
|
|
1304
|
+
return new Promise((resolve) => {
|
|
1305
|
+
let timer = null;
|
|
1306
|
+
const stop = this.subscribe((status) => {
|
|
1307
|
+
if (status.phase === "idle") {
|
|
1308
|
+
if (timer === null) {
|
|
1309
|
+
timer = setTimeout(() => {
|
|
1310
|
+
stop();
|
|
1311
|
+
resolve();
|
|
1312
|
+
}, settleMs);
|
|
1313
|
+
}
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
if (timer !== null) {
|
|
1317
|
+
clearTimeout(timer);
|
|
1318
|
+
timer = null;
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
/** Stable-partition `ordered` in place: what a watched node needs, first. */
|
|
1324
|
+
viewerFirst(ordered) {
|
|
1325
|
+
if (this.watched.size === 0) return;
|
|
1326
|
+
const needed = /* @__PURE__ */ new Set();
|
|
1327
|
+
const visit = (id) => {
|
|
1328
|
+
if (needed.has(id)) return;
|
|
1329
|
+
const node = this.graph.getNode(id);
|
|
1330
|
+
if (!node) return;
|
|
1331
|
+
needed.add(id);
|
|
1332
|
+
for (const input of node.inputs) {
|
|
1333
|
+
for (const connection of input.connections) visit(connection.from.nodeId);
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
for (const id of this.watched.keys()) visit(id);
|
|
1337
|
+
if (needed.size === 0) return;
|
|
1338
|
+
const first = ordered.filter((node) => needed.has(node.id));
|
|
1339
|
+
if (first.length === 0 || first.length === ordered.length) return;
|
|
1340
|
+
const rest = ordered.filter((node) => !needed.has(node.id));
|
|
1341
|
+
ordered.splice(0, ordered.length, ...first, ...rest);
|
|
1342
|
+
}
|
|
1343
|
+
collectDownstream(entryNode) {
|
|
1344
|
+
const result = /* @__PURE__ */ new Set();
|
|
1345
|
+
const visit = (node) => {
|
|
1346
|
+
if (result.has(node.id)) return;
|
|
1347
|
+
result.add(node.id);
|
|
1348
|
+
for (const output of node.outputs) {
|
|
1349
|
+
for (const connection of output.connections) {
|
|
1350
|
+
const downstream = this.graph.getNode(connection.to.nodeId);
|
|
1351
|
+
if (downstream) visit(downstream);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
visit(entryNode);
|
|
1356
|
+
return result;
|
|
1357
|
+
}
|
|
1358
|
+
setStatus(status) {
|
|
1359
|
+
this._status = Object.freeze(status);
|
|
1360
|
+
this.emit();
|
|
1361
|
+
}
|
|
1362
|
+
emit() {
|
|
1363
|
+
for (const listener of this.listeners) listener(this._status);
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
// src/nodes/Graph.ts
|
|
1368
|
+
var GRAPH_FORMAT_VERSION = "0.2";
|
|
1369
|
+
function parsePortId(portId) {
|
|
1370
|
+
const parts = portId.split("_");
|
|
1371
|
+
if (parts.length < 3) {
|
|
1372
|
+
throw new Error(`Invalid port ID format: ${portId}`);
|
|
1373
|
+
}
|
|
1374
|
+
const portType = parts[parts.length - 2];
|
|
1375
|
+
const index = parseInt(parts[parts.length - 1], 10);
|
|
1376
|
+
if (isNaN(index)) {
|
|
1377
|
+
throw new Error(`Invalid port index in port ID: ${portId}`);
|
|
1378
|
+
}
|
|
1379
|
+
const elementId = parts.slice(0, -2).join("_");
|
|
1380
|
+
return { elementId, portType, index };
|
|
1381
|
+
}
|
|
1382
|
+
function createPortId(elementId, portType, index) {
|
|
1383
|
+
return `${elementId}_${portType}_${index}`;
|
|
1384
|
+
}
|
|
1385
|
+
var Graph = class _Graph {
|
|
1386
|
+
constructor(assetManager, packageManager, moduleResolver) {
|
|
1387
|
+
this._elements = [];
|
|
1388
|
+
this._elementMap = /* @__PURE__ */ new Map();
|
|
1389
|
+
// O(1) lookup cache
|
|
1390
|
+
this.connections = [];
|
|
1391
|
+
this._connectionMap = /* @__PURE__ */ new Map();
|
|
1392
|
+
// O(1) connection lookup
|
|
1393
|
+
this.connectionIdCounter = 0;
|
|
1394
|
+
// Project configuration (v0.2)
|
|
1395
|
+
this.project = { packages: [] };
|
|
1396
|
+
this.createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1397
|
+
// Execution Control
|
|
1398
|
+
this.cookingNodes = /* @__PURE__ */ new Set();
|
|
1399
|
+
// Execution state
|
|
1400
|
+
this.executionState = "idle";
|
|
1401
|
+
// Cached topological order (invalidated when graph structure changes)
|
|
1402
|
+
this.cachedTopologicalOrder = null;
|
|
1403
|
+
this.restoringHierarchy = false;
|
|
1404
|
+
/**
|
|
1405
|
+
* Whether the load-time connection restore has stopped making progress.
|
|
1406
|
+
*
|
|
1407
|
+
* Set by whoever drives the warm-up passes — Studio's `cookGraph`, the CLI's
|
|
1408
|
+
* runner — when the pending list stops shrinking. The pending list itself is
|
|
1409
|
+
* kept, so a connection can still bind later if a bypass is lifted or a
|
|
1410
|
+
* module finally compiles; what ends is treating it as a reason to hold a
|
|
1411
|
+
* node back.
|
|
1412
|
+
*/
|
|
1413
|
+
this.connectionsSettled = false;
|
|
1414
|
+
this.assetManager = assetManager || new AssetManager();
|
|
1415
|
+
this.packageManager = packageManager || new PackageManager();
|
|
1416
|
+
this.moduleResolver = moduleResolver || createModuleResolver();
|
|
1417
|
+
this.scheduler = new CookScheduler(this);
|
|
1418
|
+
}
|
|
1419
|
+
get created() {
|
|
1420
|
+
return this.createdAt;
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Unified elements array (computations and annotations)
|
|
1424
|
+
*/
|
|
1425
|
+
get elements() {
|
|
1426
|
+
return this._elements;
|
|
1427
|
+
}
|
|
1428
|
+
set elements(value) {
|
|
1429
|
+
this._elements = value;
|
|
1430
|
+
this._elementMap.clear();
|
|
1431
|
+
for (const el of value) {
|
|
1432
|
+
this._elementMap.set(el.id, el);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Helper to check if an element is an annotation
|
|
1437
|
+
* Uses marker property for reliability (survives HMR and serialization)
|
|
1438
|
+
*/
|
|
1439
|
+
isAnnotation(element) {
|
|
1440
|
+
return element.isAnnotation === true;
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Get all computations (non-annotation nodes)
|
|
1444
|
+
*/
|
|
1445
|
+
get nodes() {
|
|
1446
|
+
return this._elements.filter((e) => !this.isAnnotation(e));
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Set computations (triggers Svelte reactivity)
|
|
1450
|
+
*/
|
|
1451
|
+
set nodes(value) {
|
|
1452
|
+
this._elements = [...this._elements.filter((e) => this.isAnnotation(e)), ...value];
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Get all annotations
|
|
1456
|
+
*/
|
|
1457
|
+
get annotations() {
|
|
1458
|
+
return this._elements.filter((e) => this.isAnnotation(e));
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Set annotations (triggers Svelte reactivity)
|
|
1462
|
+
*/
|
|
1463
|
+
set annotations(value) {
|
|
1464
|
+
this._elements = [...this._elements.filter((e) => !this.isAnnotation(e)), ...value];
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Generates a unique element ID based on a base ID.
|
|
1468
|
+
* If the base ID ends in a number (e.g., "Checkers1"), strips it and increments.
|
|
1469
|
+
* Always appends a number starting from 1 (e.g., "Checkers1", "Checkers2").
|
|
1470
|
+
* Ensures the ID has no spaces.
|
|
1471
|
+
* @param baseId The base ID to use (typically the element type or existing node ID)
|
|
1472
|
+
* @param excludeElementId Optional element ID to exclude from uniqueness check (useful when renaming)
|
|
1473
|
+
* @returns A unique element ID with no spaces
|
|
1474
|
+
*/
|
|
1475
|
+
generateUniqueNodeId(baseId, excludeElementId) {
|
|
1476
|
+
const sanitizedBaseId = baseId.replace(/\s+/g, "");
|
|
1477
|
+
const match = sanitizedBaseId.match(/^(.+?)(\d+)?$/);
|
|
1478
|
+
const baseName = match ? match[1] : sanitizedBaseId;
|
|
1479
|
+
let counter = 1;
|
|
1480
|
+
let candidateId = `${baseName}${counter}`;
|
|
1481
|
+
while (this._elements.some(
|
|
1482
|
+
(element) => element.id === candidateId && (!excludeElementId || element.id !== excludeElementId)
|
|
1483
|
+
)) {
|
|
1484
|
+
counter++;
|
|
1485
|
+
candidateId = `${baseName}${counter}`;
|
|
1486
|
+
}
|
|
1487
|
+
return candidateId;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Get any element by ID (O(1) using Map cache)
|
|
1491
|
+
*/
|
|
1492
|
+
getElement(id) {
|
|
1493
|
+
return this._elementMap.get(id) || null;
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Get a computation by ID (type-safe)
|
|
1497
|
+
*/
|
|
1498
|
+
getNode(nodeId) {
|
|
1499
|
+
const element = this.getElement(nodeId);
|
|
1500
|
+
return element && !this.isAnnotation(element) ? element : null;
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Get an annotation by ID (type-safe)
|
|
1504
|
+
*/
|
|
1505
|
+
getAnnotation(id) {
|
|
1506
|
+
const element = this.getElement(id);
|
|
1507
|
+
return element && this.isAnnotation(element) ? element : null;
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Return the live members of a group in authored order.
|
|
1511
|
+
*
|
|
1512
|
+
* Group membership is an annotation relationship, separate from subnet
|
|
1513
|
+
* parenting. Only elements in the group's current network are visible, and
|
|
1514
|
+
* recursive traversal is guarded against duplicate references and malformed
|
|
1515
|
+
* cycles in loaded documents.
|
|
1516
|
+
*/
|
|
1517
|
+
getGroupMembers(groupId, recursive = true) {
|
|
1518
|
+
const group = this.getElement(groupId);
|
|
1519
|
+
if (!this.isGroupAnnotation(group)) return [];
|
|
1520
|
+
const members = [];
|
|
1521
|
+
const seen = /* @__PURE__ */ new Set([group.id]);
|
|
1522
|
+
const visitingGroups = /* @__PURE__ */ new Set([group.id]);
|
|
1523
|
+
const visit = (owner) => {
|
|
1524
|
+
for (const memberId of owner.containedElements ?? []) {
|
|
1525
|
+
if (seen.has(memberId)) continue;
|
|
1526
|
+
const member = this.getElement(memberId);
|
|
1527
|
+
if (!member || member === owner || member.parent !== owner.parent) continue;
|
|
1528
|
+
seen.add(memberId);
|
|
1529
|
+
members.push(memberId);
|
|
1530
|
+
if (recursive && this.isGroupAnnotation(member) && !visitingGroups.has(member.id)) {
|
|
1531
|
+
visitingGroups.add(member.id);
|
|
1532
|
+
visit(member);
|
|
1533
|
+
visitingGroups.delete(member.id);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
visit(group);
|
|
1538
|
+
return members;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Assign existing elements to one group, or remove their group ownership.
|
|
1542
|
+
* Validation completes before any annotation is changed, so failures leave
|
|
1543
|
+
* every membership list untouched. Assignment never changes subnet parents.
|
|
1544
|
+
*/
|
|
1545
|
+
assignToGroup(elementIds, groupId) {
|
|
1546
|
+
const ids = [...new Set(elementIds)];
|
|
1547
|
+
const elements = ids.map((id) => this.getElement(id));
|
|
1548
|
+
if (elements.some((element) => !element)) return false;
|
|
1549
|
+
const targetElement = groupId === null ? null : this.getElement(groupId);
|
|
1550
|
+
if (groupId !== null && !this.isGroupAnnotation(targetElement)) return false;
|
|
1551
|
+
const target = this.isGroupAnnotation(targetElement) ? targetElement : null;
|
|
1552
|
+
if (target) {
|
|
1553
|
+
for (const element of elements) {
|
|
1554
|
+
if (element === target || element.parent !== target.parent) return false;
|
|
1555
|
+
if (this.isGroupAnnotation(element) && this.groupContains(element, target.id)) return false;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
const selected = new Set(ids);
|
|
1559
|
+
for (const group of this.groupAnnotations()) {
|
|
1560
|
+
const current = group.containedElements ?? [];
|
|
1561
|
+
group.containedElements = current.filter((id) => !selected.has(id));
|
|
1562
|
+
}
|
|
1563
|
+
if (target) {
|
|
1564
|
+
target.containedElements = [
|
|
1565
|
+
.../* @__PURE__ */ new Set([...target.containedElements ?? [], ...ids])
|
|
1566
|
+
];
|
|
1567
|
+
}
|
|
1568
|
+
return true;
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Get a node by absolute path (e.g., "/effects/blur1")
|
|
1572
|
+
*/
|
|
1573
|
+
nodeByPath(path) {
|
|
1574
|
+
if (!path.startsWith("/")) return null;
|
|
1575
|
+
const segments = path.slice(1).split("/").filter((s) => s.length > 0);
|
|
1576
|
+
if (segments.length === 0) return null;
|
|
1577
|
+
let current = this.nodes.find((node) => !node.parent && node.id === segments[0]) ?? null;
|
|
1578
|
+
for (let index = 1; current && index < segments.length; index++) {
|
|
1579
|
+
if (!current.isNetwork()) return null;
|
|
1580
|
+
current = current.children().find((child) => child.id === segments[index]) ?? null;
|
|
1581
|
+
}
|
|
1582
|
+
return current;
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Add an element to the graph
|
|
1586
|
+
*/
|
|
1587
|
+
addElement(element) {
|
|
1588
|
+
this._elements.push(element);
|
|
1589
|
+
this._elementMap.set(element.id, element);
|
|
1590
|
+
this.invalidateTopologicalOrder();
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Move an element between networks while keeping both sides of the parent
|
|
1594
|
+
* relationship consistent. Structural editor code must use this instead of
|
|
1595
|
+
* mutating `parent` or `_children` directly.
|
|
1596
|
+
*/
|
|
1597
|
+
reparentElement(element, parent) {
|
|
1598
|
+
if (parent) {
|
|
1599
|
+
if (!parent.isNetwork() || parent === element) return false;
|
|
1600
|
+
for (let ancestor = parent; ancestor; ancestor = ancestor.parent) {
|
|
1601
|
+
if (ancestor === element) return false;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
if (element.parent === parent) return true;
|
|
1605
|
+
const previous = element.parent;
|
|
1606
|
+
if (previous) {
|
|
1607
|
+
const removeChild = previous.removeChild;
|
|
1608
|
+
if (typeof removeChild === "function") removeChild.call(previous, element);
|
|
1609
|
+
else {
|
|
1610
|
+
previous._children = previous.children().filter((child) => child !== element);
|
|
1611
|
+
element.parent = null;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
if (parent) {
|
|
1615
|
+
const addChild = parent.addChild;
|
|
1616
|
+
if (typeof addChild === "function") addChild.call(parent, element);
|
|
1617
|
+
else {
|
|
1618
|
+
element.parent = parent;
|
|
1619
|
+
parent._children.push(element);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
if (!this.restoringHierarchy) this.pruneInvalidGroupMemberships();
|
|
1623
|
+
this.invalidateTopologicalOrder();
|
|
1624
|
+
return true;
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Remove an element from the graph
|
|
1628
|
+
*/
|
|
1629
|
+
removeElement(id) {
|
|
1630
|
+
const element = this._elementMap.get(id);
|
|
1631
|
+
if (!element) return;
|
|
1632
|
+
const removed = [];
|
|
1633
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1634
|
+
const collect = (candidate) => {
|
|
1635
|
+
if (visited.has(candidate)) return;
|
|
1636
|
+
visited.add(candidate);
|
|
1637
|
+
candidate.children().forEach(collect);
|
|
1638
|
+
removed.push(candidate);
|
|
1639
|
+
};
|
|
1640
|
+
collect(element);
|
|
1641
|
+
const removedIds = new Set(removed.map((candidate) => candidate.id));
|
|
1642
|
+
this.connections.filter((connection) => removedIds.has(connection.from.nodeId) || removedIds.has(connection.to.nodeId)).forEach((connection) => this.disconnect(connection.id));
|
|
1643
|
+
const pending = this._connectionsToRestore;
|
|
1644
|
+
if (Array.isArray(pending)) {
|
|
1645
|
+
this._connectionsToRestore = pending.filter((connection) => {
|
|
1646
|
+
const fromId = Array.isArray(connection?.[0]) ? connection[0][0] : void 0;
|
|
1647
|
+
const toId = Array.isArray(connection?.[1]) ? connection[1][0] : void 0;
|
|
1648
|
+
return !removedIds.has(fromId) && !removedIds.has(toId);
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
this._elements = this._elements.filter((candidate) => !removedIds.has(candidate.id));
|
|
1652
|
+
for (const candidate of removed) {
|
|
1653
|
+
this._elementMap.delete(candidate.id);
|
|
1654
|
+
this.cookingNodes.delete(candidate);
|
|
1655
|
+
}
|
|
1656
|
+
for (const group of this.groupAnnotations()) {
|
|
1657
|
+
group.containedElements = (group.containedElements ?? []).filter((memberId) => !removedIds.has(memberId));
|
|
1658
|
+
}
|
|
1659
|
+
for (const candidate of removed) {
|
|
1660
|
+
const parent = candidate.parent;
|
|
1661
|
+
if (parent) {
|
|
1662
|
+
const removeChild = parent.removeChild;
|
|
1663
|
+
if (typeof removeChild === "function") removeChild.call(parent, candidate);
|
|
1664
|
+
else candidate.parent = null;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
this.invalidateTopologicalOrder();
|
|
1668
|
+
for (const candidate of removed) candidate.onDestroy?.();
|
|
1669
|
+
}
|
|
1670
|
+
isGroupAnnotation(element) {
|
|
1671
|
+
return !!element && this.isAnnotation(element) && element.type === "Group";
|
|
1672
|
+
}
|
|
1673
|
+
groupAnnotations() {
|
|
1674
|
+
return this.annotations.filter((annotation) => this.isGroupAnnotation(annotation));
|
|
1675
|
+
}
|
|
1676
|
+
groupContains(group, soughtId) {
|
|
1677
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1678
|
+
const visit = (candidate) => {
|
|
1679
|
+
if (visited.has(candidate.id)) return false;
|
|
1680
|
+
visited.add(candidate.id);
|
|
1681
|
+
for (const memberId of candidate.containedElements ?? []) {
|
|
1682
|
+
if (memberId === soughtId) return true;
|
|
1683
|
+
const member = this.getElement(memberId);
|
|
1684
|
+
if (this.isGroupAnnotation(member) && member.parent === candidate.parent && visit(member)) return true;
|
|
1685
|
+
}
|
|
1686
|
+
return false;
|
|
1687
|
+
};
|
|
1688
|
+
return visit(group);
|
|
1689
|
+
}
|
|
1690
|
+
pruneInvalidGroupMemberships() {
|
|
1691
|
+
for (const group of this.groupAnnotations()) {
|
|
1692
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1693
|
+
group.containedElements = (group.containedElements ?? []).filter((memberId) => {
|
|
1694
|
+
const member = this.getElement(memberId);
|
|
1695
|
+
if (!member || member === group || member.parent !== group.parent || seen.has(memberId)) return false;
|
|
1696
|
+
seen.add(memberId);
|
|
1697
|
+
return true;
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Rename an element's ID, updating internal maps
|
|
1703
|
+
*/
|
|
1704
|
+
renameElement(oldId, newId) {
|
|
1705
|
+
const element = this._elementMap.get(oldId);
|
|
1706
|
+
if (!element || !newId || oldId !== newId && this._elementMap.has(newId)) return false;
|
|
1707
|
+
if (oldId === newId) return true;
|
|
1708
|
+
const renamedPorts = element.renameId(newId);
|
|
1709
|
+
this._elementMap.delete(oldId);
|
|
1710
|
+
this._elementMap.set(newId, element);
|
|
1711
|
+
for (const connection of this.connections) {
|
|
1712
|
+
if (connection.from.nodeId === oldId) connection.from.nodeId = newId;
|
|
1713
|
+
if (connection.to.nodeId === oldId) connection.to.nodeId = newId;
|
|
1714
|
+
connection.from.portId = renamedPorts.get(connection.from.portId) ?? connection.from.portId;
|
|
1715
|
+
connection.to.portId = renamedPorts.get(connection.to.portId) ?? connection.to.portId;
|
|
1716
|
+
}
|
|
1717
|
+
const pending = this._connectionsToRestore;
|
|
1718
|
+
if (Array.isArray(pending)) {
|
|
1719
|
+
for (const connection of pending) {
|
|
1720
|
+
for (const endpoint of Array.isArray(connection) ? connection : []) {
|
|
1721
|
+
if (Array.isArray(endpoint) && endpoint[0] === oldId) endpoint[0] = newId;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
for (const annotation of this.annotations) {
|
|
1726
|
+
if (Array.isArray(annotation.containedElements)) {
|
|
1727
|
+
annotation.containedElements = annotation.containedElements.map((id) => id === oldId ? newId : id);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
this.invalidateTopologicalOrder();
|
|
1731
|
+
return true;
|
|
1732
|
+
}
|
|
1733
|
+
addNode(type, position) {
|
|
1734
|
+
const displayName = getNodeDisplayName(type);
|
|
1735
|
+
const baseId = displayName.toLowerCase().replace(/\s+/g, "_");
|
|
1736
|
+
const id = this.generateUniqueNodeId(baseId);
|
|
1737
|
+
const NodeClass = getNodeClass(type);
|
|
1738
|
+
let node;
|
|
1739
|
+
if (NodeClass) {
|
|
1740
|
+
node = new NodeClass(id, this);
|
|
1741
|
+
} else {
|
|
1742
|
+
node = new Node(id, packagePathToType(type), this);
|
|
1743
|
+
}
|
|
1744
|
+
node.modulePath = type;
|
|
1745
|
+
if (isStandardLibraryNode(type)) {
|
|
1746
|
+
node.sourceType = "stdlib";
|
|
1747
|
+
} else if (type.startsWith("local.")) {
|
|
1748
|
+
node.sourceType = "embedded";
|
|
1749
|
+
} else {
|
|
1750
|
+
const sourceType = this.moduleResolver.getSourceType(type);
|
|
1751
|
+
node.sourceType = sourceType || "embedded";
|
|
1752
|
+
}
|
|
1753
|
+
node.position = position;
|
|
1754
|
+
this.addElement(node);
|
|
1755
|
+
return node;
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* Point an existing node instance at a different module.
|
|
1759
|
+
*
|
|
1760
|
+
* Duplicating a built-in into an editable copy, and promoting an embedded
|
|
1761
|
+
* node to a file, both keep the node where it is on the canvas with its
|
|
1762
|
+
* connections and parameter values intact and only change which module
|
|
1763
|
+
* supplies `execute`. The compiled module is captured once when a node is
|
|
1764
|
+
* loaded, so swapping the path alone would leave the old code running.
|
|
1765
|
+
*
|
|
1766
|
+
* Ports and parameters are untouched on purpose: both callers move IDENTICAL
|
|
1767
|
+
* code, so the ports and settings the next cook declares are the ones already
|
|
1768
|
+
* there, and reconciling them would only be a chance to be wrong.
|
|
1769
|
+
*
|
|
1770
|
+
* Switching to a different VERSION of a definition is a different move — use
|
|
1771
|
+
* retargetDefinition, which reconciles and reports.
|
|
1772
|
+
*/
|
|
1773
|
+
retargetModule(nodeId, modulePath, source, code) {
|
|
1774
|
+
return this.retargetDefinition(nodeId, modulePath, source, code, {
|
|
1775
|
+
preserveParameters: false
|
|
1776
|
+
}).ok;
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Point a node at a different definition and carry its settings across.
|
|
1780
|
+
*
|
|
1781
|
+
* The general form of retargetModule. One code path: the module swap is
|
|
1782
|
+
* identical, and what differs is whether a reconciliation is asked for.
|
|
1783
|
+
* retargetModule is the case where it is not — identical code drops nothing,
|
|
1784
|
+
* so its report would be empty anyway, and skipping it keeps a working path
|
|
1785
|
+
* working. Everything else goes through the reconciler.
|
|
1786
|
+
*
|
|
1787
|
+
* Policy (see ParameterCarryOverReport in Node.ts):
|
|
1788
|
+
* - a parameter surviving by NAME and TYPE keeps its value, and its
|
|
1789
|
+
* expression with it
|
|
1790
|
+
* - one the new definition adds takes its declared default
|
|
1791
|
+
* - one the new definition no longer has is dropped AND REPORTED, with the
|
|
1792
|
+
* value it held, so a caller can offer it back
|
|
1793
|
+
* - same name, different type is a conflict: the new type wins with its
|
|
1794
|
+
* default and the conflict is reported. No coercion is attempted
|
|
1795
|
+
*
|
|
1796
|
+
* Pass `options.parameters` when the new definition's declarations are known
|
|
1797
|
+
* (a Definition panel has them) and the result is complete on return. Without
|
|
1798
|
+
* them the result is `deferred: true` — a module's parameters are only known
|
|
1799
|
+
* once its code declares them, which happens on the next cook.
|
|
1800
|
+
*/
|
|
1801
|
+
retargetDefinition(nodeId, modulePath, source, code, options = {}) {
|
|
1802
|
+
const blank = () => ({ kept: [], defaulted: [], dropped: [], retyped: [] });
|
|
1803
|
+
const node = this.getNode(nodeId);
|
|
1804
|
+
if (!node) {
|
|
1805
|
+
return { ok: false, deferred: false, ...blank(), error: `no node with id ${nodeId}` };
|
|
1806
|
+
}
|
|
1807
|
+
const preserve = options.preserveParameters !== false;
|
|
1808
|
+
if (preserve) node.beginParameterCarryOver();
|
|
1809
|
+
node.modulePath = modulePath;
|
|
1810
|
+
node.sourceType = source;
|
|
1811
|
+
if (source === "embedded" && code !== void 0) node.code = code;
|
|
1812
|
+
const modulePromise = source === "project" ? loadProjectModule(modulePath) : loadEmbeddedModule(code ?? node.code);
|
|
1813
|
+
node.setFunction(moduleNodeFunction(node, modulePath, modulePromise));
|
|
1814
|
+
modulePromise.catch((error) => {
|
|
1815
|
+
node.abandonParameterCarryOver();
|
|
1816
|
+
console.warn("Failed to compile " + modulePath + ":", error);
|
|
1817
|
+
});
|
|
1818
|
+
node.markDirty();
|
|
1819
|
+
if (preserve && options.parameters) {
|
|
1820
|
+
const report = node.applyParameterDeclarations(options.parameters) ?? blank();
|
|
1821
|
+
return { ok: true, deferred: false, ...report };
|
|
1822
|
+
}
|
|
1823
|
+
return { ok: true, deferred: preserve, ...blank() };
|
|
1824
|
+
}
|
|
1825
|
+
/** The report from a deferred retarget, once its cook has run. Null until
|
|
1826
|
+
* then, and null for a node that was never retargeted. */
|
|
1827
|
+
getRetargetReport(nodeId) {
|
|
1828
|
+
return this.getNode(nodeId)?.lastCarryOverReport ?? null;
|
|
1829
|
+
}
|
|
1830
|
+
removeNode(nodeId) {
|
|
1831
|
+
this.removeElement(nodeId);
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Validate a connection before creating it
|
|
1835
|
+
* Works with any element type (nodes and annotations)
|
|
1836
|
+
*/
|
|
1837
|
+
validateConnection(fromPort, toPort) {
|
|
1838
|
+
let fromElementId;
|
|
1839
|
+
let toElementId;
|
|
1840
|
+
try {
|
|
1841
|
+
const fromParsed = parsePortId(fromPort.id);
|
|
1842
|
+
const toParsed = parsePortId(toPort.id);
|
|
1843
|
+
fromElementId = fromParsed.elementId;
|
|
1844
|
+
toElementId = toParsed.elementId;
|
|
1845
|
+
} catch (err) {
|
|
1846
|
+
return { valid: false, error: `Invalid port ID format: ${err}` };
|
|
1847
|
+
}
|
|
1848
|
+
const fromElement = this.getElement(fromElementId);
|
|
1849
|
+
const toElement = this.getElement(toElementId);
|
|
1850
|
+
if (!fromElement || !toElement) {
|
|
1851
|
+
return { valid: false, error: "Element not found" };
|
|
1852
|
+
}
|
|
1853
|
+
if (!this.isAnnotation(fromElement) && !this.isAnnotation(toElement)) {
|
|
1854
|
+
const error = GraphValidator.validateConnection(
|
|
1855
|
+
this,
|
|
1856
|
+
fromElementId,
|
|
1857
|
+
toElementId,
|
|
1858
|
+
fromPort.id,
|
|
1859
|
+
toPort.id
|
|
1860
|
+
);
|
|
1861
|
+
if (error) {
|
|
1862
|
+
return { valid: false, error: error.message };
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
return { valid: true };
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* Check data type compatibility between ports
|
|
1869
|
+
* Returns a warning message if types don't match, or null if compatible
|
|
1870
|
+
*/
|
|
1871
|
+
checkDataTypeCompatibility(fromPort, toPort) {
|
|
1872
|
+
const fromType = normalizeType(fromPort.dataType);
|
|
1873
|
+
const toType = normalizeType(toPort.dataType);
|
|
1874
|
+
if (canConnect(fromType, toType)) {
|
|
1875
|
+
if (fromType === "bundle" && toType === "bundle" && !bundleSchemasCompatible(fromPort.options?.schema, toPort.options?.schema)) {
|
|
1876
|
+
return `Schema mismatch: connecting a ${fromPort.options.schema} bundle to an input that takes ${toPort.options.schema}`;
|
|
1877
|
+
}
|
|
1878
|
+
return null;
|
|
1879
|
+
}
|
|
1880
|
+
const legacy = {
|
|
1881
|
+
number: ["number[]", "string"],
|
|
1882
|
+
float: ["number[]"],
|
|
1883
|
+
int: ["number[]"],
|
|
1884
|
+
string: ["number", "float", "int"]
|
|
1885
|
+
};
|
|
1886
|
+
if (legacy[fromType]?.includes(toType)) return null;
|
|
1887
|
+
return `Type mismatch: connecting ${fromType} to ${toType}`;
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Update type mismatch warnings on a node based on its input connections
|
|
1891
|
+
*/
|
|
1892
|
+
updateTypeMismatchWarnings(nodeId) {
|
|
1893
|
+
const node = this.getNode(nodeId);
|
|
1894
|
+
if (!node) return;
|
|
1895
|
+
const warnings = [];
|
|
1896
|
+
for (const inputPort of node.inputs) {
|
|
1897
|
+
for (const conn of inputPort.connections) {
|
|
1898
|
+
const fromNode = this.getNode(conn.from.nodeId);
|
|
1899
|
+
if (!fromNode) continue;
|
|
1900
|
+
const fromPort = fromNode.outputs.find((p) => p.id === conn.from.portId);
|
|
1901
|
+
if (!fromPort) continue;
|
|
1902
|
+
const warning = this.checkDataTypeCompatibility(fromPort, inputPort);
|
|
1903
|
+
if (warning) {
|
|
1904
|
+
warnings.push(warning);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
if (warnings.length > 0) {
|
|
1909
|
+
node.warning = warnings.join("; ");
|
|
1910
|
+
} else if (node.warning?.startsWith("Type mismatch")) {
|
|
1911
|
+
node.warning = null;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
connect(fromPort, toPort) {
|
|
1915
|
+
const validation = this.validateConnection(fromPort, toPort);
|
|
1916
|
+
if (!validation.valid) {
|
|
1917
|
+
throw new Error(`Invalid connection: ${validation.error}`);
|
|
1918
|
+
}
|
|
1919
|
+
if (!toPort.variadic && toPort.connections && toPort.connections.length > 0) {
|
|
1920
|
+
const connectionsToRemove = [...toPort.connections];
|
|
1921
|
+
for (const conn of connectionsToRemove) {
|
|
1922
|
+
this.disconnect(conn.id);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
const fromParsed = parsePortId(fromPort.id);
|
|
1926
|
+
const toParsed = parsePortId(toPort.id);
|
|
1927
|
+
const connection = {
|
|
1928
|
+
id: `conn_${Date.now()}_${++this.connectionIdCounter}`,
|
|
1929
|
+
from: {
|
|
1930
|
+
nodeId: fromParsed.elementId,
|
|
1931
|
+
portId: fromPort.id
|
|
1932
|
+
},
|
|
1933
|
+
to: {
|
|
1934
|
+
nodeId: toParsed.elementId,
|
|
1935
|
+
portId: toPort.id
|
|
1936
|
+
},
|
|
1937
|
+
type: fromPort.portType
|
|
1938
|
+
};
|
|
1939
|
+
const existing = fromPort.connections.find(
|
|
1940
|
+
(c) => c.to.nodeId === connection.to.nodeId && c.to.portId === connection.to.portId
|
|
1941
|
+
);
|
|
1942
|
+
if (existing) {
|
|
1943
|
+
return existing;
|
|
1944
|
+
}
|
|
1945
|
+
this.connections.push(connection);
|
|
1946
|
+
this._connectionMap.set(connection.id, connection);
|
|
1947
|
+
fromPort.connections.push(connection);
|
|
1948
|
+
toPort.connections.push(connection);
|
|
1949
|
+
this.invalidateTopologicalOrder();
|
|
1950
|
+
if (fromPort.value !== void 0 && fromPort.value !== null) {
|
|
1951
|
+
toPort.value = fromPort.value;
|
|
1952
|
+
if (toPort.onChange) {
|
|
1953
|
+
try {
|
|
1954
|
+
toPort.onChange(fromPort.value);
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
console.error(`Error in onChange callback for port ${toPort.name}:`, err);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
this.syncVariadicPortsOnNode(toParsed.elementId);
|
|
1961
|
+
this.updateTypeMismatchWarnings(toParsed.elementId);
|
|
1962
|
+
this.getNode(toParsed.elementId)?.markDirty();
|
|
1963
|
+
return connection;
|
|
1964
|
+
}
|
|
1965
|
+
disconnect(connectionId) {
|
|
1966
|
+
const conn = this._connectionMap.get(connectionId);
|
|
1967
|
+
if (!conn) return;
|
|
1968
|
+
const sourceNode = this.getElement(conn.from.nodeId);
|
|
1969
|
+
const targetNode = this.getElement(conn.to.nodeId);
|
|
1970
|
+
if (sourceNode) {
|
|
1971
|
+
const sourcePort = sourceNode.getPort(conn.from.portId);
|
|
1972
|
+
if (sourcePort) {
|
|
1973
|
+
sourcePort.connections = sourcePort.connections.filter((c) => c.id !== connectionId);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
if (targetNode) {
|
|
1977
|
+
const targetPort = targetNode.getPort(conn.to.portId);
|
|
1978
|
+
if (targetPort) {
|
|
1979
|
+
targetPort.connections = targetPort.connections.filter((c) => c.id !== connectionId);
|
|
1980
|
+
if (targetPort.connections.length === 0) {
|
|
1981
|
+
targetPort.value = targetPort.defaultValue;
|
|
1982
|
+
try {
|
|
1983
|
+
targetPort.onChange?.(targetPort.defaultValue);
|
|
1984
|
+
} catch (err) {
|
|
1985
|
+
console.error(`Error in onChange callback for port ${targetPort.name}:`, err);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
const index = this.connections.findIndex((c) => c.id === connectionId);
|
|
1991
|
+
if (index >= 0) {
|
|
1992
|
+
this.connections.splice(index, 1);
|
|
1993
|
+
}
|
|
1994
|
+
this._connectionMap.delete(connectionId);
|
|
1995
|
+
this.invalidateTopologicalOrder();
|
|
1996
|
+
this.syncVariadicPortsOnNode(conn.to.nodeId);
|
|
1997
|
+
this.updateTypeMismatchWarnings(conn.to.nodeId);
|
|
1998
|
+
this.getNode(conn.to.nodeId)?.markDirty();
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Sync variadic ports on a node after connection changes
|
|
2002
|
+
*/
|
|
2003
|
+
syncVariadicPortsOnNode(nodeId) {
|
|
2004
|
+
const node = this.getNode(nodeId);
|
|
2005
|
+
if (node) {
|
|
2006
|
+
node.syncVariadicPorts();
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* Validate the entire graph
|
|
2011
|
+
*/
|
|
2012
|
+
validate() {
|
|
2013
|
+
return GraphValidator.validateGraph(this);
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Execute graph using topological sort for proper ordering
|
|
2017
|
+
*/
|
|
2018
|
+
async execute(entryNode) {
|
|
2019
|
+
this.executionState = "running";
|
|
2020
|
+
try {
|
|
2021
|
+
await this.scheduler.flush(entryNode);
|
|
2022
|
+
} finally {
|
|
2023
|
+
this.executionState = "idle";
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* Get or compute topological order for the entire graph
|
|
2028
|
+
* Caches the result until graph structure changes
|
|
2029
|
+
*/
|
|
2030
|
+
getTopologicalOrder() {
|
|
2031
|
+
if (this.cachedTopologicalOrder) {
|
|
2032
|
+
return this.cachedTopologicalOrder;
|
|
2033
|
+
}
|
|
2034
|
+
this.cachedTopologicalOrder = GraphValidator.topologicalSort(this);
|
|
2035
|
+
return this.cachedTopologicalOrder;
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Invalidate cached topological order (call when graph structure changes)
|
|
2039
|
+
*/
|
|
2040
|
+
invalidateTopologicalOrder() {
|
|
2041
|
+
this.cachedTopologicalOrder = null;
|
|
2042
|
+
}
|
|
2043
|
+
stop() {
|
|
2044
|
+
this.executionState = "stopped";
|
|
2045
|
+
}
|
|
2046
|
+
reset() {
|
|
2047
|
+
this.nodes.forEach((node) => {
|
|
2048
|
+
node.error = null;
|
|
2049
|
+
node.warning = null;
|
|
2050
|
+
node.markDirty();
|
|
2051
|
+
});
|
|
2052
|
+
this.cookingNodes.clear();
|
|
2053
|
+
this.executionState = "idle";
|
|
2054
|
+
}
|
|
2055
|
+
// Behavior Control (v1.2)
|
|
2056
|
+
clearCookingNodes(except) {
|
|
2057
|
+
if (except) {
|
|
2058
|
+
this.cookingNodes.forEach((comp) => {
|
|
2059
|
+
if (comp !== except) {
|
|
2060
|
+
comp.setCook(false);
|
|
2061
|
+
}
|
|
2062
|
+
});
|
|
2063
|
+
} else {
|
|
2064
|
+
this.cookingNodes.forEach((comp) => {
|
|
2065
|
+
comp.setCook(false);
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
isDownstreamOfCooking(node) {
|
|
2070
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2071
|
+
const checkUpstream = (n) => {
|
|
2072
|
+
if (visited.has(n.id)) return false;
|
|
2073
|
+
visited.add(n.id);
|
|
2074
|
+
if (this.cookingNodes.has(n)) {
|
|
2075
|
+
return true;
|
|
2076
|
+
}
|
|
2077
|
+
for (const input of n.inputs) {
|
|
2078
|
+
for (const conn of input.connections) {
|
|
2079
|
+
const upstreamNode = this.getNode(conn.from.nodeId);
|
|
2080
|
+
if (upstreamNode && checkUpstream(upstreamNode)) {
|
|
2081
|
+
return true;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
return false;
|
|
2086
|
+
};
|
|
2087
|
+
return checkUpstream(node);
|
|
2088
|
+
}
|
|
2089
|
+
addAnnotation(annotation) {
|
|
2090
|
+
let annotationInstance;
|
|
2091
|
+
if (this.isAnnotation(annotation)) {
|
|
2092
|
+
annotationInstance = annotation;
|
|
2093
|
+
} else {
|
|
2094
|
+
const annData = annotation;
|
|
2095
|
+
const normalizedType = annData.type.charAt(0).toUpperCase() + annData.type.slice(1).toLowerCase();
|
|
2096
|
+
switch (normalizedType) {
|
|
2097
|
+
case "Image":
|
|
2098
|
+
annotationInstance = new ImageAnnotation(annData.id, this);
|
|
2099
|
+
annotationInstance.src = annData.src;
|
|
2100
|
+
break;
|
|
2101
|
+
case "Text":
|
|
2102
|
+
annotationInstance = new TextAnnotation(annData.id, this);
|
|
2103
|
+
annotationInstance.content = annData.content;
|
|
2104
|
+
break;
|
|
2105
|
+
case "Group":
|
|
2106
|
+
annotationInstance = new GroupAnnotation(annData.id, this);
|
|
2107
|
+
annotationInstance.content = annData.content;
|
|
2108
|
+
break;
|
|
2109
|
+
case "Line":
|
|
2110
|
+
annotationInstance = new LineAnnotation(annData.id, this);
|
|
2111
|
+
if (annData.endPosition) {
|
|
2112
|
+
annotationInstance.endPosition = annData.endPosition;
|
|
2113
|
+
}
|
|
2114
|
+
break;
|
|
2115
|
+
case "Polyline":
|
|
2116
|
+
annotationInstance = new PolylineAnnotation(annData.id, this);
|
|
2117
|
+
if (annData.points) {
|
|
2118
|
+
annotationInstance.points = annData.points;
|
|
2119
|
+
}
|
|
2120
|
+
break;
|
|
2121
|
+
default:
|
|
2122
|
+
annotationInstance = new Annotation(annData.id, annData.type, this);
|
|
2123
|
+
if (annData.content !== void 0) annotationInstance.content = annData.content;
|
|
2124
|
+
if (annData.src !== void 0) annotationInstance.src = annData.src;
|
|
2125
|
+
if (annData.endPosition) annotationInstance.endPosition = annData.endPosition;
|
|
2126
|
+
if (annData.points) annotationInstance.points = annData.points;
|
|
2127
|
+
}
|
|
2128
|
+
annotationInstance.position = annData.position || { x: 0, y: 0 };
|
|
2129
|
+
if (annData.size) annotationInstance.size = annData.size;
|
|
2130
|
+
if (annData.style) annotationInstance.style = { ...annData.style };
|
|
2131
|
+
if (annData.caption !== void 0) annotationInstance.caption = annData.caption;
|
|
2132
|
+
if (annData.containedElements) annotationInstance.containedElements = annData.containedElements;
|
|
2133
|
+
}
|
|
2134
|
+
this.addElement(annotationInstance);
|
|
2135
|
+
this.initializeAnnotationPorts(annotationInstance).catch((err) => {
|
|
2136
|
+
console.error(`Failed to initialize ports for annotation ${annotationInstance.id}:`, err);
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
removeAnnotation(id) {
|
|
2140
|
+
this.removeElement(id);
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* Initialize output ports for an annotation based on its type
|
|
2144
|
+
* Annotations always have exactly one output at index 0
|
|
2145
|
+
*/
|
|
2146
|
+
async initializeAnnotationPorts(annotation) {
|
|
2147
|
+
annotation.outputs = [];
|
|
2148
|
+
if (annotation.type === "Image") {
|
|
2149
|
+
const imageAnnotation = annotation;
|
|
2150
|
+
const portId = createPortId(annotation.id, "output", 0);
|
|
2151
|
+
const port = {
|
|
2152
|
+
id: portId,
|
|
2153
|
+
name: "image",
|
|
2154
|
+
portType: "param",
|
|
2155
|
+
dataType: "asset",
|
|
2156
|
+
value: null,
|
|
2157
|
+
connections: [],
|
|
2158
|
+
setValue: (value) => {
|
|
2159
|
+
port.value = value;
|
|
2160
|
+
port.connections.forEach((conn) => {
|
|
2161
|
+
const toParsed = parsePortId(conn.to.portId);
|
|
2162
|
+
const targetElement = this.getElement(toParsed.elementId);
|
|
2163
|
+
if (targetElement && !this.isAnnotation(targetElement)) {
|
|
2164
|
+
const targetPort = targetElement.getInputPort(toParsed.index);
|
|
2165
|
+
if (targetPort) {
|
|
2166
|
+
targetPort.value = value;
|
|
2167
|
+
targetPort.onChange?.(value);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
});
|
|
2171
|
+
},
|
|
2172
|
+
trigger: (props) => {
|
|
2173
|
+
port.connections.forEach((conn) => {
|
|
2174
|
+
const toParsed = parsePortId(conn.to.portId);
|
|
2175
|
+
const targetElement = this.getElement(toParsed.elementId);
|
|
2176
|
+
if (targetElement && !this.isAnnotation(targetElement)) {
|
|
2177
|
+
const targetPort = targetElement.getInputPort(toParsed.index);
|
|
2178
|
+
targetPort?.onTrigger?.(props);
|
|
2179
|
+
}
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
};
|
|
2183
|
+
annotation.outputs.push(port);
|
|
2184
|
+
const imageSrc = imageAnnotation.src;
|
|
2185
|
+
if (imageSrc) {
|
|
2186
|
+
try {
|
|
2187
|
+
const img = new Image();
|
|
2188
|
+
img.crossOrigin = "anonymous";
|
|
2189
|
+
await new Promise((resolve, reject) => {
|
|
2190
|
+
img.onload = () => {
|
|
2191
|
+
port.setValue(img);
|
|
2192
|
+
resolve();
|
|
2193
|
+
};
|
|
2194
|
+
img.onerror = reject;
|
|
2195
|
+
img.src = imageSrc.startsWith("/") || imageSrc.startsWith("http") || imageSrc.startsWith("data:") ? imageSrc : `/${imageSrc}`;
|
|
2196
|
+
});
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
console.error(`Failed to load image for annotation ${annotation.id}:`, error);
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
} else if (annotation.type === "Text") {
|
|
2202
|
+
const textAnnotation = annotation;
|
|
2203
|
+
if (textAnnotation.content !== void 0) {
|
|
2204
|
+
const portId = createPortId(annotation.id, "output", 0);
|
|
2205
|
+
const port = {
|
|
2206
|
+
id: portId,
|
|
2207
|
+
name: "text",
|
|
2208
|
+
portType: "param",
|
|
2209
|
+
dataType: "string",
|
|
2210
|
+
value: textAnnotation.content,
|
|
2211
|
+
connections: [],
|
|
2212
|
+
setValue: (value) => {
|
|
2213
|
+
port.value = value;
|
|
2214
|
+
port.connections.forEach((conn) => {
|
|
2215
|
+
const toParsed = parsePortId(conn.to.portId);
|
|
2216
|
+
const targetElement = this.getElement(toParsed.elementId);
|
|
2217
|
+
if (targetElement && !this.isAnnotation(targetElement)) {
|
|
2218
|
+
const targetPort = targetElement.getInputPort(toParsed.index);
|
|
2219
|
+
if (targetPort) {
|
|
2220
|
+
targetPort.value = value;
|
|
2221
|
+
targetPort.onChange?.(value);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
});
|
|
2225
|
+
},
|
|
2226
|
+
trigger: (props) => {
|
|
2227
|
+
port.connections.forEach((conn) => {
|
|
2228
|
+
const toParsed = parsePortId(conn.to.portId);
|
|
2229
|
+
const targetElement = this.getElement(toParsed.elementId);
|
|
2230
|
+
if (targetElement && !this.isAnnotation(targetElement)) {
|
|
2231
|
+
const targetPort = targetElement.getInputPort(toParsed.index);
|
|
2232
|
+
targetPort?.onTrigger?.(props);
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
annotation.outputs.push(port);
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
// ============ Path Validation ============
|
|
2242
|
+
/**
|
|
2243
|
+
* Validate all expressions in the graph for broken paths
|
|
2244
|
+
* Returns warnings for any expressions referencing non-existent nodes or parameters
|
|
2245
|
+
*/
|
|
2246
|
+
validateExpressionPaths() {
|
|
2247
|
+
const warnings = [];
|
|
2248
|
+
const pathRefPattern = /\b(?:ch|chs|chv)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
2249
|
+
for (const node of this.nodes) {
|
|
2250
|
+
for (const [propName, prop] of Object.entries(node.props)) {
|
|
2251
|
+
if (!prop.expression) continue;
|
|
2252
|
+
const location = `${node.path()}/${propName}`;
|
|
2253
|
+
const matches = prop.expression.matchAll(pathRefPattern);
|
|
2254
|
+
for (const match of matches) {
|
|
2255
|
+
const referencedPath = match[1];
|
|
2256
|
+
const bundleWarning = this.bundlePathWarning(node, referencedPath);
|
|
2257
|
+
if (bundleWarning !== void 0) {
|
|
2258
|
+
if (bundleWarning) warnings.push({ location, expression: prop.expression, brokenPath: referencedPath, suggestion: bundleWarning });
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
const resolvedNode = this.resolvePathFromNode(node, referencedPath);
|
|
2262
|
+
if (!resolvedNode.node) {
|
|
2263
|
+
warnings.push({
|
|
2264
|
+
location,
|
|
2265
|
+
expression: prop.expression,
|
|
2266
|
+
brokenPath: referencedPath,
|
|
2267
|
+
suggestion: this.suggestPathFix(node, referencedPath)
|
|
2268
|
+
});
|
|
2269
|
+
} else if (resolvedNode.parmName && !resolvedNode.node.props[resolvedNode.parmName]) {
|
|
2270
|
+
warnings.push({
|
|
2271
|
+
location,
|
|
2272
|
+
expression: prop.expression,
|
|
2273
|
+
brokenPath: referencedPath,
|
|
2274
|
+
suggestion: `Parameter "${resolvedNode.parmName}" not found on node "${resolvedNode.node.id}"`
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
return {
|
|
2281
|
+
valid: warnings.length === 0,
|
|
2282
|
+
warnings
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* A bundle field path in an expression: undefined when the path is not one,
|
|
2287
|
+
* null when it is and reads a field the producer declares (or one that
|
|
2288
|
+
* cannot be checked), and the problem otherwise.
|
|
2289
|
+
*/
|
|
2290
|
+
bundlePathWarning(node, path) {
|
|
2291
|
+
const remote = remoteBundlePath(path);
|
|
2292
|
+
if (remote) {
|
|
2293
|
+
const owner = this.getNode(remote.node);
|
|
2294
|
+
const output = owner?.outputs.find((port) => port.name === remote.port);
|
|
2295
|
+
if (output?.dataType !== "bundle") return void 0;
|
|
2296
|
+
return `Expressions read bundle fields through a wire: connect ${remote.node}.${remote.port} to a bundle input here and read "${remote.port}.${remote.field}"`;
|
|
2297
|
+
}
|
|
2298
|
+
const reference = parseBundleFieldPath(path);
|
|
2299
|
+
if (!reference) return void 0;
|
|
2300
|
+
const input = node.inputs.find((port) => port.name === reference.port);
|
|
2301
|
+
if (!input || input.dataType !== "bundle") return void 0;
|
|
2302
|
+
const known = this.bundleFieldsFeeding(input);
|
|
2303
|
+
if (!known || known.includes(reference.field)) return null;
|
|
2304
|
+
return unknownFieldMessage(reference.field, known, `${node.id}.${reference.port}`);
|
|
2305
|
+
}
|
|
2306
|
+
/** The field names declared by whatever feeds a bundle input, or null. */
|
|
2307
|
+
bundleFieldsFeeding(input) {
|
|
2308
|
+
const connection = input.connections[0];
|
|
2309
|
+
if (!connection) return null;
|
|
2310
|
+
const port = this.getNode(connection.from.nodeId)?.getPort(connection.from.portId);
|
|
2311
|
+
const fields = port?.options?.bundleFields;
|
|
2312
|
+
return fields ? Object.keys(fields) : null;
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Resolve a path from a node's context, returning both the node and optional parameter name
|
|
2316
|
+
*/
|
|
2317
|
+
resolvePathFromNode(fromNode, path) {
|
|
2318
|
+
const lastSlash = path.lastIndexOf("/");
|
|
2319
|
+
let nodePath;
|
|
2320
|
+
let parmName;
|
|
2321
|
+
if (lastSlash > 0) {
|
|
2322
|
+
const possibleParm = path.substring(lastSlash + 1);
|
|
2323
|
+
const possibleNodePath = path.substring(0, lastSlash);
|
|
2324
|
+
const asNode = fromNode.node(path);
|
|
2325
|
+
if (asNode) {
|
|
2326
|
+
return { node: asNode };
|
|
2327
|
+
}
|
|
2328
|
+
const parentNode = fromNode.node(possibleNodePath);
|
|
2329
|
+
if (parentNode) {
|
|
2330
|
+
return { node: parentNode, parmName: possibleParm };
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
return { node: fromNode.node(path) };
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
2336
|
+
* Try to suggest a fix for a broken path
|
|
2337
|
+
*/
|
|
2338
|
+
suggestPathFix(fromNode, brokenPath) {
|
|
2339
|
+
const segments = brokenPath.replace(/^\.?\.?\//, "").split("/");
|
|
2340
|
+
const targetName = segments[segments.length - 1];
|
|
2341
|
+
const allNodes = this.nodes;
|
|
2342
|
+
const similar = allNodes.filter(
|
|
2343
|
+
(n) => n.id.toLowerCase().includes(targetName.toLowerCase()) || targetName.toLowerCase().includes(n.id.toLowerCase())
|
|
2344
|
+
);
|
|
2345
|
+
if (similar.length > 0) {
|
|
2346
|
+
const suggestion = similar[0];
|
|
2347
|
+
return `Did you mean "${suggestion.path()}"?`;
|
|
2348
|
+
}
|
|
2349
|
+
return void 0;
|
|
2350
|
+
}
|
|
2351
|
+
toJSON() {
|
|
2352
|
+
const result = {
|
|
2353
|
+
version: GRAPH_FORMAT_VERSION,
|
|
2354
|
+
metadata: {
|
|
2355
|
+
name: this.project.name || "Cascade Graph",
|
|
2356
|
+
created: this.createdAt,
|
|
2357
|
+
modified: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2358
|
+
...this.project.description ? { description: this.project.description } : {},
|
|
2359
|
+
...this.project.author ? { author: this.project.author } : {}
|
|
2360
|
+
}
|
|
2361
|
+
};
|
|
2362
|
+
if (this.project.packages.length > 0) {
|
|
2363
|
+
result.project = {
|
|
2364
|
+
packages: this.project.packages.map((pkg) => ({
|
|
2365
|
+
path: pkg.path,
|
|
2366
|
+
alias: pkg.alias
|
|
2367
|
+
}))
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
const resolverConfig = this.moduleResolver.exportConfig();
|
|
2371
|
+
if (Object.keys(resolverConfig.embeddedModules).length > 0) {
|
|
2372
|
+
result.embeddedModules = resolverConfig.embeddedModules;
|
|
2373
|
+
}
|
|
2374
|
+
if (Object.keys(resolverConfig.externalModules).length > 0) {
|
|
2375
|
+
result.externalModules = resolverConfig.externalModules;
|
|
2376
|
+
}
|
|
2377
|
+
const packages = this.packageManager.getCachedPackages().map((pkg) => {
|
|
2378
|
+
const [name, version] = pkg.split("@");
|
|
2379
|
+
return { name, version: version || "latest" };
|
|
2380
|
+
});
|
|
2381
|
+
if (packages.length > 0) {
|
|
2382
|
+
result.packages = packages;
|
|
2383
|
+
}
|
|
2384
|
+
const assets = this.assetManager.list().map((asset) => ({
|
|
2385
|
+
id: asset.id,
|
|
2386
|
+
path: asset.path,
|
|
2387
|
+
type: asset.type,
|
|
2388
|
+
size: asset.size
|
|
2389
|
+
}));
|
|
2390
|
+
if (assets.length > 0) {
|
|
2391
|
+
result.assets = { manifest: assets };
|
|
2392
|
+
}
|
|
2393
|
+
const annotations = this.annotations;
|
|
2394
|
+
const nodes = this.nodes;
|
|
2395
|
+
if (annotations.length > 0) {
|
|
2396
|
+
result.annotations = annotations.map((ann) => {
|
|
2397
|
+
const serialized = {
|
|
2398
|
+
id: ann.id,
|
|
2399
|
+
type: ann.type,
|
|
2400
|
+
position: [ann.position.x, ann.position.y]
|
|
2401
|
+
};
|
|
2402
|
+
const annAny = ann;
|
|
2403
|
+
if (annAny.content !== void 0) serialized.content = annAny.content;
|
|
2404
|
+
if (annAny.src !== void 0) serialized.src = annAny.src;
|
|
2405
|
+
if (ann.size) serialized.size = ann.size;
|
|
2406
|
+
if (annAny.points) serialized.points = annAny.points.map((p) => [p.x, p.y]);
|
|
2407
|
+
if (annAny.endPosition) serialized.endPosition = [annAny.endPosition.x, annAny.endPosition.y];
|
|
2408
|
+
if (ann.style) serialized.style = ann.style;
|
|
2409
|
+
if (ann.caption !== void 0) serialized.caption = ann.caption;
|
|
2410
|
+
if (ann.containedElements) serialized.containedElements = [...ann.containedElements];
|
|
2411
|
+
if (ann.parent) serialized.parent = ann.parent.id;
|
|
2412
|
+
return serialized;
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
result.nodes = nodes.map((n) => this.nodeToJSON(n));
|
|
2416
|
+
const connections = this.connections.map((conn) => {
|
|
2417
|
+
try {
|
|
2418
|
+
const fromParsed = parsePortId(conn.from.portId);
|
|
2419
|
+
const toParsed = parsePortId(conn.to.portId);
|
|
2420
|
+
const fromElement = this.getElement(fromParsed.elementId);
|
|
2421
|
+
const toElement = this.getElement(toParsed.elementId);
|
|
2422
|
+
if (!fromElement || !toElement) return null;
|
|
2423
|
+
const fromPort = fromElement.outputs?.[fromParsed.index];
|
|
2424
|
+
const toPort = toElement.inputs?.[toParsed.index];
|
|
2425
|
+
if (!fromPort || !toPort) return null;
|
|
2426
|
+
return [
|
|
2427
|
+
[fromParsed.elementId, fromParsed.index, fromPort.name],
|
|
2428
|
+
[toParsed.elementId, toParsed.index, toPort.name]
|
|
2429
|
+
];
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
console.warn(`Failed to parse port ID in connection: ${err}`);
|
|
2432
|
+
return null;
|
|
2433
|
+
}
|
|
2434
|
+
}).filter((conn) => conn !== null);
|
|
2435
|
+
if (connections.length > 0) {
|
|
2436
|
+
result.connections = connections;
|
|
2437
|
+
}
|
|
2438
|
+
if (this.editor) result.editor = this.editor;
|
|
2439
|
+
if (this.seed !== void 0) result.seed = this.seed;
|
|
2440
|
+
if (this.timeline) result.timeline = this.timeline;
|
|
2441
|
+
return result;
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Serialize a node with v0.2 source information
|
|
2445
|
+
*/
|
|
2446
|
+
nodeToJSON(node) {
|
|
2447
|
+
const nodeAny = node;
|
|
2448
|
+
const fullType = nodeAny.modulePath || (node.type.includes(".") ? node.type : `cascade.image.${node.type}`);
|
|
2449
|
+
let sourceType = nodeAny.sourceType;
|
|
2450
|
+
if (!sourceType) {
|
|
2451
|
+
if (isStandardLibraryNode(fullType)) {
|
|
2452
|
+
sourceType = "stdlib";
|
|
2453
|
+
} else if (fullType.startsWith("local.")) {
|
|
2454
|
+
sourceType = "embedded";
|
|
2455
|
+
} else {
|
|
2456
|
+
sourceType = this.moduleResolver.getSourceType(fullType) || "embedded";
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
const result = {
|
|
2460
|
+
id: node.id,
|
|
2461
|
+
module: fullType,
|
|
2462
|
+
position: [node.position.x, node.position.y],
|
|
2463
|
+
source: sourceType
|
|
2464
|
+
};
|
|
2465
|
+
if (sourceType === "project") {
|
|
2466
|
+
const config = this.moduleResolver.exportConfig();
|
|
2467
|
+
const external = config.externalModules[fullType];
|
|
2468
|
+
if (external) {
|
|
2469
|
+
result.file = external.file;
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
if (result.source === "embedded" && node.code) {
|
|
2473
|
+
result.code = node.code;
|
|
2474
|
+
}
|
|
2475
|
+
if (node.comment && node.comment.trim()) {
|
|
2476
|
+
result.comment = node.comment;
|
|
2477
|
+
}
|
|
2478
|
+
if (node.color) {
|
|
2479
|
+
result.color = node.color;
|
|
2480
|
+
}
|
|
2481
|
+
if (node.parent) {
|
|
2482
|
+
result.parent = node.parent.id;
|
|
2483
|
+
}
|
|
2484
|
+
const rawParameterValue = (p) => node.rawParameterValue(p.name);
|
|
2485
|
+
const changedParameters = (node.parameters ?? []).filter(
|
|
2486
|
+
(p) => p.promoted || node.preservesPlainProp(p.name) || JSON.stringify(rawParameterValue(p)) !== JSON.stringify(p.defaultValue)
|
|
2487
|
+
);
|
|
2488
|
+
const parameters = changedParameters.filter((p) => p.documentField !== "props").map((p) => p.promoted ? { name: p.name, value: rawParameterValue(p), promoted: true } : { name: p.name, value: rawParameterValue(p) });
|
|
2489
|
+
if (parameters.length > 0) {
|
|
2490
|
+
result.params = parameters;
|
|
2491
|
+
}
|
|
2492
|
+
if (node.promotions?.length) {
|
|
2493
|
+
result.promotions = node.promotions.map((entry) => ({
|
|
2494
|
+
nodeId: entry.nodeId,
|
|
2495
|
+
param: entry.param,
|
|
2496
|
+
...entry.label ? { label: entry.label } : {}
|
|
2497
|
+
}));
|
|
2498
|
+
}
|
|
2499
|
+
const inputValues = node.inputs.filter((port) => (port.connections?.length ?? 0) === 0).filter((port) => port.value !== void 0 && port.value !== null).filter((port) => typeof port.value !== "function").map((port) => ({ name: port.name, defaultValue: port.value, dataType: port.dataType })).filter((port) => port.defaultValue !== "" || port.dataType === "string");
|
|
2500
|
+
if (inputValues.length > 0) {
|
|
2501
|
+
result.inputs = inputValues;
|
|
2502
|
+
}
|
|
2503
|
+
const props = Object.entries(node.props).reduce((acc, [key, prop]) => {
|
|
2504
|
+
const keyed = (prop.channel?.keys?.length ?? 0) > 0;
|
|
2505
|
+
if (prop.fromParameter && !prop.expression && !keyed) return acc;
|
|
2506
|
+
let value = prop.value;
|
|
2507
|
+
if (prop.type === "color" && isColorValue(value)) {
|
|
2508
|
+
const normalized = normalizeColor(value);
|
|
2509
|
+
value = [normalized.r, normalized.g, normalized.b, normalized.a ?? 1];
|
|
2510
|
+
}
|
|
2511
|
+
acc[key] = node.serializePropValue(key, value);
|
|
2512
|
+
return acc;
|
|
2513
|
+
}, {});
|
|
2514
|
+
for (const parameter of changedParameters) {
|
|
2515
|
+
if (parameter.documentField !== "props") continue;
|
|
2516
|
+
props[parameter.name] = node.serializePropValue(
|
|
2517
|
+
parameter.name,
|
|
2518
|
+
node.rawParameterValue(parameter.name)
|
|
2519
|
+
);
|
|
2520
|
+
}
|
|
2521
|
+
if (Object.keys(props).length > 0) {
|
|
2522
|
+
result.props = props;
|
|
2523
|
+
}
|
|
2524
|
+
if (node.bypass) {
|
|
2525
|
+
result.bypass = true;
|
|
2526
|
+
}
|
|
2527
|
+
if (node.cook) {
|
|
2528
|
+
result.cook = true;
|
|
2529
|
+
}
|
|
2530
|
+
if (typeof node.serialize === "function") {
|
|
2531
|
+
const nodeState = node.serialize();
|
|
2532
|
+
if (nodeState && Object.keys(nodeState).length > 0) {
|
|
2533
|
+
result.state = nodeState;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
return result;
|
|
2537
|
+
}
|
|
2538
|
+
static fromJSON(json, assetManager, packageManager) {
|
|
2539
|
+
const graph = new _Graph(assetManager, packageManager);
|
|
2540
|
+
const version = json.version || "0.1";
|
|
2541
|
+
if (typeof json.metadata?.created === "string") graph.createdAt = json.metadata.created;
|
|
2542
|
+
if (json.editor && typeof json.editor === "object") graph.editor = json.editor;
|
|
2543
|
+
if (Number.isInteger(json.seed)) graph.seed = json.seed;
|
|
2544
|
+
if (json.timeline && typeof json.timeline === "object") graph.timeline = json.timeline;
|
|
2545
|
+
graph.project = {
|
|
2546
|
+
...graph.project,
|
|
2547
|
+
...typeof json.metadata?.name === "string" ? { name: json.metadata.name } : {},
|
|
2548
|
+
...typeof json.metadata?.description === "string" ? { description: json.metadata.description } : {},
|
|
2549
|
+
...typeof json.metadata?.author === "string" ? { author: json.metadata.author } : {}
|
|
2550
|
+
};
|
|
2551
|
+
if (json.project) {
|
|
2552
|
+
graph.project = {
|
|
2553
|
+
...graph.project,
|
|
2554
|
+
name: json.metadata?.name ?? graph.project.name,
|
|
2555
|
+
description: json.metadata?.description ?? graph.project.description,
|
|
2556
|
+
author: json.metadata?.author ?? graph.project.author,
|
|
2557
|
+
packages: json.project.packages || []
|
|
2558
|
+
};
|
|
2559
|
+
json.project.packages?.forEach((pkg) => {
|
|
2560
|
+
graph.moduleResolver.addProjectPackage(pkg);
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
if (json.embeddedModules) {
|
|
2564
|
+
graph.moduleResolver.importConfig({
|
|
2565
|
+
embeddedModules: json.embeddedModules
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
if (json.externalModules) {
|
|
2569
|
+
graph.moduleResolver.importConfig({
|
|
2570
|
+
externalModules: json.externalModules
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
if (json.packages && Array.isArray(json.packages)) {
|
|
2574
|
+
}
|
|
2575
|
+
json.nodes.forEach((nodeData) => {
|
|
2576
|
+
let nodeId = nodeData.id;
|
|
2577
|
+
if (!nodeId) {
|
|
2578
|
+
const baseId = nodeData.name || nodeData.type || nodeData.module;
|
|
2579
|
+
nodeId = graph.generateUniqueNodeId(baseId);
|
|
2580
|
+
} else {
|
|
2581
|
+
let counter = 1;
|
|
2582
|
+
let candidateId = nodeId;
|
|
2583
|
+
while (graph.elements.some((e) => e.id === candidateId)) {
|
|
2584
|
+
counter++;
|
|
2585
|
+
candidateId = `${nodeId}${counter}`;
|
|
2586
|
+
}
|
|
2587
|
+
nodeId = candidateId;
|
|
2588
|
+
}
|
|
2589
|
+
const nodeType = nodeData.module || nodeData.type;
|
|
2590
|
+
if (!nodeType || !nodeType.includes(".")) {
|
|
2591
|
+
console.warn(`Invalid node type format: ${nodeType}. Expected package path (e.g., "cascade.image.Color"). Skipping node.`);
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
const shortType = packagePathToType(nodeType);
|
|
2595
|
+
const source = nodeData.source || (isStandardLibraryNode(nodeType) ? "stdlib" : "embedded");
|
|
2596
|
+
let nodeCode = "";
|
|
2597
|
+
if (source === "embedded") {
|
|
2598
|
+
const embeddedModule = json.embeddedModules?.[nodeType];
|
|
2599
|
+
nodeCode = embeddedModule?.code || nodeData.code || "";
|
|
2600
|
+
} else if (source === "project") {
|
|
2601
|
+
const externalModule = json.externalModules?.[nodeType];
|
|
2602
|
+
nodeCode = externalModule?.cachedCode || nodeData.code || "";
|
|
2603
|
+
}
|
|
2604
|
+
const NodeClass = getNodeClass(nodeType);
|
|
2605
|
+
let node;
|
|
2606
|
+
if (NodeClass) {
|
|
2607
|
+
node = new NodeClass(nodeId, graph);
|
|
2608
|
+
} else if (nodeType.startsWith("cascade.")) {
|
|
2609
|
+
throw new Error(`Unknown Cascade node type: ${nodeType}`);
|
|
2610
|
+
} else {
|
|
2611
|
+
node = new Node(nodeId, shortType, graph);
|
|
2612
|
+
}
|
|
2613
|
+
node.modulePath = nodeType;
|
|
2614
|
+
node.sourceType = source;
|
|
2615
|
+
if (Array.isArray(nodeData.position)) {
|
|
2616
|
+
node.position = { x: nodeData.position[0] || 0, y: nodeData.position[1] || 0 };
|
|
2617
|
+
} else if (nodeData.position && typeof nodeData.position === "object") {
|
|
2618
|
+
node.position = { x: nodeData.position.x || 0, y: nodeData.position.y || 0 };
|
|
2619
|
+
} else {
|
|
2620
|
+
node.position = { x: 0, y: 0 };
|
|
2621
|
+
}
|
|
2622
|
+
node.code = nodeCode;
|
|
2623
|
+
node.comment = nodeData.comment || "";
|
|
2624
|
+
if (nodeData.color) node.color = nodeData.color;
|
|
2625
|
+
if (Array.isArray(nodeData.params)) {
|
|
2626
|
+
nodeData.params.forEach((saved) => {
|
|
2627
|
+
if (!saved?.name) return;
|
|
2628
|
+
const declared = node.parameters.find((parameter) => parameter.name === saved.name);
|
|
2629
|
+
if (declared) {
|
|
2630
|
+
declared.value = saved.value;
|
|
2631
|
+
declared.promoted = Boolean(saved.promoted);
|
|
2632
|
+
} else {
|
|
2633
|
+
node.parameters.push({
|
|
2634
|
+
name: saved.name,
|
|
2635
|
+
value: saved.value,
|
|
2636
|
+
defaultValue: saved.value,
|
|
2637
|
+
dataType: "any",
|
|
2638
|
+
promoted: Boolean(saved.promoted),
|
|
2639
|
+
options: {}
|
|
2640
|
+
});
|
|
2641
|
+
}
|
|
2642
|
+
if (saved.promoted) {
|
|
2643
|
+
const port = node.in(saved.name, saved.value);
|
|
2644
|
+
port.fromParameter = saved.name;
|
|
2645
|
+
}
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
if (Array.isArray(nodeData.promotions)) {
|
|
2649
|
+
node.promotions = nodeData.promotions.filter((entry) => entry?.nodeId && entry?.param).map((entry) => ({
|
|
2650
|
+
nodeId: String(entry.nodeId),
|
|
2651
|
+
param: String(entry.param),
|
|
2652
|
+
...entry.label ? { label: String(entry.label) } : {}
|
|
2653
|
+
}));
|
|
2654
|
+
}
|
|
2655
|
+
graph.addElement(node);
|
|
2656
|
+
const restoreInput = (name, value, hasValue, saved = {}) => {
|
|
2657
|
+
if (!name) return;
|
|
2658
|
+
const declared = node.inputs.find((port2) => port2.name === name);
|
|
2659
|
+
const port = declared ?? node.in(
|
|
2660
|
+
name,
|
|
2661
|
+
hasValue ? value : void 0,
|
|
2662
|
+
saved.dataType ? { type: saved.dataType } : {}
|
|
2663
|
+
);
|
|
2664
|
+
if (declared && hasValue) declared.value = value;
|
|
2665
|
+
if (saved.id && port.id !== saved.id) port.id = saved.id;
|
|
2666
|
+
};
|
|
2667
|
+
if (Array.isArray(nodeData.inputs)) {
|
|
2668
|
+
nodeData.inputs.forEach((saved) => {
|
|
2669
|
+
if (!saved || typeof saved !== "object") return;
|
|
2670
|
+
const hasDefault = Object.prototype.hasOwnProperty.call(saved, "defaultValue");
|
|
2671
|
+
const hasValue = Object.prototype.hasOwnProperty.call(saved, "value");
|
|
2672
|
+
restoreInput(
|
|
2673
|
+
saved.name,
|
|
2674
|
+
hasDefault ? saved.defaultValue : saved.value,
|
|
2675
|
+
hasDefault || hasValue,
|
|
2676
|
+
saved
|
|
2677
|
+
);
|
|
2678
|
+
});
|
|
2679
|
+
} else if (nodeData.inputs && typeof nodeData.inputs === "object") {
|
|
2680
|
+
Object.entries(nodeData.inputs).forEach(([name, value]) => {
|
|
2681
|
+
restoreInput(name, value, true);
|
|
2682
|
+
});
|
|
2683
|
+
}
|
|
2684
|
+
if (nodeData.outputs && Array.isArray(nodeData.outputs)) {
|
|
2685
|
+
nodeData.outputs.forEach((portData) => {
|
|
2686
|
+
const port = node.out(portData.name, portData.portType || "param");
|
|
2687
|
+
if (portData.id && port.id !== portData.id) {
|
|
2688
|
+
port.id = portData.id;
|
|
2689
|
+
}
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
2692
|
+
if (nodeData.props) {
|
|
2693
|
+
Object.entries(nodeData.props).forEach(([key, propData]) => {
|
|
2694
|
+
let value;
|
|
2695
|
+
let expression;
|
|
2696
|
+
if (propData && typeof propData === "object" && "value" in propData) {
|
|
2697
|
+
value = propData.value;
|
|
2698
|
+
expression = propData.expression;
|
|
2699
|
+
} else {
|
|
2700
|
+
value = propData;
|
|
2701
|
+
}
|
|
2702
|
+
let normalizedValue = value;
|
|
2703
|
+
const declaredColor = node.props[key]?.type === "color";
|
|
2704
|
+
if (isColorValue(value) && (declaredColor || !Array.isArray(value))) {
|
|
2705
|
+
const normalized = normalizeColor(value);
|
|
2706
|
+
normalizedValue = normalized.a !== void 0 && normalized.a !== 1 ? { r: normalized.r, g: normalized.g, b: normalized.b, a: normalized.a } : { r: normalized.r, g: normalized.g, b: normalized.b };
|
|
2707
|
+
}
|
|
2708
|
+
const definitionProp = node.parameters.find(
|
|
2709
|
+
(parameter) => parameter.name === key && parameter.documentField === "props"
|
|
2710
|
+
);
|
|
2711
|
+
if (definitionProp) {
|
|
2712
|
+
definitionProp.value = value;
|
|
2713
|
+
} else if (!node.props[key]) {
|
|
2714
|
+
node.props[key] = { value: normalizedValue };
|
|
2715
|
+
} else {
|
|
2716
|
+
node.props[key].value = normalizedValue;
|
|
2717
|
+
}
|
|
2718
|
+
if (expression && node.props[key]) {
|
|
2719
|
+
node.props[key].expression = expression;
|
|
2720
|
+
} else {
|
|
2721
|
+
node.preservePlainProp(key);
|
|
2722
|
+
}
|
|
2723
|
+
if (!expression && node.props[key]?.expression) {
|
|
2724
|
+
delete node.props[key].expression;
|
|
2725
|
+
node.props[key] = { ...node.props[key] };
|
|
2726
|
+
}
|
|
2727
|
+
if (propData?.channel && node.props[key]) {
|
|
2728
|
+
node.restorePropChannel(key, propData.channel);
|
|
2729
|
+
}
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
if (nodeData.bypass !== void 0) {
|
|
2733
|
+
node.setBypass(nodeData.bypass);
|
|
2734
|
+
}
|
|
2735
|
+
if (nodeData.cook !== void 0) {
|
|
2736
|
+
node.setCook(nodeData.cook);
|
|
2737
|
+
}
|
|
2738
|
+
if (!NodeClass && (source === "project" || node.code)) {
|
|
2739
|
+
const modulePromise = source === "project" ? loadProjectModule(nodeType) : loadEmbeddedModule(node.code);
|
|
2740
|
+
node.setFunction(moduleNodeFunction(node, nodeType, modulePromise));
|
|
2741
|
+
modulePromise.catch((err) => {
|
|
2742
|
+
console.warn("Failed to compile node " + node.id + ":", err);
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
if (nodeData.state && typeof node.deserialize === "function") {
|
|
2746
|
+
try {
|
|
2747
|
+
node.deserialize(nodeData.state);
|
|
2748
|
+
} catch (err) {
|
|
2749
|
+
console.warn("Failed to deserialize state for node " + node.id + ":", err);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
});
|
|
2753
|
+
const connectionsToRestore = json.connections || [];
|
|
2754
|
+
if (json.annotations && Array.isArray(json.annotations)) {
|
|
2755
|
+
const annotations = json.annotations.map((annData) => {
|
|
2756
|
+
let annotation;
|
|
2757
|
+
const position = Array.isArray(annData.position) ? { x: annData.position[0] || 0, y: annData.position[1] || 0 } : { x: annData.position?.x || 0, y: annData.position?.y || 0 };
|
|
2758
|
+
const normalizedType = annData.type.charAt(0).toUpperCase() + annData.type.slice(1).toLowerCase();
|
|
2759
|
+
switch (normalizedType) {
|
|
2760
|
+
case "Image":
|
|
2761
|
+
annotation = new ImageAnnotation(annData.id, graph);
|
|
2762
|
+
annotation.src = annData.src;
|
|
2763
|
+
break;
|
|
2764
|
+
case "Text":
|
|
2765
|
+
annotation = new TextAnnotation(annData.id, graph);
|
|
2766
|
+
annotation.content = annData.content;
|
|
2767
|
+
break;
|
|
2768
|
+
case "Group":
|
|
2769
|
+
annotation = new GroupAnnotation(annData.id, graph);
|
|
2770
|
+
annotation.content = annData.content;
|
|
2771
|
+
break;
|
|
2772
|
+
case "Line":
|
|
2773
|
+
annotation = new LineAnnotation(annData.id, graph);
|
|
2774
|
+
if (annData.endPosition) {
|
|
2775
|
+
annotation.endPosition = Array.isArray(annData.endPosition) ? { x: annData.endPosition[0] || 0, y: annData.endPosition[1] || 0 } : { x: annData.endPosition.x || 0, y: annData.endPosition.y || 0 };
|
|
2776
|
+
}
|
|
2777
|
+
break;
|
|
2778
|
+
case "Polyline":
|
|
2779
|
+
annotation = new PolylineAnnotation(annData.id, graph);
|
|
2780
|
+
if (annData.points) {
|
|
2781
|
+
annotation.points = annData.points.map(
|
|
2782
|
+
(p) => Array.isArray(p) ? { x: p[0] || 0, y: p[1] || 0 } : { x: p.x || 0, y: p.y || 0 }
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
break;
|
|
2786
|
+
default:
|
|
2787
|
+
annotation = new Annotation(annData.id, annData.type, graph);
|
|
2788
|
+
}
|
|
2789
|
+
annotation.position = position;
|
|
2790
|
+
if (annData.size) annotation.size = { width: annData.size.width, height: annData.size.height };
|
|
2791
|
+
if (annData.style) annotation.style = { ...annData.style };
|
|
2792
|
+
if (annData.caption !== void 0) annotation.caption = annData.caption;
|
|
2793
|
+
if (annData.containedElements) annotation.containedElements = annData.containedElements;
|
|
2794
|
+
return annotation;
|
|
2795
|
+
});
|
|
2796
|
+
annotations.forEach((ann) => graph.addElement(ann));
|
|
2797
|
+
graph._annotationPortsInitialized = Promise.all(
|
|
2798
|
+
annotations.map((ann) => graph.initializeAnnotationPorts(ann))
|
|
2799
|
+
).catch((err) => {
|
|
2800
|
+
console.error("Failed to initialize some annotation ports:", err);
|
|
2801
|
+
});
|
|
2802
|
+
} else {
|
|
2803
|
+
graph._annotationPortsInitialized = Promise.resolve();
|
|
2804
|
+
}
|
|
2805
|
+
const authoredElements = [
|
|
2806
|
+
...Array.isArray(json.nodes) ? json.nodes : [],
|
|
2807
|
+
...Array.isArray(json.annotations) ? json.annotations : []
|
|
2808
|
+
];
|
|
2809
|
+
graph.restoringHierarchy = true;
|
|
2810
|
+
for (const elementData of authoredElements) {
|
|
2811
|
+
if (typeof elementData?.parent !== "string") continue;
|
|
2812
|
+
const child = graph.getElement(elementData.id);
|
|
2813
|
+
const parent = graph.getElement(elementData.parent);
|
|
2814
|
+
const warn = (reason) => console.warn(
|
|
2815
|
+
`Parent link for "${elementData.id}" to "${elementData.parent}" ignored: ${reason}; element loaded at root.`
|
|
2816
|
+
);
|
|
2817
|
+
if (!child) continue;
|
|
2818
|
+
if (!parent) {
|
|
2819
|
+
warn("parent does not exist");
|
|
2820
|
+
continue;
|
|
2821
|
+
}
|
|
2822
|
+
if (child === parent) {
|
|
2823
|
+
warn("self-parenting is invalid");
|
|
2824
|
+
continue;
|
|
2825
|
+
}
|
|
2826
|
+
if (!parent.isNetwork()) {
|
|
2827
|
+
warn("parent is not a network");
|
|
2828
|
+
continue;
|
|
2829
|
+
}
|
|
2830
|
+
let ancestor = parent;
|
|
2831
|
+
let closesCycle = false;
|
|
2832
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2833
|
+
while (ancestor && !visited.has(ancestor)) {
|
|
2834
|
+
if (ancestor === child) {
|
|
2835
|
+
closesCycle = true;
|
|
2836
|
+
break;
|
|
2837
|
+
}
|
|
2838
|
+
visited.add(ancestor);
|
|
2839
|
+
ancestor = ancestor.parent;
|
|
2840
|
+
}
|
|
2841
|
+
if (closesCycle) {
|
|
2842
|
+
warn("parent cycle detected");
|
|
2843
|
+
continue;
|
|
2844
|
+
}
|
|
2845
|
+
graph.reparentElement(child, parent);
|
|
2846
|
+
}
|
|
2847
|
+
graph.restoringHierarchy = false;
|
|
2848
|
+
graph.pruneInvalidGroupMemberships();
|
|
2849
|
+
const depth = (node) => {
|
|
2850
|
+
let value = 0;
|
|
2851
|
+
let parent = node.parent;
|
|
2852
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2853
|
+
while (parent && !visited.has(parent)) {
|
|
2854
|
+
visited.add(parent);
|
|
2855
|
+
value++;
|
|
2856
|
+
parent = parent.parent;
|
|
2857
|
+
}
|
|
2858
|
+
return value;
|
|
2859
|
+
};
|
|
2860
|
+
graph.nodes.filter((node) => node.isNetwork()).sort((left, right) => depth(right) - depth(left)).forEach((node) => node.syncPorts?.());
|
|
2861
|
+
graph._connectionsToRestore = connectionsToRestore;
|
|
2862
|
+
connectionsToRestore.forEach((connData) => {
|
|
2863
|
+
graph.tryRestoreConnection(connData, false);
|
|
2864
|
+
});
|
|
2865
|
+
return graph;
|
|
2866
|
+
}
|
|
2867
|
+
async waitForAnnotationPorts() {
|
|
2868
|
+
const initPromise = this._annotationPortsInitialized;
|
|
2869
|
+
if (initPromise) {
|
|
2870
|
+
await initPromise;
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
/**
|
|
2874
|
+
* Try to restore a single connection from serialized data
|
|
2875
|
+
* @internal
|
|
2876
|
+
*/
|
|
2877
|
+
tryRestoreConnection(connData, checkDuplicates = false) {
|
|
2878
|
+
if (!Array.isArray(connData) || !Array.isArray(connData[0]) || !Array.isArray(connData[1])) {
|
|
2879
|
+
return false;
|
|
2880
|
+
}
|
|
2881
|
+
const [fromNodeId, fromPortIndex, fromPortName] = connData[0];
|
|
2882
|
+
const [toNodeId, toPortIndex, toPortName] = connData[1];
|
|
2883
|
+
if (typeof fromPortIndex !== "number" || typeof toPortIndex !== "number") {
|
|
2884
|
+
return false;
|
|
2885
|
+
}
|
|
2886
|
+
const fromElement = this.getElement(fromNodeId);
|
|
2887
|
+
const toElement = this.getElement(toNodeId);
|
|
2888
|
+
if (!fromElement || !toElement) return false;
|
|
2889
|
+
const byName = (ports, name) => typeof name === "string" ? ports?.find((p) => p.name === name) : void 0;
|
|
2890
|
+
const resolve = (ports, name, index) => typeof name === "string" ? byName(ports, name) : ports?.[index];
|
|
2891
|
+
const fromPort = resolve(fromElement.outputs, fromPortName, fromPortIndex);
|
|
2892
|
+
const toPort = resolve(toElement.inputs, toPortName, toPortIndex);
|
|
2893
|
+
if (!fromPort || !toPort) return false;
|
|
2894
|
+
if (checkDuplicates) {
|
|
2895
|
+
const exists = this.connections.some(
|
|
2896
|
+
(c) => c.from.nodeId === fromNodeId && c.from.portId === fromPort.id && c.to.nodeId === toNodeId && c.to.portId === toPort.id
|
|
2897
|
+
);
|
|
2898
|
+
if (exists) return true;
|
|
2899
|
+
}
|
|
2900
|
+
try {
|
|
2901
|
+
this.connect(fromPort, toPort);
|
|
2902
|
+
return true;
|
|
2903
|
+
} catch (err) {
|
|
2904
|
+
console.warn(`[connect] ${fromNodeId}[${fromPortIndex}] -> ${toNodeId}[${toPortIndex}] rejected:`, err);
|
|
2905
|
+
return false;
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
/**
|
|
2909
|
+
* Restore connections that were stored during fromJSON
|
|
2910
|
+
*/
|
|
2911
|
+
/**
|
|
2912
|
+
* Bind the connections a loaded file described, and KEEP the ones that could
|
|
2913
|
+
* not bind yet.
|
|
2914
|
+
*
|
|
2915
|
+
* A node's ports are created by running its code, so on load a connection
|
|
2916
|
+
* into a node that hasn't executed has no port to attach to. This used to
|
|
2917
|
+
* drop those connections on the floor — the pending list was deleted after
|
|
2918
|
+
* one pass — which meant a graph of code-defined nodes came up almost
|
|
2919
|
+
* entirely unwired, and nothing downstream of a source ever ran. Anything
|
|
2920
|
+
* still unresolved stays pending, so calling this again after another cook
|
|
2921
|
+
* finishes the job.
|
|
2922
|
+
*/
|
|
2923
|
+
/**
|
|
2924
|
+
* Node ids that a loaded file wires an input to, and whose connection has
|
|
2925
|
+
* not bound yet.
|
|
2926
|
+
*
|
|
2927
|
+
* The reason this is public: **an input that is about to be connected is not
|
|
2928
|
+
* an input that is missing**, and until now nothing could tell the two
|
|
2929
|
+
* apart. On load a node's ports only exist once its code has run, so the
|
|
2930
|
+
* first cook passes reach a node whose upstream edge has no port to attach
|
|
2931
|
+
* to — the node runs with `undefined` on that input, throws whatever its
|
|
2932
|
+
* author wrote for the unwired case, and a later pass then succeeds.
|
|
2933
|
+
*
|
|
2934
|
+
* The graph ends up correct and the log does not: those warm-up throws are
|
|
2935
|
+
* real errors from the node's point of view, they are recorded, and they
|
|
2936
|
+
* never clear. A user reported `volume-field` as broken on exactly this,
|
|
2937
|
+
* having verified from the cache files that the output was in fact right.
|
|
2938
|
+
*
|
|
2939
|
+
* So the scheduler asks this before cooking, and treats a node with a
|
|
2940
|
+
* pending inbound edge as *not ready* rather than as failed.
|
|
2941
|
+
*/
|
|
2942
|
+
pendingConnectionTargets() {
|
|
2943
|
+
const pending = this._connectionsToRestore;
|
|
2944
|
+
const targets = /* @__PURE__ */ new Set();
|
|
2945
|
+
if (this.connectionsSettled) return targets;
|
|
2946
|
+
if (!Array.isArray(pending)) return targets;
|
|
2947
|
+
for (const connection of pending) {
|
|
2948
|
+
const target = Array.isArray(connection?.[1]) ? connection[1][0] : void 0;
|
|
2949
|
+
if (typeof target === "string") targets.add(target);
|
|
2950
|
+
}
|
|
2951
|
+
return targets;
|
|
2952
|
+
}
|
|
2953
|
+
/** Called when the warm-up passes stop making progress. */
|
|
2954
|
+
markConnectionsSettled() {
|
|
2955
|
+
this.connectionsSettled = true;
|
|
2956
|
+
}
|
|
2957
|
+
restoreConnections() {
|
|
2958
|
+
const connectionsToRestore = this._connectionsToRestore || [];
|
|
2959
|
+
if (connectionsToRestore.length === 0) return;
|
|
2960
|
+
const stillPending = connectionsToRestore.filter(
|
|
2961
|
+
(connData) => !this.tryRestoreConnection(connData, true)
|
|
2962
|
+
);
|
|
2963
|
+
if (stillPending.length < connectionsToRestore.length) this.connectionsSettled = false;
|
|
2964
|
+
if (stillPending.length > 0) {
|
|
2965
|
+
this._connectionsToRestore = stillPending;
|
|
2966
|
+
} else {
|
|
2967
|
+
delete this._connectionsToRestore;
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
};
|
|
2971
|
+
export {
|
|
2972
|
+
GRAPH_FORMAT_VERSION,
|
|
2973
|
+
Graph
|
|
2974
|
+
};
|
|
2975
|
+
//# sourceMappingURL=Graph-ZJJTRQTM.js.map
|