@field/cascade 0.8.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.
Files changed (195) hide show
  1. package/AGENTS.md +36 -3
  2. package/CHANGELOG.md +401 -0
  3. package/DESIGN.md +14 -0
  4. package/README.md +41 -26
  5. package/dist/assets/CodeEditor-4O7FQ__R.js +623 -0
  6. package/dist/assets/CodePanel-BU-autmq.js +2 -0
  7. package/dist/assets/{CustomNodeDialog-BQbEhIwL.js → CustomNodeDialog-BRpDfOV5.js} +1 -1
  8. package/dist/assets/OpenGraphDialog-BwbRlC0X.js +1 -0
  9. package/dist/assets/ProjectSettings-DLOXLxcA.js +2 -0
  10. package/dist/assets/SettingsDialog-Bz8JxISH.js +1 -0
  11. package/dist/assets/{VersionHistoryDialog-D6VBzSQ0.js → VersionHistoryDialog-D5Z0nJ5-.js} +2 -2
  12. package/dist/assets/{core-BInLG4gw.js → core-Yqi0Ni61.js} +8 -8
  13. package/dist/assets/{cssMode-DfR2qmYG.js → cssMode-CxUzc4vp.js} +1 -1
  14. package/dist/assets/definitions-GreEMXLV.js +1 -0
  15. package/dist/assets/{editor.api-Ck8_vgQV.js → editor.api-BNFb25CX.js} +1 -1
  16. package/dist/assets/{freemarker2-fVn10Wn3.js → freemarker2-0ZDnWxeM.js} +1 -1
  17. package/dist/assets/geo-u0n_jOsD.js +1 -0
  18. package/dist/assets/{handlebars-BCSchLT2.js → handlebars-Cy18MoaD.js} +1 -1
  19. package/dist/assets/{html-CVpSv_k9.js → html-B-2Dx7nq.js} +1 -1
  20. package/dist/assets/{htmlMode-Da4ESTv0.js → htmlMode-DUAc9B_d.js} +1 -1
  21. package/dist/assets/{image-BLn--oSF.js → image-cAa-Nu9O.js} +3 -3
  22. package/dist/assets/index-5ZtDAuy2.js +220 -0
  23. package/dist/assets/index-7vae6YFC.css +1 -0
  24. package/dist/assets/index-client-DqYmKstU.js +1 -0
  25. package/dist/assets/{javascript-BpX0Gvcf.js → javascript-DeQsDqP5.js} +1 -1
  26. package/dist/assets/{jsonMode-CsLqaou7.js → jsonMode-BiFwJVSk.js} +1 -1
  27. package/dist/assets/legacy-CTEeXeqF.js +2 -0
  28. package/dist/assets/{liquid-efUz51Wf.js → liquid-BLoHH68a.js} +1 -1
  29. package/dist/assets/{lspLanguageFeatures-DCi5-K0n.js → lspLanguageFeatures-CNFgm1Kv.js} +1 -1
  30. package/dist/assets/math-DioIQOPJ.js +1 -0
  31. package/dist/assets/{mdx-DP_sjGVy.js → mdx-CFXQVNfs.js} +1 -1
  32. package/dist/assets/pop-DC_TQG9t.js +1 -0
  33. package/dist/assets/{python-aAuP3LUi.js → python-Cf7S7p2E.js} +1 -1
  34. package/dist/assets/{razor-7M6sMmC5.js → razor-CMHBcG-g.js} +1 -1
  35. package/dist/assets/studioScenes-8sq9FfFg.js +1 -0
  36. package/dist/assets/{tsMode-Bf4CnoYK.js → tsMode-BEih0GDj.js} +1 -1
  37. package/dist/assets/{typescript-ddhYBGyT.js → typescript-COEKYD_a.js} +1 -1
  38. package/dist/assets/{workers-X5la8DDT.js → workers-BNhAhMNn.js} +1 -1
  39. package/dist/assets/{xml-BzbXa3Ps.js → xml-CPiMpnPl.js} +1 -1
  40. package/dist/assets/{yaml-CXs-VzxL.js → yaml-Bgp1ITLR.js} +1 -1
  41. package/dist/cli/Graph-ZJJTRQTM.js +2975 -0
  42. package/dist/cli/chunk-2KXB47FP.js +115 -0
  43. package/dist/cli/chunk-35D343DC.js +1447 -0
  44. package/dist/cli/chunk-36P5K3SD.js +631 -0
  45. package/dist/cli/chunk-3JTJHF7V.js +267 -0
  46. package/dist/cli/chunk-4UDFYF5G.js +132 -0
  47. package/dist/cli/chunk-53NMBJAO.js +35 -0
  48. package/dist/cli/chunk-A4OAMOZH.js +310 -0
  49. package/dist/cli/chunk-BIJO5OFS.js +71 -0
  50. package/dist/cli/chunk-BP7UKF36.js +376 -0
  51. package/dist/cli/chunk-CKS2FQWR.js +89 -0
  52. package/dist/cli/chunk-CO22XHNG.js +593 -0
  53. package/dist/cli/chunk-D6BUOHUG.js +634 -0
  54. package/dist/cli/chunk-DEQSEFFH.js +153 -0
  55. package/dist/cli/chunk-DUIUFVU2.js +241 -0
  56. package/dist/cli/chunk-EUIAC3U3.js +3006 -0
  57. package/dist/cli/chunk-FCDXJ6EK.js +2208 -0
  58. package/dist/cli/chunk-GFS6W3C5.js +308 -0
  59. package/dist/cli/chunk-HFHWYL55.js +139 -0
  60. package/dist/cli/chunk-HSNOBIVT.js +263 -0
  61. package/dist/cli/chunk-ICH7N5PN.js +26 -0
  62. package/dist/cli/chunk-J7VK7MNF.js +418 -0
  63. package/dist/cli/chunk-KH4MUBKF.js +1551 -0
  64. package/dist/cli/chunk-MIIEFUGG.js +623 -0
  65. package/dist/cli/chunk-OWWBYLHC.js +136 -0
  66. package/dist/cli/chunk-POMMNKZZ.js +672 -0
  67. package/dist/cli/chunk-QBBKRNGY.js +90 -0
  68. package/dist/cli/chunk-QQPA2ZHH.js +273 -0
  69. package/dist/cli/chunk-RLCAJBVR.js +224 -0
  70. package/dist/cli/chunk-RQZ4SX5R.js +1125 -0
  71. package/dist/cli/chunk-RUU3YWXU.js +99 -0
  72. package/dist/cli/chunk-RXDIMXN5.js +98 -0
  73. package/dist/cli/chunk-SGJAET2L.js +197 -0
  74. package/dist/cli/chunk-VVGSLVSF.js +151 -0
  75. package/dist/cli/chunk-WD62JIP6.js +118 -0
  76. package/dist/cli/chunk-WIH4TEIS.js +104 -0
  77. package/dist/cli/chunk-WKURDVYP.js +1119 -0
  78. package/dist/cli/chunk-WPUDGEFY.js +20 -0
  79. package/dist/cli/chunk-XWDSKG7N.js +1301 -0
  80. package/dist/cli/chunk-YAT5Y25U.js +121 -0
  81. package/dist/cli/chunk-ZL4JTG5Y.js +37 -0
  82. package/dist/cli/cliCommands-UQSFGEHF.js +3464 -0
  83. package/dist/cli/commands-Q23M26QM.js +1292 -0
  84. package/dist/cli/definitions-HF2FTHYU.js +32 -0
  85. package/dist/cli/doctorCommand-B7BPQMN6.js +76 -0
  86. package/dist/cli/edit-JHCPTQFT.js +28 -0
  87. package/dist/cli/editorCommands-UIF5YLUZ.js +33 -0
  88. package/dist/cli/encode-HAUZTYPT.js +17 -0
  89. package/dist/cli/frames-LSAUBZ7P.js +16 -0
  90. package/dist/cli/geo-4RNK3QVN.js +43 -0
  91. package/dist/cli/graphCommands-6BMAKGQE.js +184 -0
  92. package/dist/cli/graphFile-VPSQ6OF7.js +286 -0
  93. package/dist/cli/headlessCanvas-5XLDZT7M.js +15 -0
  94. package/dist/cli/helpCommand-3VRE3KVO.js +155 -0
  95. package/dist/cli/index.js +106 -14207
  96. package/dist/cli/math-ZRMLKU2D.js +16 -0
  97. package/dist/cli/mcp-ZLEEMYGF.js +164 -0
  98. package/dist/cli/parity-KINARCP2.js +328 -0
  99. package/dist/cli/pop-USDFXB7H.js +39 -0
  100. package/dist/cli/project-K3QIPGBB.js +12 -0
  101. package/dist/cli/projectDefinition-DMTZN2NE.js +27 -0
  102. package/dist/cli/projectRuntime-Z5PJOH47.js +48 -0
  103. package/dist/cli/registerStandardNodes-S2VLMF6J.js +3081 -0
  104. package/dist/cli/render-YURAPPUI.js +436 -0
  105. package/dist/cli/runCommand-PI764LEG.js +198 -0
  106. package/dist/cli/runner-RA6UKEPD.js +37 -0
  107. package/dist/cli/runtime/image.ts +277 -0
  108. package/dist/cli/runtime/stage.ts +74 -11
  109. package/dist/cli/stageRunner-KUNIMQKJ.js +10 -0
  110. package/dist/cli/verbs-RCE3K4VQ.js +19 -0
  111. package/dist/cli/webBuild-GT4PZT5D.js +37 -0
  112. package/dist/contracts/bundle.d.ts +75 -0
  113. package/dist/contracts/bundle.js +112 -0
  114. package/dist/contracts/definition.d.ts +67 -4
  115. package/dist/contracts/document.d.ts +29 -0
  116. package/dist/contracts/documentFormat.d.ts +18 -0
  117. package/dist/contracts/documentFormat.js +195 -0
  118. package/dist/contracts/editor.d.ts +189 -0
  119. package/dist/contracts/editor.js +2 -0
  120. package/dist/contracts/index.d.ts +3 -0
  121. package/dist/contracts/index.js +3 -0
  122. package/dist/contracts/validate.js +59 -3
  123. package/dist/contracts/values.d.ts +3 -3
  124. package/dist/contracts/values.js +1 -0
  125. package/dist/image.d.ts +141 -0
  126. package/dist/image.js +127 -0
  127. package/dist/index.html +4 -4
  128. package/dist/player/app.js +17 -5
  129. package/dist/player/editor.js +1919 -0
  130. package/dist/player/editorEngine.js +477 -0
  131. package/dist/runtime/builtins/core/bundle.d.ts +70 -0
  132. package/dist/runtime/builtins/core/bundle.js +81 -0
  133. package/dist/runtime/builtins/core/index.d.ts +3 -2
  134. package/dist/runtime/builtins/core/index.js +6 -2
  135. package/dist/runtime/builtins/core/routing.d.ts +27 -0
  136. package/dist/runtime/builtins/core/routing.js +28 -0
  137. package/dist/runtime/builtins/headless.d.ts +19 -0
  138. package/dist/runtime/builtins/headless.js +25 -0
  139. package/dist/runtime/builtins/image/contactSheet.d.ts +226 -0
  140. package/dist/runtime/builtins/image/contactSheet.js +199 -0
  141. package/dist/runtime/builtins/image/index.d.ts +9 -0
  142. package/dist/runtime/builtins/image/index.js +9 -0
  143. package/dist/runtime/edit/document.d.ts +94 -0
  144. package/dist/runtime/edit/document.js +194 -0
  145. package/dist/runtime/edit/graphEditor.d.ts +223 -0
  146. package/dist/runtime/edit/graphEditor.js +893 -0
  147. package/dist/runtime/edit/index.d.ts +12 -0
  148. package/dist/runtime/edit/index.js +6 -0
  149. package/dist/runtime/edit/layout.d.ts +50 -0
  150. package/dist/runtime/edit/layout.js +126 -0
  151. package/dist/runtime/edit/operations.d.ts +65 -0
  152. package/dist/runtime/edit/operations.js +57 -0
  153. package/dist/runtime/edit/parameters.d.ts +75 -0
  154. package/dist/runtime/edit/parameters.js +284 -0
  155. package/dist/runtime/edit/read.d.ts +35 -0
  156. package/dist/runtime/edit/read.js +65 -0
  157. package/dist/runtime/editor/index.d.ts +104 -0
  158. package/dist/runtime/editor/index.js +439 -0
  159. package/dist/runtime/expressions/bundlePaths.d.ts +37 -0
  160. package/dist/runtime/expressions/bundlePaths.js +55 -0
  161. package/dist/runtime/expressions/index.d.ts +2 -0
  162. package/dist/runtime/expressions/index.js +1 -0
  163. package/dist/runtime/index.d.ts +2 -0
  164. package/dist/runtime/index.js +2 -0
  165. package/dist/runtime/params/animator.d.ts +5 -0
  166. package/dist/runtime/params/animator.js +16 -0
  167. package/dist/runtime/runtime.js +238 -12
  168. package/dist/runtime/seed.d.ts +29 -0
  169. package/dist/runtime/seed.js +52 -0
  170. package/dist/runtime/types.d.ts +12 -0
  171. package/dist/stage.js +43 -9
  172. package/dist/studio/panel.d.ts +2 -0
  173. package/doc/CLI_REFERENCE.md +171 -0
  174. package/doc/EDITOR_EXPORT.md +235 -0
  175. package/doc/HEADLESS_GPU.md +27 -0
  176. package/doc/MCP.md +69 -0
  177. package/doc/NODE_REFERENCE.md +208 -0
  178. package/doc/PROJECT_AUTHORING.md +258 -1
  179. package/package.json +29 -8
  180. package/dist/assets/CodeEditor-BEk-kIBd.js +0 -623
  181. package/dist/assets/CodePanel-CeSdjXkg.js +0 -2
  182. package/dist/assets/ExportDialog-CjTbbTZJ.js +0 -358
  183. package/dist/assets/ExportDialog-DOq0flMV.css +0 -1
  184. package/dist/assets/OpenGraphDialog-DCwH9itM.js +0 -1
  185. package/dist/assets/ProjectSettings-Dg7M5xt9.js +0 -2
  186. package/dist/assets/SettingsDialog-e7GgsjB9.js +0 -1
  187. package/dist/assets/definitions-DZpOjWKR.js +0 -1
  188. package/dist/assets/geo-D2VPjAdt.js +0 -1
  189. package/dist/assets/index-CMiBdEuT.js +0 -212
  190. package/dist/assets/index-DMIuml9a.css +0 -1
  191. package/dist/assets/index-client-BI2H82FJ.js +0 -1
  192. package/dist/assets/legacy-ZA7textG.js +0 -2
  193. package/dist/assets/math-B6hpxPJO.js +0 -1
  194. package/dist/assets/pop-CYUIjT6x.js +0 -1
  195. package/dist/assets/projectSettingsApi-C1WvIwfP.js +0 -1
@@ -0,0 +1,2208 @@
1
+ import {
2
+ expressionEngine
3
+ } from "./chunk-RUU3YWXU.js";
4
+ import {
5
+ resolvePropBinding
6
+ } from "./chunk-HFHWYL55.js";
7
+ import {
8
+ bundleFieldReferences,
9
+ deleteKey,
10
+ deserializeChannel,
11
+ isEmptyChannel,
12
+ serializeChannel,
13
+ setKey
14
+ } from "./chunk-CO22XHNG.js";
15
+ import {
16
+ CORE_TYPES,
17
+ canConnectTypes,
18
+ normalizeCascadeType
19
+ } from "./chunk-BIJO5OFS.js";
20
+
21
+ // src/utils/nodeTypeUtils.ts
22
+ var nodeClassRegistries = /* @__PURE__ */ new Map();
23
+ var nodeMetadataRegistry = /* @__PURE__ */ new Map();
24
+ function registerNodeMetadata(metadata) {
25
+ nodeMetadataRegistry.set(metadata.type, metadata);
26
+ }
27
+ function getNodeMetadata(type) {
28
+ if (nodeMetadataRegistry.has(type)) {
29
+ return nodeMetadataRegistry.get(type) || null;
30
+ }
31
+ const shortType = packagePathToType(type);
32
+ if (nodeMetadataRegistry.has(shortType)) {
33
+ return nodeMetadataRegistry.get(shortType) || null;
34
+ }
35
+ return null;
36
+ }
37
+ function getNodeDisplayName(type) {
38
+ const metadata = getNodeMetadata(type);
39
+ if (metadata?.name) {
40
+ return metadata.name;
41
+ }
42
+ return packagePathToType(type);
43
+ }
44
+ function registerNodeClasses(libraryId, classes) {
45
+ const existing = nodeClassRegistries.get(libraryId);
46
+ nodeClassRegistries.set(libraryId, existing ? { ...existing, ...classes } : classes);
47
+ }
48
+ function typeToPackagePath(type) {
49
+ if (type.includes(".")) {
50
+ return type;
51
+ }
52
+ for (const [libraryId, classes] of nodeClassRegistries) {
53
+ if (type in classes) {
54
+ return `cascade.${libraryId}.${type}`;
55
+ }
56
+ }
57
+ return type;
58
+ }
59
+ function packagePathToType(packagePath) {
60
+ if (!packagePath.includes(".")) {
61
+ return packagePath;
62
+ }
63
+ const parts = packagePath.split(".");
64
+ return parts[parts.length - 1];
65
+ }
66
+ function isStandardLibraryNode(type) {
67
+ return type.includes(".") && type.startsWith("cascade.");
68
+ }
69
+ function getLibraryIdFromType(type) {
70
+ if (!type.includes(".")) {
71
+ return null;
72
+ }
73
+ const parts = type.split(".");
74
+ if (parts.length >= 2 && parts[0] === "cascade") {
75
+ return parts[1];
76
+ }
77
+ return null;
78
+ }
79
+ function getNodeClass(type) {
80
+ const libraryId = getLibraryIdFromType(type);
81
+ const nodeType = packagePathToType(type);
82
+ if (!libraryId) {
83
+ return null;
84
+ }
85
+ const registry = nodeClassRegistries.get(libraryId);
86
+ if (registry && registry[nodeType]) {
87
+ return registry[nodeType];
88
+ }
89
+ return null;
90
+ }
91
+
92
+ // src/utils/colorUtils.ts
93
+ function normalizeColor(input) {
94
+ if (typeof input === "object" && !Array.isArray(input) && "r" in input && "g" in input && "b" in input) {
95
+ const obj = input;
96
+ const isNormalized = obj.r <= 1 && obj.g <= 1 && obj.b <= 1;
97
+ return {
98
+ r: isNormalized ? obj.r : obj.r / 255,
99
+ g: isNormalized ? obj.g : obj.g / 255,
100
+ b: isNormalized ? obj.b : obj.b / 255,
101
+ a: obj.a !== void 0 ? isNormalized ? obj.a : obj.a / 255 : 1
102
+ };
103
+ }
104
+ if (Array.isArray(input)) {
105
+ const isNormalized = input.every((v) => v <= 1);
106
+ return {
107
+ r: isNormalized ? input[0] : input[0] / 255,
108
+ g: isNormalized ? input[1] : input[1] / 255,
109
+ b: isNormalized ? input[2] : input[2] / 255,
110
+ a: input[3] !== void 0 ? isNormalized ? input[3] : input[3] / 255 : 1
111
+ };
112
+ }
113
+ if (typeof input === "string") {
114
+ const rgbMatch = input.match(/rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
115
+ if (rgbMatch) {
116
+ return {
117
+ r: parseInt(rgbMatch[1], 10) / 255,
118
+ g: parseInt(rgbMatch[2], 10) / 255,
119
+ b: parseInt(rgbMatch[3], 10) / 255,
120
+ a: 1
121
+ };
122
+ }
123
+ const hex = input.replace("#", "");
124
+ if (hex.length === 3 || hex.length === 6) {
125
+ const expanded = hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex;
126
+ const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(expanded);
127
+ if (result) {
128
+ return {
129
+ r: parseInt(result[1], 16) / 255,
130
+ g: parseInt(result[2], 16) / 255,
131
+ b: parseInt(result[3], 16) / 255,
132
+ a: 1
133
+ };
134
+ }
135
+ }
136
+ }
137
+ return { r: 0, g: 0, b: 0, a: 1 };
138
+ }
139
+ function colorToCss(color) {
140
+ const r = Math.round(color.r * 255);
141
+ const g = Math.round(color.g * 255);
142
+ const b = Math.round(color.b * 255);
143
+ const a = color.a !== void 0 ? color.a : 1;
144
+ if (a < 1) {
145
+ return `rgba(${r},${g},${b},${a})`;
146
+ }
147
+ return `rgb(${r},${g},${b})`;
148
+ }
149
+ function isColorValue(value) {
150
+ if (typeof value === "string") {
151
+ return value.startsWith("#") || value.startsWith("rgb(");
152
+ }
153
+ if (typeof value === "object" && value !== null) {
154
+ if ("r" in value && "g" in value && "b" in value) {
155
+ return true;
156
+ }
157
+ if (Array.isArray(value) && (value.length === 3 || value.length === 4)) {
158
+ return value.every((v) => typeof v === "number");
159
+ }
160
+ }
161
+ return false;
162
+ }
163
+
164
+ // src/types/coreTypes.ts
165
+ var CORE_TYPES2 = CORE_TYPES;
166
+ var CORE_SET = new Set(CORE_TYPES2);
167
+ function isCoreType(type) {
168
+ return CORE_SET.has(type);
169
+ }
170
+ var normalizeType = normalizeCascadeType;
171
+ function canConnect(fromType, toType) {
172
+ return canConnectTypes(fromType, toType);
173
+ }
174
+
175
+ // src/utils/thrownError.ts
176
+ function asError(thrown) {
177
+ if (thrown instanceof Error) return thrown;
178
+ const described = describeThrown(thrown);
179
+ const error = new Error(described);
180
+ error.cause = thrown;
181
+ return error;
182
+ }
183
+ function describeThrown(thrown) {
184
+ if (thrown === null) return "null was thrown";
185
+ if (thrown === void 0) return "undefined was thrown";
186
+ if (typeof thrown === "string") return thrown || "an empty string was thrown";
187
+ if (typeof thrown !== "object") return `${String(thrown)} was thrown`;
188
+ const value = thrown;
189
+ const kind = typeof value.name === "string" && value.name ? value.name : value.constructor?.name || "object";
190
+ const parts = [];
191
+ if (typeof value.message === "string" && value.message) parts.push(value.message);
192
+ if (typeof value.reason === "string" && value.reason) parts.push(`reason: ${value.reason}`);
193
+ if (!parts.length) {
194
+ try {
195
+ const json = JSON.stringify(thrown);
196
+ if (json && json !== "{}") parts.push(json);
197
+ } catch {
198
+ }
199
+ }
200
+ return parts.length ? `${kind}: ${parts.join(", ")}` : `${kind} was thrown with no message`;
201
+ }
202
+
203
+ // src/nodes/outputEquality.ts
204
+ function sameOutputValue(previous, next, budget = 2e6) {
205
+ const state = { remaining: budget };
206
+ return same(previous, next, state, true);
207
+ }
208
+ function sameBundleField(previous, next, budget = 2e6) {
209
+ return same(previous, next, { remaining: budget }, false);
210
+ }
211
+ function isPlain(value) {
212
+ const proto = Object.getPrototypeOf(value);
213
+ return proto === Object.prototype || proto === null || proto === Array.prototype;
214
+ }
215
+ function same(a, b, state, top) {
216
+ if (--state.remaining < 0) return false;
217
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
218
+ if (typeof a === "function" || typeof b === "function") return false;
219
+ return Object.is(a, b);
220
+ }
221
+ if (a === b && (top || ArrayBuffer.isView(a) || !isPlain(a))) return false;
222
+ if (ArrayBuffer.isView(a) || ArrayBuffer.isView(b)) {
223
+ if (!ArrayBuffer.isView(a) || !ArrayBuffer.isView(b)) return false;
224
+ if (a.constructor !== b.constructor || a.byteLength !== b.byteLength) return false;
225
+ state.remaining -= a.byteLength / 8;
226
+ if (state.remaining < 0) return false;
227
+ const x2 = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
228
+ const y2 = new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
229
+ for (let i = 0; i < x2.length; i++) if (x2[i] !== y2[i]) return false;
230
+ return true;
231
+ }
232
+ if (!isPlain(a) || !isPlain(b)) return false;
233
+ const arrayA = Array.isArray(a);
234
+ if (arrayA !== Array.isArray(b)) return false;
235
+ if (arrayA) {
236
+ const x2 = a;
237
+ const y2 = b;
238
+ if (x2.length !== y2.length) return false;
239
+ for (let i = 0; i < x2.length; i++) if (!same(x2[i], y2[i], state, false)) return false;
240
+ return true;
241
+ }
242
+ const x = a;
243
+ const y = b;
244
+ const keys = Object.keys(x);
245
+ if (keys.length !== Object.keys(y).length) return false;
246
+ for (const key of keys) {
247
+ if (!Object.prototype.hasOwnProperty.call(y, key)) return false;
248
+ if (!same(x[key], y[key], state, false)) return false;
249
+ }
250
+ return true;
251
+ }
252
+
253
+ // src/nodes/inputFingerprint.ts
254
+ var identities = /* @__PURE__ */ new WeakMap();
255
+ var nextIdentity = 1;
256
+ var identityOf = (value) => identities.get(value) ?? (identities.set(value, nextIdentity), nextIdentity++);
257
+ function hashBytes(view) {
258
+ const length = view.byteLength;
259
+ let words;
260
+ if ((view.byteOffset | length) & 3) {
261
+ const padded = new Uint8Array(length + 3 & ~3);
262
+ padded.set(new Uint8Array(view.buffer, view.byteOffset, length));
263
+ words = new Int32Array(padded.buffer);
264
+ } else {
265
+ words = new Int32Array(view.buffer, view.byteOffset, length >> 2);
266
+ }
267
+ let a = 2166136261 ^ length, b = 2654435769, c = 2246822507, d = 3266489909;
268
+ const count = words.length;
269
+ const blocks = count & ~3;
270
+ for (let i = 0; i < blocks; i += 4) {
271
+ a = Math.imul(a ^ words[i], 16777619);
272
+ a = a << 13 | a >>> 19;
273
+ b = Math.imul(b ^ words[i + 1], 1540483477);
274
+ b = b << 13 | b >>> 19;
275
+ c = Math.imul(c ^ words[i + 2], 16777619);
276
+ c = c << 13 | c >>> 19;
277
+ d = Math.imul(d ^ words[i + 3], 1540483477);
278
+ d = d << 13 | d >>> 19;
279
+ }
280
+ for (let i = blocks; i < count; i++) {
281
+ a = Math.imul(a ^ words[i], 16777619);
282
+ a = a << 13 | a >>> 19;
283
+ }
284
+ return [a, b, c, d].map((lane) => (lane >>> 0).toString(36)).join(".");
285
+ }
286
+ var hashingReplacer = (_key, item) => ArrayBuffer.isView(item) ? `#${hashBytes(item)}` : item;
287
+ function valueFingerprint(value) {
288
+ const type = typeof value;
289
+ if (value == null || type !== "object" && type !== "function") return `${type}:${String(value)}`;
290
+ const object = value;
291
+ const channels = object.channels;
292
+ if (Array.isArray(channels) && channels.every((channel) => ArrayBuffer.isView(channel))) {
293
+ return `${object.width}x${object.height}:${object.colorSpace}:${channels.map(hashBytes).join(",")}`;
294
+ }
295
+ let json;
296
+ try {
297
+ json = JSON.stringify(object, hashingReplacer);
298
+ } catch {
299
+ json = `#${identityOf(object)}`;
300
+ }
301
+ const proto = Object.getPrototypeOf(object);
302
+ const content = proto === Object.prototype || proto === null || Array.isArray(object) || ArrayBuffer.isView(object);
303
+ return content ? `json:${json}` : `obj#${identityOf(object)}:${json}`;
304
+ }
305
+
306
+ // src/nodes/Node.ts
307
+ function inferParamType(value) {
308
+ if (typeof value === "number") return Number.isInteger(value) ? "int" : "float";
309
+ if (typeof value === "boolean") return "bool";
310
+ if (typeof value === "string") return "string";
311
+ if (Array.isArray(value) && value.every((v) => typeof v === "number")) {
312
+ if (value.length === 2) return "vec2";
313
+ if (value.length === 3) return "vec3";
314
+ if (value.length === 4) return "vec4";
315
+ }
316
+ return "any";
317
+ }
318
+ function paramControlType(dataType) {
319
+ switch (normalizeType(dataType ?? "any")) {
320
+ case "int":
321
+ return "int";
322
+ case "float":
323
+ return "number";
324
+ case "bool":
325
+ return "boolean";
326
+ case "string":
327
+ return "text";
328
+ case "color":
329
+ return "color";
330
+ case "vec2":
331
+ return "vec2";
332
+ case "vec3":
333
+ return "vec3";
334
+ case "vec4":
335
+ return "vec4";
336
+ default:
337
+ return void 0;
338
+ }
339
+ }
340
+ var PROP_CONTROL_TO_DATA_TYPE = {
341
+ number: "float",
342
+ slider: "float",
343
+ range: "float",
344
+ float: "float",
345
+ int: "int",
346
+ text: "string",
347
+ textarea: "string",
348
+ string: "string",
349
+ select: "string",
350
+ boolean: "bool",
351
+ bool: "bool",
352
+ vector: "vec3"
353
+ };
354
+ function carryType(type) {
355
+ if (!type) return "any";
356
+ return normalizeType(PROP_CONTROL_TO_DATA_TYPE[type] ?? type);
357
+ }
358
+ var NUMERIC_CARRY = /* @__PURE__ */ new Set(["int", "float"]);
359
+ function carryTypesMatch(before, after) {
360
+ if (before === "any" || after === "any") return true;
361
+ if (NUMERIC_CARRY.has(before) && NUMERIC_CARRY.has(after)) return true;
362
+ return before === after;
363
+ }
364
+ function sameStoredValue(a, b) {
365
+ if (a === b) return true;
366
+ if (Array.isArray(a) && Array.isArray(b)) {
367
+ return a.length === b.length && a.every((item, index) => sameStoredValue(item, b[index]));
368
+ }
369
+ if (isPlainRecord(a) && isPlainRecord(b)) {
370
+ const keys = Object.keys(a);
371
+ if (keys.length !== Object.keys(b).length) return false;
372
+ return keys.every((key) => key in b && sameStoredValue(a[key], b[key]));
373
+ }
374
+ return typeof a === "number" && typeof b === "number" && Number.isNaN(a) && Number.isNaN(b);
375
+ }
376
+ function isPlainRecord(value) {
377
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
378
+ const prototype = Object.getPrototypeOf(value);
379
+ return prototype === Object.prototype || prototype === null;
380
+ }
381
+ var Node = class _Node {
382
+ constructor(id, type, graph) {
383
+ // Ports - available to all nodes
384
+ this.inputs = [];
385
+ this.outputs = [];
386
+ // Props system
387
+ this.props = {};
388
+ this.propWatchers = /* @__PURE__ */ new Map();
389
+ // Actions system - button-triggered callbacks
390
+ this.actions = {};
391
+ // Variadic inputs
392
+ /** The node's own values — see param(). Distinct from `props`, which is the
393
+ * older image-node control system. */
394
+ this.parameters = [];
395
+ this.parametersUsedDuringSetup = /* @__PURE__ */ new Set();
396
+ /**
397
+ * Props the node's own code declared with `addParm()`, and which its cook may
398
+ * therefore read as `this.props.name.value`.
399
+ *
400
+ * A prop restored from a document is NOT in here until the code that owns it
401
+ * has run, which for any on-demand-compiled module is after the first cook
402
+ * has begun. See evaluateAllExpressions for why that distinction has to be
403
+ * made rather than assumed.
404
+ */
405
+ this.propsDeclaredByCode = /* @__PURE__ */ new Set();
406
+ /** Non-null between a retarget and the cook that resolves it. */
407
+ this.carryOver = null;
408
+ /** The last completed reconciliation, for a panel that arrives after the fact. */
409
+ this.lastCarryOverReport = null;
410
+ this.variadic = false;
411
+ this.variadicDefault = null;
412
+ this.variadicOptions = {};
413
+ // Visual
414
+ this.preview = null;
415
+ // Metadata
416
+ this.comment = "";
417
+ this.error = null;
418
+ this.warning = null;
419
+ // Computation-specific
420
+ this.code = "";
421
+ this.bypass = false;
422
+ this.cook = false;
423
+ // Path system - hierarchical node organization
424
+ this.parent = null;
425
+ /**
426
+ * Child parameters this node shows as its own — Houdini's promoted
427
+ * parameters.
428
+ *
429
+ * Marcus, 2026-09-09, on where a force's settings should live: *"keep the
430
+ * force settings on the inside, but allow to promote parameters to the level
431
+ * above with 1 click."*
432
+ *
433
+ * **This is presentation, not state.** The value stays on the child and there
434
+ * is exactly one of it; the container only says which ones to show. The
435
+ * alternative — a real parameter on the container that writes through — would
436
+ * be a second copy of the value, a resolution order between the two, and a
437
+ * way for them to disagree. Props already carry a value, an expression and a
438
+ * channel with an order between them; doing it again for promotion is the
439
+ * mistake this avoids.
440
+ *
441
+ * Which is also why the runtime ignores this field entirely. Nothing about a
442
+ * cook changes when a parameter is promoted, so a headless render and a
443
+ * Studio session cannot diverge over it.
444
+ */
445
+ this.promotions = [];
446
+ this._children = [];
447
+ // ============ Early cutoff ============
448
+ /**
449
+ * Whether this node may be skipped when it is stale only because something
450
+ * upstream re-cooked and every input came back identical.
451
+ *
452
+ * On by default. A node whose result depends on something other than its
453
+ * inputs and its own parameters — the clock, a file it reads behind the
454
+ * graph's back, a random draw it wants fresh each time — sets this false, or
455
+ * marks itself dirty when that other thing changes (which is the better fix,
456
+ * and what time-dependent expressions already do).
457
+ */
458
+ this.earlyCutoff = true;
459
+ /** Per output port: bumped whenever a committed value is not provably the
460
+ * same as the last one. What downstream nodes compare instead of values. */
461
+ this.outputVersions = /* @__PURE__ */ new Map();
462
+ /** Set when this node itself was invalidated (a parameter, its code, a
463
+ * connection), as opposed to only being downstream of something that was.
464
+ * A self-dirty node always cooks. */
465
+ this.selfDirty = true;
466
+ /** The input versions this node last cooked cleanly against, or null. */
467
+ this.cutoffKey = null;
468
+ /**
469
+ * Per bundle output: a version per field, plus an epoch bumped whenever a
470
+ * change could not be attributed to fields (an output the cook did not set,
471
+ * the same object committed twice). A consumer that declares the fields it
472
+ * reads compares these instead of the port's version, so a change to one
473
+ * field does not re-cook the consumers of the others.
474
+ */
475
+ this.bundleFieldVersions = /* @__PURE__ */ new Map();
476
+ // Execution state
477
+ this.hasExecuted = false;
478
+ this.lastInputHash = "";
479
+ /** The inputs an execute threw on, or null if the last cook did not throw.
480
+ * Read only by `isDirty` — see the comment there for why it exists. */
481
+ this.failedInputHash = null;
482
+ /**
483
+ * The abort controllers for cooks currently in flight.
484
+ *
485
+ * A set rather than one, for the same reason `stagingStack` is a stack: two
486
+ * cooks can overlap, and the one that finishes first must not cancel the
487
+ * other. Aborted together on invalidation, because an invalidation
488
+ * invalidates every version of this node's work rather than the newest.
489
+ */
490
+ this.cookAborts = /* @__PURE__ */ new Set();
491
+ this.cookState = "stale";
492
+ this.cookGeneration = 0;
493
+ /**
494
+ * Output values staged by the cook currently running, one map per cook.
495
+ *
496
+ * A stack rather than a field, because it was a field and that was a race.
497
+ * `stagedOutputs` was instance state created per cook, iterated under a
498
+ * `generation === this.cookGeneration` guard, and nulled in `finally` — but
499
+ * `cookGeneration` only advances in `invalidateCook()`, and `cook()` has no
500
+ * re-entrancy guard. So two overlapping cooks of the same node with no
501
+ * invalidation between them both ran at the same generation: the second's
502
+ * `finally` nulled the field, the first resumed, passed the generation check
503
+ * because nothing had invalidated it, and iterated `null` —
504
+ * `stagedOutputs is not iterable`.
505
+ *
506
+ * Worse than the crash, and the reason this is a correctness fix rather than
507
+ * a robustness one: the second cook's `new Map()` **replaced the first's
508
+ * mid-flight**, so the first cook's staged writes were lost whenever the
509
+ * timing did not happen to produce the throw.
510
+ *
511
+ * The underlying fault is that per-cook state lived on the instance, and the
512
+ * thing protecting it (the generation) was not the thing destroying it (the
513
+ * `finally`) — two mechanisms guarding one field, neither aware of the other.
514
+ * Each cook now owns its own map, holds it locally, and removes it **by
515
+ * identity**, so an interleaving cannot make one cook drop another's.
516
+ *
517
+ * Diagnosed by MW-OBSERVATORY-ART, 2026-09-08.
518
+ */
519
+ this.stagingStack = [];
520
+ this.executionTimeout = 3e4;
521
+ // Cook info - performance tracking
522
+ this.cookInfo = {
523
+ lastCookTime: 0,
524
+ totalCookTime: 0,
525
+ cookCount: 0,
526
+ averageCookTime: 0,
527
+ lastCookTimestamp: 0,
528
+ peakMemory: 0
529
+ };
530
+ // Time dependency tracking (for expression engine)
531
+ this.isTimeDependent = false;
532
+ // Port tracking for cleanup
533
+ this.portsUsedDuringSetup = /* @__PURE__ */ new Set();
534
+ // Port index map for O(1) lookups (portId -> port)
535
+ this._portMap = /* @__PURE__ */ new Map();
536
+ // ============ Props System ============
537
+ /**
538
+ * Add a parameter to this node
539
+ * Short alias for defineProp - use in setup()
540
+ */
541
+ /** Props whose declared default expression has already been applied — see
542
+ * `bindParameterProp`, which must not reinstate one the author deleted. */
543
+ this.defaultExpressionsApplied = /* @__PURE__ */ new Set();
544
+ /** Props whose plain stored value explicitly overrides a declared default
545
+ * expression. The marker survives even when that value equals the default. */
546
+ this.plainPropOverrides = /* @__PURE__ */ new Set();
547
+ /**
548
+ * A container that a declaration said is one.
549
+ *
550
+ * Set by `attachDefinition` from `definition.container`, because `isNetwork`
551
+ * was a class override and a definition-v1 node is a plain `Node` — so a
552
+ * `cascade.core.Subnet` created through the adapter reported `false` and
553
+ * nothing that asks could tell it was a subnet.
554
+ *
555
+ * Marcus, 2026-09-09, hitting both symptoms of that at once: *"double
556
+ * clicking on the node goes to the code view ... not the subnet ... also the
557
+ * i and o keys are not doing anything."* Two features, one cause — the
558
+ * double-click handler and the i/o shortcuts both gate on `isNetwork()`, and
559
+ * both were correct code asking a question that could only ever answer no.
560
+ */
561
+ this.declaredContainer = null;
562
+ this.id = id;
563
+ this.type = type;
564
+ this.graph = graph;
565
+ this.position = { x: 0, y: 0 };
566
+ if (this.constructor !== _Node) {
567
+ this.setup();
568
+ queueMicrotask(() => {
569
+ if (this.onReady) {
570
+ try {
571
+ this.onReady();
572
+ } catch (err) {
573
+ console.error(`Error in onReady for node ${this.id}:`, err);
574
+ }
575
+ }
576
+ });
577
+ }
578
+ }
579
+ /** Is this child parameter shown on the parent? */
580
+ isPromotedToParent(param) {
581
+ return (this.parent?.promotions ?? []).some(
582
+ (entry) => entry.nodeId === this.id && entry.param === param
583
+ );
584
+ }
585
+ /**
586
+ * One click, both directions.
587
+ *
588
+ * A no-op without a parent rather than an error: the button is only rendered
589
+ * for a child, and a guard that throws would turn a stale UI into a crash.
590
+ */
591
+ setPromotedToParent(param, promoted) {
592
+ const parent = this.parent;
593
+ if (!parent) return;
594
+ const index = parent.promotions.findIndex(
595
+ (entry) => entry.nodeId === this.id && entry.param === param
596
+ );
597
+ if (promoted && index < 0) parent.promotions.push({ nodeId: this.id, param });
598
+ else if (!promoted && index >= 0) parent.promotions.splice(index, 1);
599
+ }
600
+ /** @internal The version of one output, for downstream cutoff. */
601
+ outputVersion(portId) {
602
+ return this.outputVersions.get(portId) ?? 0;
603
+ }
604
+ bumpOutputVersion(portId) {
605
+ this.outputVersions.set(portId, (this.outputVersions.get(portId) ?? 0) + 1);
606
+ const fields = this.bundleFieldVersions.get(portId);
607
+ if (fields) fields.epoch += 1;
608
+ }
609
+ /** @internal The version of one field of a bundle output. */
610
+ outputFieldVersion(portId, field) {
611
+ const entry = this.bundleFieldVersions.get(portId);
612
+ return entry ? `${entry.epoch}.${entry.fields.get(field) ?? 0}` : `0.0`;
613
+ }
614
+ /** Bump the versions of the fields a bundle commit changed. */
615
+ bumpBundleFields(portId, previous, next) {
616
+ let entry = this.bundleFieldVersions.get(portId);
617
+ if (!entry) {
618
+ entry = { epoch: 0, fields: /* @__PURE__ */ new Map() };
619
+ this.bundleFieldVersions.set(portId, entry);
620
+ }
621
+ const prev = previous !== null && typeof previous === "object" ? previous : null;
622
+ const curr = next !== null && typeof next === "object" ? next : null;
623
+ if (!prev || !curr || prev === curr) {
624
+ entry.epoch += 1;
625
+ return;
626
+ }
627
+ const has = Object.prototype.hasOwnProperty;
628
+ for (const field of /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(curr)])) {
629
+ const changed = has.call(prev, field) !== has.call(curr, field) || !sameBundleField(prev[field], curr[field]);
630
+ if (changed) entry.fields.set(field, (entry.fields.get(field) ?? 0) + 1);
631
+ }
632
+ }
633
+ /**
634
+ * The fields of a bundle input this node depends on, or null for the whole
635
+ * bundle. Declared by the node (`fieldSelection`), plus whatever its
636
+ * parameter expressions read through `ch("port.field")`. A node that does
637
+ * not declare its fields may read any of them, so it depends on all.
638
+ */
639
+ bundleFieldsRead(input) {
640
+ if (input.dataType !== "bundle" || !input.options?.fieldSelection) return null;
641
+ const fields = new Set(input.options.fieldSelection);
642
+ for (const prop of Object.values(this.props)) {
643
+ if (!prop?.expression) continue;
644
+ for (const reference of bundleFieldReferences(prop.expression))
645
+ if (reference.port === input.name) fields.add(reference.field);
646
+ }
647
+ return [...fields].sort();
648
+ }
649
+ /** @internal The scheduler calls this on the node an invalidation started at. */
650
+ markSelfDirty() {
651
+ this.selfDirty = true;
652
+ }
653
+ /** What every input currently comes from, as upstream output versions. Null
654
+ * when an input comes from something that keeps no versions (an annotation),
655
+ * which disables the cutoff for this node. */
656
+ inputVersionKey() {
657
+ const parts = [];
658
+ for (const input of this.inputs) {
659
+ if (input.connections.length === 0) {
660
+ parts.push(`${input.id}=${valueFingerprint(input.value)}`);
661
+ continue;
662
+ }
663
+ const fields = this.bundleFieldsRead(input);
664
+ for (const connection of input.connections) {
665
+ const upstream = this.graph.getNode(connection.from.nodeId);
666
+ if (!upstream) return null;
667
+ const source = `${input.id}<${connection.from.nodeId}:${connection.from.portId}`;
668
+ parts.push(fields ? `${source}{${fields.map((field) => `${field}@${upstream.outputFieldVersion(connection.from.portId, field)}`).join(",")}}` : `${source}@${upstream.outputVersion(connection.from.portId)}`);
669
+ }
670
+ }
671
+ return parts.join("|");
672
+ }
673
+ /**
674
+ * @internal Early cutoff: true when this node is stale only transitively,
675
+ * cooked cleanly last time, and every upstream output it reads still has the
676
+ * version it cooked against — i.e. the upstream re-cook produced the same
677
+ * values. Skipping it then is exact, and the nodes below it see unchanged
678
+ * versions in turn, so the whole subgraph stays cached.
679
+ */
680
+ canSkipCook() {
681
+ if (!this.earlyCutoff || this.selfDirty || !this.hasExecuted) return false;
682
+ if (this.cutoffKey === null || this.error !== null) return false;
683
+ if (!this.nodeFunction && this.onCook === _Node.prototype.onCook) return false;
684
+ return this.inputVersionKey() === this.cutoffKey;
685
+ }
686
+ /** @internal Mark clean without cooking; see `canSkipCook`. */
687
+ skipCook() {
688
+ this.setCookState("clean");
689
+ }
690
+ /** The owning graph's document `seed`, which `context.seed` derives from. */
691
+ get documentSeed() {
692
+ return this.graph?.seed;
693
+ }
694
+ // ============ Port Creation ============
695
+ in(name, defaultValue, options = {}) {
696
+ const portType = name === "trigger" ? "trigger" : "param";
697
+ const existingPort = this.inputs.find((p) => p.name === name);
698
+ if (existingPort) {
699
+ this.portsUsedDuringSetup.add(`input_${name}`);
700
+ if (options.type) existingPort.dataType = options.type;
701
+ if (defaultValue !== void 0 && existingPort.defaultValue !== defaultValue) {
702
+ existingPort.defaultValue = defaultValue;
703
+ if (existingPort.value === void 0 || existingPort.value === existingPort.defaultValue) {
704
+ existingPort.value = defaultValue;
705
+ }
706
+ }
707
+ existingPort.options = Object.assign(existingPort.options ?? {}, options);
708
+ return existingPort;
709
+ }
710
+ const portIndex = this.inputs.length;
711
+ const port = {
712
+ id: `${this.id}_in_${portIndex}`,
713
+ name,
714
+ portType,
715
+ dataType: options.type || "any",
716
+ value: defaultValue,
717
+ defaultValue,
718
+ options,
719
+ connections: []
720
+ };
721
+ this.inputs.push(port);
722
+ this._portMap.set(port.id, port);
723
+ this.portsUsedDuringSetup.add(`input_${name}`);
724
+ return port;
725
+ }
726
+ /**
727
+ * Declare an output port. Takes the same `options` as `in()` — an output that
728
+ * cannot state its type is a hole in the type system: the connection check
729
+ * only ever sees `any` on one side, and the port draws in the neutral colour
730
+ * whatever it actually carries.
731
+ */
732
+ out(name, portType = "param", options = {}) {
733
+ const existingPort = this.outputs.find((p) => p.name === name);
734
+ if (existingPort) {
735
+ this.portsUsedDuringSetup.add(`output_${name}`);
736
+ if (existingPort.portType !== portType) existingPort.portType = portType;
737
+ if (options.type) existingPort.dataType = options.type;
738
+ existingPort.options = { ...existingPort.options ?? {}, ...options };
739
+ return existingPort;
740
+ }
741
+ const portIndex = this.outputs.length;
742
+ const port = {
743
+ id: `${this.id}_out_${portIndex}`,
744
+ name,
745
+ portType,
746
+ dataType: options.type || "any",
747
+ value: void 0,
748
+ connections: [],
749
+ options,
750
+ setValue: (value) => this.setOutputValue(port, value),
751
+ trigger: (props) => {
752
+ port.connections.forEach((conn) => {
753
+ const targetNode = this.graph.getNode(conn.to.nodeId);
754
+ if (targetNode) {
755
+ const targetPort = targetNode.getInputPortById(conn.to.portId);
756
+ if (targetPort && targetPort.onTrigger) targetPort.onTrigger(props);
757
+ }
758
+ });
759
+ }
760
+ };
761
+ this.outputs.push(port);
762
+ this._portMap.set(port.id, port);
763
+ this.portsUsedDuringSetup.add(`output_${name}`);
764
+ return port;
765
+ }
766
+ // ============ Port Access ============
767
+ getInputPort(index) {
768
+ return index >= 0 && index < this.inputs.length ? this.inputs[index] : null;
769
+ }
770
+ getOutputPort(index) {
771
+ return index >= 0 && index < this.outputs.length ? this.outputs[index] : null;
772
+ }
773
+ /** Update the element id and every derived port id as one operation. */
774
+ renameId(newId) {
775
+ const oldId = this.id;
776
+ const renamed = /* @__PURE__ */ new Map();
777
+ this.id = newId;
778
+ this._portMap.clear();
779
+ for (const port of [...this.inputs, ...this.outputs]) {
780
+ const oldPortId = port.id;
781
+ port.id = oldPortId.startsWith(`${oldId}_`) ? `${newId}${oldPortId.slice(oldId.length)}` : oldPortId;
782
+ renamed.set(oldPortId, port.id);
783
+ this._portMap.set(port.id, port);
784
+ }
785
+ return renamed;
786
+ }
787
+ getPort(portId) {
788
+ return this._portMap.get(portId) || null;
789
+ }
790
+ getInputPortById(portId) {
791
+ const port = this._portMap.get(portId);
792
+ return port && "defaultValue" in port ? port : null;
793
+ }
794
+ setOutputValue(port, value) {
795
+ const staging = this.stagingStack[this.stagingStack.length - 1];
796
+ if (staging) {
797
+ staging.set(port, value);
798
+ return;
799
+ }
800
+ this.commitOutputValue(port, value);
801
+ }
802
+ commitOutputValue(port, value) {
803
+ if (!sameOutputValue(port.value, value)) {
804
+ if (port.dataType === "bundle") {
805
+ this.outputVersions.set(port.id, (this.outputVersions.get(port.id) ?? 0) + 1);
806
+ this.bumpBundleFields(port.id, port.value, value);
807
+ } else {
808
+ this.bumpOutputVersion(port.id);
809
+ }
810
+ }
811
+ port.value = value;
812
+ for (const connection of port.connections) {
813
+ const targetNode = this.graph.getNode(connection.to.nodeId);
814
+ const targetPort = targetNode?.getInputPortById(connection.to.portId);
815
+ if (!targetPort) continue;
816
+ targetPort.value = value;
817
+ targetPort.onChange?.(value);
818
+ }
819
+ }
820
+ addParm(name, config) {
821
+ this.props[name] = config;
822
+ this.propsDeclaredByCode.add(name);
823
+ this.reconcileProp(name);
824
+ if (this.cookState !== "cooking") this.markDirty();
825
+ }
826
+ /**
827
+ * Define a parameter on this node
828
+ * @deprecated Use addParm() instead
829
+ */
830
+ defineProp(name, config) {
831
+ this.addParm(name, config);
832
+ }
833
+ /**
834
+ * Add an action button to this node
835
+ * Actions appear in the Inspector and can be triggered by keyboard shortcuts
836
+ */
837
+ addAction(name, config) {
838
+ this.actions[name] = config;
839
+ }
840
+ /**
841
+ * Execute an action by name
842
+ */
843
+ async executeAction(name) {
844
+ const action = this.actions[name];
845
+ if (!action) return;
846
+ if (action.condition && !action.condition()) return;
847
+ await action.callback();
848
+ }
849
+ updateProp(name, value) {
850
+ if (!this.props[name]) return;
851
+ const newValue = Array.isArray(value) ? [...value] : value;
852
+ this.props[name] = { ...this.props[name], value: newValue };
853
+ if (this.props[name].onChange) {
854
+ try {
855
+ this.props[name].onChange(this.props[name], this);
856
+ } catch (err) {
857
+ console.error(`Error in prop onChange for ${name}:`, err);
858
+ }
859
+ }
860
+ const watchers = this.propWatchers.get(name);
861
+ if (watchers) {
862
+ watchers.forEach((cb) => {
863
+ try {
864
+ cb(newValue, this.props[name]);
865
+ } catch (err) {
866
+ console.error(`Error in prop watcher for ${name}:`, err);
867
+ }
868
+ });
869
+ }
870
+ this.props = { ...this.props };
871
+ this.markDirty();
872
+ _Node.onPropParamsChanged?.(this.id);
873
+ }
874
+ /**
875
+ * Update prop params (e.g., min, max) and trigger UI reactivity
876
+ */
877
+ updatePropParams(name, params) {
878
+ if (!this.props[name]) return;
879
+ this.props[name] = {
880
+ ...this.props[name],
881
+ params: { ...this.props[name].params, ...params }
882
+ };
883
+ this.props = { ...this.props };
884
+ _Node.onPropParamsChanged?.(this.id);
885
+ }
886
+ watchProp(name, callback) {
887
+ if (!this.propWatchers.has(name)) this.propWatchers.set(name, []);
888
+ this.propWatchers.get(name).push(callback);
889
+ }
890
+ // ============ Parm API (HOM-style wrappers) ============
891
+ /**
892
+ * Get a parameter wrapper object for HOM-style access
893
+ */
894
+ parm(name) {
895
+ const prop = this.props[name];
896
+ if (!prop) return null;
897
+ const self = this;
898
+ const live = () => self.props[name] ?? prop;
899
+ return {
900
+ name: () => name,
901
+ path: () => `${self.path()}/${name}`,
902
+ node: () => self,
903
+ eval: () => live().value,
904
+ evalAsFloat: () => parseFloat(live().value) || 0,
905
+ evalAsInt: () => parseInt(live().value, 10) || 0,
906
+ evalAsString: () => String(live().value ?? ""),
907
+ rawValue: () => live().value,
908
+ set: (value) => self.updateProp(name, value),
909
+ parmType: () => live().type || "any",
910
+ // Expression support
911
+ expression: () => live().expression ?? null,
912
+ setExpression: (expr) => {
913
+ self.props[name] = { ...self.props[name], expression: expr };
914
+ self.updateTimeDependent();
915
+ self.markDirty();
916
+ },
917
+ deleteExpression: () => {
918
+ const { expression: _, expressionError: __, ...rest } = self.props[name];
919
+ self.props[name] = rest;
920
+ if (self.defaultExpressionsApplied.has(name)) {
921
+ self.plainPropOverrides.add(name);
922
+ }
923
+ self.updateTimeDependent();
924
+ self.markDirty();
925
+ },
926
+ hasExpression: () => !!live().expression,
927
+ expressionError: () => live().expressionError ?? null,
928
+ // ---- Keyframe channel ----
929
+ // Same shape as the expression API above, deliberately: a channel and an
930
+ // expression are the same kind of binding, one drawn and one written.
931
+ channel: () => self.props[name]?.channel ?? null,
932
+ hasChannel: () => !isEmptyChannel(self.props[name]?.channel),
933
+ keys: () => self.props[name]?.channel?.keys ?? [],
934
+ /**
935
+ * Key the parameter. Both arguments default to "here, now": the current
936
+ * frame and the parameter's current evaluated value, which is what
937
+ * alt-clicking a parameter has to mean.
938
+ */
939
+ setKey: (frame, value, interpolation) => {
940
+ const at = frame ?? self.currentFrame();
941
+ const v = value ?? Number(self.evalParm(name));
942
+ if (!Number.isFinite(v)) return;
943
+ self.setPropChannel(name, setKey(self.props[name]?.channel, at, v, interpolation));
944
+ },
945
+ deleteKey: (frame) => {
946
+ const current = self.props[name]?.channel;
947
+ if (!current) return;
948
+ self.setPropChannel(name, deleteKey(current, frame));
949
+ },
950
+ clearChannel: () => self.setPropChannel(name, null)
951
+ };
952
+ }
953
+ // ============ Keyframe channels ============
954
+ /**
955
+ * The frame a channel is sampled against.
956
+ *
957
+ * One clock today — the expression engine's, which the host keeps in step
958
+ * with its own frame — so `sin($T)` and a keyed curve on the same node
959
+ * always agree. When timelines become document objects this is the single
960
+ * place that has to learn which one a node belongs to.
961
+ */
962
+ currentFrame() {
963
+ return expressionEngine.fframe;
964
+ }
965
+ /**
966
+ * Attach, replace or clear a channel. An empty channel is removed rather
967
+ * than stored, so `hasChannel()` and the serialised form never disagree
968
+ * about whether a parameter is animated.
969
+ */
970
+ setPropChannel(name, channel) {
971
+ const prop = this.props[name];
972
+ if (!prop) return;
973
+ if (!channel || isEmptyChannel(channel)) {
974
+ const { channel: _dropped, ...rest } = this.props[name];
975
+ this.props[name] = rest;
976
+ } else {
977
+ this.props[name] = { ...prop, channel };
978
+ }
979
+ this.props = { ...this.props };
980
+ this.updateTimeDependent();
981
+ this.markDirty();
982
+ _Node.onPropParamsChanged?.(this.id);
983
+ }
984
+ /**
985
+ * A prop as it goes to disk: the bare value when there is nothing more to
986
+ * say, the object form when it carries an expression, a channel, or both.
987
+ *
988
+ * Lives here rather than in the serializer because the channel type is the
989
+ * node's, and one writer is what stops the two forms disagreeing.
990
+ */
991
+ serializePropValue(name, value) {
992
+ const prop = this.props[name];
993
+ if (!prop) return value;
994
+ const channel = serializeChannel(prop.channel);
995
+ if (!prop.expression && !channel) return value;
996
+ return {
997
+ value,
998
+ ...prop.expression ? { expression: prop.expression } : {},
999
+ ...channel ? { channel } : {}
1000
+ };
1001
+ }
1002
+ preservePlainProp(name) {
1003
+ this.plainPropOverrides.add(name);
1004
+ }
1005
+ preservesPlainProp(name) {
1006
+ return this.plainPropOverrides.has(name) && this.defaultExpressionsApplied.has(name);
1007
+ }
1008
+ /** Read a channel back off disk. Bad data yields no channel, never a throw. */
1009
+ restorePropChannel(name, data) {
1010
+ if (!this.props[name]) return;
1011
+ const channel = deserializeChannel(data);
1012
+ this.setPropChannel(name, isEmptyChannel(channel) ? null : channel);
1013
+ }
1014
+ /** The serialised channel for one prop, or undefined when it is not keyed. */
1015
+ serializePropChannel(name) {
1016
+ return serializeChannel(this.props[name]?.channel);
1017
+ }
1018
+ /**
1019
+ * The one place a parameter becomes a value — in Studio.
1020
+ *
1021
+ * Three bindings, in one order: a keyframe channel, an expression, the raw
1022
+ * value. All of them resolve BEFORE execute, so the node receives a plain
1023
+ * number and never reads a clock — determinism is "same graph plus same
1024
+ * frame gives the same output", which is still cacheable once the cache key
1025
+ * gains a frame.
1026
+ *
1027
+ * The ORDER itself is no longer written here. It lives in
1028
+ * `@cascade/runtime/params`, because the deterministic runtime resolves the
1029
+ * same three bindings for the same document and a second implementation of
1030
+ * the order would be a second answer to the same question. What stays here
1031
+ * is the Studio-specific half: the clock the channel is sampled against, and
1032
+ * the expression engine with its Inspector-facing error bookkeeping.
1033
+ */
1034
+ evalParm(name) {
1035
+ const prop = this.props[name];
1036
+ if (!prop) return void 0;
1037
+ return resolvePropBinding(prop, {
1038
+ frame: this.currentFrame(),
1039
+ // `evaluateExpression` already stores the message on the prop for the
1040
+ // panel and falls back to the raw value, so it reports no error upward:
1041
+ // the resolver's own fallback would be the same value twice.
1042
+ evaluateExpression: () => ({ value: this.evaluateExpression(name) })
1043
+ }).value;
1044
+ }
1045
+ /**
1046
+ * Set a parameter value (shorthand for updateProp)
1047
+ */
1048
+ setParm(name, value) {
1049
+ this.updateProp(name, value);
1050
+ }
1051
+ /**
1052
+ * Evaluate an expression for a parameter
1053
+ * Returns the evaluated value or the raw value if evaluation fails
1054
+ */
1055
+ evaluateExpression(name) {
1056
+ const prop = this.props[name];
1057
+ if (!prop?.expression) return prop?.value;
1058
+ expressionEngine.setGraph(this.graph);
1059
+ const result = expressionEngine.evaluate(prop.expression, this);
1060
+ if (result.error) {
1061
+ this.props[name] = { ...this.props[name], expressionError: result.error };
1062
+ return prop.value;
1063
+ }
1064
+ if (prop.expressionError) {
1065
+ const { expressionError: _, ...rest } = this.props[name];
1066
+ this.props[name] = rest;
1067
+ }
1068
+ return result.value;
1069
+ }
1070
+ /**
1071
+ * Evaluate all expressions on this node and update values
1072
+ * Called during execution to get current expression values
1073
+ */
1074
+ evaluateAllExpressions() {
1075
+ for (const [name, prop] of Object.entries(this.props)) {
1076
+ if (prop.expression && !prop.fromParameter && this.propsDeclaredByCode.has(name)) {
1077
+ const evaluated = this.evaluateExpression(name);
1078
+ if (evaluated !== void 0) {
1079
+ this.props[name] = { ...this.props[name], value: evaluated };
1080
+ }
1081
+ }
1082
+ }
1083
+ this.updateTimeDependent();
1084
+ }
1085
+ // ============ Parameters ============
1086
+ /**
1087
+ * A value that belongs to the node — not a pin.
1088
+ *
1089
+ * Cascade only had inputs and outputs, so every setting a node had was
1090
+ * declared as an input port that nobody ever wired. That reads wrong in the
1091
+ * graph (density-blend showed twenty input pins when it has four real inputs
1092
+ * and sixteen settings) and it reads wrong in the Inspector, where a crop
1093
+ * fraction and an incoming image sat in the same list.
1094
+ *
1095
+ * So: inputs are edges from other nodes, parameters are the node's own
1096
+ * values, outputs are edges out.
1097
+ *
1098
+ * A parameter can be PROMOTED to an input pin when you want it driven from
1099
+ * upstream rather than typed — a signal's weight computed by another node,
1100
+ * say. Promotion is per parameter and off by default: making every setting a
1101
+ * pin is how the two got conflated in the first place.
1102
+ */
1103
+ param(name, defaultValue, options = {}) {
1104
+ let parameter = this.parameters.find((p) => p.name === name);
1105
+ if (!parameter) {
1106
+ parameter = {
1107
+ name,
1108
+ value: defaultValue,
1109
+ defaultValue,
1110
+ dataType: options.type || inferParamType(defaultValue),
1111
+ promoted: false,
1112
+ options
1113
+ };
1114
+ this.parameters.push(parameter);
1115
+ } else {
1116
+ if (options.type) parameter.dataType = options.type;
1117
+ parameter.defaultValue = defaultValue;
1118
+ Object.assign(parameter.options, options);
1119
+ }
1120
+ this.parametersUsedDuringSetup.add(name);
1121
+ this.reconcileParameter(parameter, defaultValue, options);
1122
+ this.bindParameterProp(parameter, name, options);
1123
+ if (parameter.promoted) {
1124
+ const port = this.in(name, parameter.value, {
1125
+ ...parameter.options,
1126
+ type: parameter.dataType,
1127
+ promoted: true
1128
+ });
1129
+ port.fromParameter = name;
1130
+ if (port.connections.length > 0 && port.value !== void 0 && port.value !== null) {
1131
+ return { ...parameter, value: port.value };
1132
+ }
1133
+ }
1134
+ return parameter;
1135
+ }
1136
+ /**
1137
+ * Give a parameter the prop that holds its value, and make `parameter.value`
1138
+ * a view onto it.
1139
+ *
1140
+ * Called from `param()` on every declaration, so everything here is
1141
+ * idempotent: an existing prop keeps its value, expression and channel, and
1142
+ * only the declaration metadata is refreshed. The prop object is mutated
1143
+ * rather than replaced for the same reason the Inspector does not re-key a
1144
+ * row on commit — replacing it mid-drag destroys the control in use.
1145
+ */
1146
+ bindParameterProp(parameter, name, options) {
1147
+ const descriptor = Object.getOwnPropertyDescriptor(parameter, "value");
1148
+ const seeded = descriptor && !descriptor.get ? descriptor.value : void 0;
1149
+ let prop = this.props[name];
1150
+ if (!prop) {
1151
+ prop = { value: seeded !== void 0 ? seeded : parameter.defaultValue };
1152
+ this.props[name] = prop;
1153
+ this.props = { ...this.props };
1154
+ }
1155
+ if (options.defaultExpression && !this.defaultExpressionsApplied.has(name)) {
1156
+ this.defaultExpressionsApplied.add(name);
1157
+ if (!this.plainPropOverrides.has(name) && !prop.expression) {
1158
+ prop.expression = options.defaultExpression;
1159
+ this.props = { ...this.props };
1160
+ this.updateTimeDependent();
1161
+ }
1162
+ }
1163
+ prop.fromParameter = name;
1164
+ prop.hidden = true;
1165
+ const control = paramControlType(parameter.dataType);
1166
+ if (control) prop.type = control;
1167
+ const params = { ...prop.params ?? {} };
1168
+ if (options.min !== void 0) params.min = options.min;
1169
+ if (options.max !== void 0) params.max = options.max;
1170
+ if (options.step !== void 0) params.step = options.step;
1171
+ if (normalizeType(parameter.dataType ?? "any") === "int") params.integer = true;
1172
+ prop.params = params;
1173
+ if (descriptor?.get) return;
1174
+ Object.defineProperty(parameter, "value", {
1175
+ // Resolved, not raw: a channel wins, then an expression, then the stored
1176
+ // value — the same order `evalParm` uses, because it IS `evalParm`. This
1177
+ // is what carries a key or `$T * 0.25` into a sketch's `execute()`
1178
+ // without the sketch knowing anything about either.
1179
+ get: () => this.evalParm(name),
1180
+ set: (next) => this.writeParameterValue(name, next),
1181
+ enumerable: true,
1182
+ configurable: true
1183
+ });
1184
+ }
1185
+ /**
1186
+ * Write a parameter's stored value.
1187
+ *
1188
+ * **A written value clears a DEFAULT expression**, which is the rule the rest
1189
+ * of the system already states — *a default expression is a default, and the
1190
+ * author's number wins* — and which this method did not implement. The
1191
+ * asymmetry was real and silent: `parameter.value` reads *resolved*, so
1192
+ * writing 42 over a prop declaring `defaultExpression: '2 * 5'` stored 42 and
1193
+ * then read back 10. A panel doing exactly that (`setParam` then `getParam`)
1194
+ * saw its own write vanish, concluded the write had failed, and re-rendered
1195
+ * every series instance at the value it had just replaced — identical cache
1196
+ * keys, identical output, and nothing thrown or logged. Found by
1197
+ * MW-OBSERVATORY-ART building the series panel.
1198
+ *
1199
+ * An expression the author wrote is a different matter and is NOT cleared
1200
+ * here: it legitimately wins, and deleting it to make a write stick would
1201
+ * destroy work. `setParameter` reports that case instead of hiding it.
1202
+ *
1203
+ * The prop object is replaced so a panel reading it sees the change; no cook
1204
+ * is scheduled here — the callers that should (setParameter, updateProp) do
1205
+ * it themselves.
1206
+ */
1207
+ writeParameterValue(name, value) {
1208
+ const prop = this.props[name];
1209
+ if (!prop) return;
1210
+ const next = { ...prop, value: Array.isArray(value) ? [...value] : value };
1211
+ if (prop.expression && this.defaultExpressionsApplied.has(name)) {
1212
+ delete next.expression;
1213
+ delete next.expressionError;
1214
+ this.plainPropOverrides.add(name);
1215
+ }
1216
+ this.props[name] = next;
1217
+ this.props = { ...this.props };
1218
+ this.updateTimeDependent();
1219
+ }
1220
+ /**
1221
+ * Why a written value would not be the value read back, or null if it would.
1222
+ *
1223
+ * `parameter.value` resolves a channel first, then an expression, then the
1224
+ * stored value — so a write beneath either of the first two is stored and
1225
+ * then shadowed. That is correct precedence and a terrible silence: the
1226
+ * caller has no way to tell a shadowed write from a failed one, and both
1227
+ * look like the parameter ignoring them.
1228
+ */
1229
+ parameterWriteShadowedBy(name) {
1230
+ const prop = this.props[name];
1231
+ if (!prop) return null;
1232
+ if ((prop.channel?.keys?.length ?? 0) > 0) return "channel";
1233
+ if (prop.expression && !this.defaultExpressionsApplied.has(name)) return "expression";
1234
+ return null;
1235
+ }
1236
+ /**
1237
+ * A parameter's stored value, before any channel or expression resolves it.
1238
+ *
1239
+ * `parameter.value` is deliberately the resolved one, so anything that has
1240
+ * to record or re-key what the author actually set — serialisation, the
1241
+ * alt-click gesture — must ask for the raw one instead. Writing the sampled
1242
+ * value back to disk would freeze an animated parameter at whatever frame
1243
+ * the save happened on.
1244
+ */
1245
+ rawParameterValue(name) {
1246
+ if (this.props[name]) return this.props[name].value;
1247
+ return this.parameters.find((parameter) => parameter.name === name)?.value;
1248
+ }
1249
+ /** Turn a parameter into an input pin, or back. */
1250
+ setParameterPromoted(name, promoted) {
1251
+ const parameter = this.parameters.find((p) => p.name === name);
1252
+ if (!parameter || parameter.promoted === promoted) return;
1253
+ parameter.promoted = promoted;
1254
+ if (promoted) {
1255
+ const port = this.in(name, parameter.value, {
1256
+ ...parameter.options,
1257
+ type: parameter.dataType,
1258
+ promoted: true
1259
+ });
1260
+ port.fromParameter = name;
1261
+ } else {
1262
+ const index = this.inputs.findIndex((p) => p.name === name);
1263
+ if (index >= 0) {
1264
+ [...this.inputs[index].connections].forEach((conn) => this.graph?.disconnect(conn.id));
1265
+ this.inputs.splice(index, 1);
1266
+ }
1267
+ }
1268
+ this.markDirty();
1269
+ }
1270
+ /**
1271
+ * Write a parameter and cook — unless the write would change nothing.
1272
+ *
1273
+ * It marked the node dirty unconditionally, and MW-OBSERVATORY-ART found
1274
+ * what that costs a panel: the series panel re-writes its filter on every
1275
+ * load, so **mounting it cooked the whole document to arrive at values it
1276
+ * already held.** Every panel that restores state on mount hits this, and
1277
+ * each of them would otherwise write the same guard.
1278
+ *
1279
+ * The equality test is on the RAW stored value, because that is what a write
1280
+ * replaces. Two things it must not treat as unchanged, both of which would
1281
+ * make this quietly wrong:
1282
+ *
1283
+ * - a write that **clears a declared default expression** changes the
1284
+ * resolved value even though the stored one may match, so a bound prop
1285
+ * is never skipped;
1286
+ * - an array or object, which is compared structurally rather than by
1287
+ * reference — a panel that hands back a copy of the same numbers is the
1288
+ * exact case this exists for, and `===` would call it a change.
1289
+ */
1290
+ setParameter(name, value) {
1291
+ const parameter = this.parameters.find((p) => p.name === name);
1292
+ if (!parameter) return;
1293
+ if (this.parameterWriteIsNoop(name, value)) return;
1294
+ parameter.value = value;
1295
+ this.markDirty();
1296
+ }
1297
+ /** Whether writing this value would leave the node exactly as it is. */
1298
+ parameterWriteIsNoop(name, value) {
1299
+ const prop = this.props[name];
1300
+ if (!prop || prop.expression || (prop.channel?.keys?.length ?? 0) > 0) return false;
1301
+ return sameStoredValue(prop.value, value);
1302
+ }
1303
+ // ============ Parameter carry-over ============
1304
+ /**
1305
+ * Snapshot every current setting and start reconciling against whatever the
1306
+ * next definition declares. Idempotent per retarget; the snapshot stays until
1307
+ * a cook completes, so a definition that fails to compile loses nothing.
1308
+ */
1309
+ beginParameterCarryOver() {
1310
+ const carried = /* @__PURE__ */ new Map();
1311
+ for (const parameter of this.parameters) {
1312
+ carried.set(`parameter:${parameter.name}`, {
1313
+ name: parameter.name,
1314
+ kind: "parameter",
1315
+ type: carryType(parameter.dataType),
1316
+ // Raw, not resolved: a retarget carries what the author set, and
1317
+ // `parameter.value` now resolves a channel or an expression.
1318
+ value: this.rawParameterValue(parameter.name),
1319
+ expression: this.props[parameter.name]?.expression,
1320
+ channel: this.props[parameter.name]?.channel
1321
+ });
1322
+ }
1323
+ for (const [name, prop] of Object.entries(this.props)) {
1324
+ if (carried.has(`parameter:${name}`)) continue;
1325
+ carried.set(`prop:${name}`, {
1326
+ name,
1327
+ kind: "prop",
1328
+ type: carryType(prop.type),
1329
+ value: prop.value,
1330
+ expression: prop.expression,
1331
+ channel: prop.channel
1332
+ });
1333
+ }
1334
+ this.carryOver = {
1335
+ carried,
1336
+ resolved: /* @__PURE__ */ new Set(),
1337
+ report: { kept: [], defaulted: [], dropped: [], retyped: [] }
1338
+ };
1339
+ this.parametersUsedDuringSetup.clear();
1340
+ }
1341
+ /** Give up on reconciling — the retarget itself failed. Values are left
1342
+ * exactly as they were and nothing is reported as dropped. */
1343
+ abandonParameterCarryOver() {
1344
+ this.carryOver = null;
1345
+ }
1346
+ hasPendingParameterCarryOver() {
1347
+ return this.carryOver !== null;
1348
+ }
1349
+ /**
1350
+ * Reconcile against a declaration list the caller already has, without
1351
+ * waiting for a cook. Runs the declarations through the ordinary `param()` /
1352
+ * `addParm()` path so the node ends up in the same state either way — this
1353
+ * is the same pre-seeding deserialization does, only reported.
1354
+ */
1355
+ applyParameterDeclarations(declarations) {
1356
+ if (!this.carryOver) return null;
1357
+ for (const declaration of declarations) {
1358
+ if (!declaration?.name) continue;
1359
+ if (declaration.kind === "prop") {
1360
+ this.addParm(declaration.name, {
1361
+ value: declaration.defaultValue,
1362
+ type: declaration.type
1363
+ });
1364
+ } else {
1365
+ this.param(declaration.name, declaration.defaultValue, {
1366
+ ...declaration.type ? { type: declaration.type } : {}
1367
+ });
1368
+ }
1369
+ }
1370
+ return this.finishParameterCarryOver();
1371
+ }
1372
+ /**
1373
+ * Close the reconciliation: anything the new definition never declared is
1374
+ * dropped from the node and reported with the value it held.
1375
+ */
1376
+ finishParameterCarryOver() {
1377
+ const carry = this.carryOver;
1378
+ if (!carry) return null;
1379
+ for (const entry of carry.carried.values()) {
1380
+ carry.report.dropped.push({
1381
+ name: entry.name,
1382
+ value: entry.value,
1383
+ ...entry.expression ? { expression: entry.expression } : {}
1384
+ });
1385
+ const index = this.parameters.findIndex((p) => p.name === entry.name);
1386
+ if (index >= 0) {
1387
+ if (this.parameters[index].promoted) this.setParameterPromoted(entry.name, false);
1388
+ const current = this.parameters.findIndex((p) => p.name === entry.name);
1389
+ if (current >= 0) this.parameters.splice(current, 1);
1390
+ }
1391
+ if (!carry.resolved.has(`prop:${entry.name}`)) delete this.props[entry.name];
1392
+ }
1393
+ this.carryOver = null;
1394
+ this.lastCarryOverReport = carry.report;
1395
+ if (carry.report.dropped.length > 0) {
1396
+ this.props = { ...this.props };
1397
+ this.updateTimeDependent();
1398
+ _Node.onPropParamsChanged?.(this.id);
1399
+ }
1400
+ return carry.report;
1401
+ }
1402
+ /** Called from param() for every declaration the new definition makes. */
1403
+ reconcileParameter(parameter, defaultValue, options) {
1404
+ const carry = this.carryOver;
1405
+ if (!carry) return;
1406
+ const key = `parameter:${parameter.name}`;
1407
+ if (carry.resolved.has(key)) return;
1408
+ carry.resolved.add(key);
1409
+ const declared = options.type ?? inferParamType(defaultValue);
1410
+ parameter.dataType = declared;
1411
+ const declaredType = carryType(declared);
1412
+ const crossKey = `prop:${parameter.name}`;
1413
+ const carried = carry.carried.get(key) ?? carry.carried.get(crossKey);
1414
+ if (!carried) {
1415
+ parameter.value = defaultValue;
1416
+ carry.report.defaulted.push(parameter.name);
1417
+ return;
1418
+ }
1419
+ carry.carried.delete(key);
1420
+ carry.carried.delete(crossKey);
1421
+ if (carryTypesMatch(carried.type, declaredType)) {
1422
+ parameter.value = carried.value;
1423
+ if (carried.expression || carried.channel) {
1424
+ this.props[parameter.name] = {
1425
+ ...this.props[parameter.name] ?? {},
1426
+ value: carried.value,
1427
+ ...carried.expression ? { expression: carried.expression } : {},
1428
+ ...carried.channel ? { channel: carried.channel } : {}
1429
+ };
1430
+ carry.resolved.add(crossKey);
1431
+ this.updateTimeDependent();
1432
+ }
1433
+ carry.report.kept.push(parameter.name);
1434
+ } else {
1435
+ parameter.value = defaultValue;
1436
+ carry.report.retyped.push({ name: parameter.name, from: carried.type, to: declaredType });
1437
+ }
1438
+ }
1439
+ /** Called from addParm() for every prop the new definition declares. */
1440
+ reconcileProp(name) {
1441
+ const carry = this.carryOver;
1442
+ if (!carry) return;
1443
+ const key = `prop:${name}`;
1444
+ if (carry.resolved.has(key)) return;
1445
+ carry.resolved.add(key);
1446
+ const prop = this.props[name];
1447
+ const declaredType = carryType(prop?.type);
1448
+ const crossKey = `parameter:${name}`;
1449
+ const carried = carry.carried.get(key) ?? carry.carried.get(crossKey);
1450
+ if (!carried) {
1451
+ carry.report.defaulted.push(name);
1452
+ return;
1453
+ }
1454
+ carry.carried.delete(key);
1455
+ carry.carried.delete(crossKey);
1456
+ if (carryTypesMatch(carried.type, declaredType)) {
1457
+ this.props[name] = {
1458
+ ...prop,
1459
+ value: carried.value,
1460
+ ...carried.expression ? { expression: carried.expression } : {},
1461
+ ...carried.channel ? { channel: carried.channel } : {}
1462
+ };
1463
+ if (carried.expression || carried.channel) this.updateTimeDependent();
1464
+ carry.report.kept.push(name);
1465
+ } else {
1466
+ carry.report.retyped.push({ name, from: carried.type, to: declaredType });
1467
+ }
1468
+ }
1469
+ // ============ Variadic Inputs ============
1470
+ setVariadic(defaultValue = null, options = {}) {
1471
+ this.variadic = true;
1472
+ this.variadicDefault = defaultValue;
1473
+ this.variadicOptions = options;
1474
+ this.syncVariadicPorts();
1475
+ }
1476
+ getVariadicInputs() {
1477
+ return this.inputs.filter((p) => p.variadic && !p.options.hidden);
1478
+ }
1479
+ syncVariadicPorts() {
1480
+ if (!this.variadic) return;
1481
+ const existingPorts = this.inputs.filter((p) => p.variadic);
1482
+ const usedCount = existingPorts.filter(
1483
+ (p) => p.connections.length > 0 || p.value !== null && p.value !== this.variadicDefault
1484
+ ).length;
1485
+ for (let i = 0; i < usedCount + 1; i++) {
1486
+ const port = this.in(`input_${i}`, this.variadicDefault, {
1487
+ ...this.variadicOptions,
1488
+ hidden: false
1489
+ });
1490
+ port.variadic = true;
1491
+ if (!port.onChange) {
1492
+ port.onChange = () => this.onUpdate?.();
1493
+ }
1494
+ }
1495
+ for (let i = usedCount + 1; i < existingPorts.length; i++) {
1496
+ if (existingPorts[i]) existingPorts[i].options.hidden = true;
1497
+ }
1498
+ }
1499
+ // ============ Path System ============
1500
+ /**
1501
+ * Get the full path of this node from the root
1502
+ * e.g., "/effects/blur1"
1503
+ */
1504
+ path() {
1505
+ const segments = [this.id];
1506
+ let current = this.parent;
1507
+ while (current) {
1508
+ segments.unshift(current.id);
1509
+ current = current.parent;
1510
+ }
1511
+ return "/" + segments.join("/");
1512
+ }
1513
+ /**
1514
+ * Get child nodes (for subnet types)
1515
+ * Override in SubnetNode to return actual children
1516
+ */
1517
+ children() {
1518
+ return this._children;
1519
+ }
1520
+ /**
1521
+ * Whether this node can contain children.
1522
+ *
1523
+ * Two sources, and both are legitimate: a subclass that overrides this
1524
+ * (`SubnetNode`), and a definition that declares a container. The class
1525
+ * route came first and stays, because the class nodes are a third authoring
1526
+ * style that is not going anywhere yet.
1527
+ */
1528
+ isNetwork() {
1529
+ return this.declaredContainer === "subnet";
1530
+ }
1531
+ /**
1532
+ * Find a node by relative path from this node
1533
+ * Supports: "child", "../sibling", "./self", "../../parent/child"
1534
+ */
1535
+ node(relativePath) {
1536
+ if (!relativePath) return null;
1537
+ if (relativePath.startsWith("/")) {
1538
+ return this.graph.nodeByPath(relativePath);
1539
+ }
1540
+ const segments = relativePath.split("/").filter((s) => s.length > 0);
1541
+ let current = this;
1542
+ for (const segment of segments) {
1543
+ if (!current) return null;
1544
+ if (segment === ".") {
1545
+ continue;
1546
+ }
1547
+ if (segment === "..") {
1548
+ current = current.parent;
1549
+ continue;
1550
+ }
1551
+ if (current.isNetwork()) {
1552
+ const child = current.children().find((c) => c.id === segment);
1553
+ if (child) {
1554
+ current = child;
1555
+ continue;
1556
+ }
1557
+ }
1558
+ return null;
1559
+ }
1560
+ return current;
1561
+ }
1562
+ /**
1563
+ * Compute relative path from this node to another node
1564
+ */
1565
+ relativePathTo(other) {
1566
+ const thisPath = this.path().split("/").filter((s) => s);
1567
+ const otherPath = other.path().split("/").filter((s) => s);
1568
+ let commonLength = 0;
1569
+ while (commonLength < thisPath.length && commonLength < otherPath.length && thisPath[commonLength] === otherPath[commonLength]) {
1570
+ commonLength++;
1571
+ }
1572
+ const ups = thisPath.length - commonLength;
1573
+ const downs = otherPath.slice(commonLength);
1574
+ const parts = [];
1575
+ for (let i = 0; i < ups; i++) {
1576
+ parts.push("..");
1577
+ }
1578
+ parts.push(...downs);
1579
+ return parts.join("/") || ".";
1580
+ }
1581
+ /**
1582
+ * Get nodes connected to this node's inputs
1583
+ */
1584
+ inputNodes() {
1585
+ const nodes = [];
1586
+ for (const input of this.inputs) {
1587
+ for (const conn of input.connections) {
1588
+ const node = this.graph.getNode(conn.from.nodeId);
1589
+ if (node && !nodes.includes(node)) {
1590
+ nodes.push(node);
1591
+ }
1592
+ }
1593
+ }
1594
+ return nodes;
1595
+ }
1596
+ /**
1597
+ * Get nodes connected to this node's outputs
1598
+ */
1599
+ outputNodes() {
1600
+ const nodes = [];
1601
+ for (const output of this.outputs) {
1602
+ for (const conn of output.connections) {
1603
+ const node = this.graph.getNode(conn.to.nodeId);
1604
+ if (node && !nodes.includes(node)) {
1605
+ nodes.push(node);
1606
+ }
1607
+ }
1608
+ }
1609
+ return nodes;
1610
+ }
1611
+ // ============ Pattern Matching (Globbing) ============
1612
+ /**
1613
+ * Convert a glob pattern to a regular expression
1614
+ * Supports: * (any chars), ? (single char), [abc] (char set), [!abc] (negated set)
1615
+ */
1616
+ globToRegex(pattern) {
1617
+ let regex = "";
1618
+ let i = 0;
1619
+ while (i < pattern.length) {
1620
+ const char = pattern[i];
1621
+ switch (char) {
1622
+ case "*":
1623
+ regex += ".*";
1624
+ break;
1625
+ case "?":
1626
+ regex += ".";
1627
+ break;
1628
+ case "[":
1629
+ let j = i + 1;
1630
+ let classContent = "";
1631
+ if (pattern[j] === "!") {
1632
+ classContent = "^";
1633
+ j++;
1634
+ }
1635
+ while (j < pattern.length && pattern[j] !== "]") {
1636
+ classContent += pattern[j];
1637
+ j++;
1638
+ }
1639
+ regex += "[" + classContent + "]";
1640
+ i = j;
1641
+ break;
1642
+ case ".":
1643
+ case "+":
1644
+ case "^":
1645
+ case "$":
1646
+ case "(":
1647
+ case ")":
1648
+ case "{":
1649
+ case "}":
1650
+ case "|":
1651
+ case "\\":
1652
+ regex += "\\" + char;
1653
+ break;
1654
+ default:
1655
+ regex += char;
1656
+ }
1657
+ i++;
1658
+ }
1659
+ return new RegExp("^" + regex + "$");
1660
+ }
1661
+ /**
1662
+ * Match direct children against a pattern
1663
+ * Pattern syntax:
1664
+ * * - match any characters
1665
+ * ? - match single character
1666
+ * [abc] - match character set
1667
+ * [!abc] - exclude character set
1668
+ * ^pattern - exclude matches (use with space: "* ^backup*")
1669
+ */
1670
+ glob(pattern) {
1671
+ const children = this.children();
1672
+ if (!pattern || pattern === "*") {
1673
+ return [...children];
1674
+ }
1675
+ const parts = pattern.split(/\s+/);
1676
+ const includePatterns = [];
1677
+ const excludePatterns = [];
1678
+ for (const part of parts) {
1679
+ if (part.startsWith("^")) {
1680
+ excludePatterns.push(this.globToRegex(part.slice(1)));
1681
+ } else {
1682
+ includePatterns.push(this.globToRegex(part));
1683
+ }
1684
+ }
1685
+ return children.filter((child) => {
1686
+ const included = includePatterns.length === 0 || includePatterns.some((regex) => regex.test(child.id));
1687
+ const excluded = excludePatterns.some((regex) => regex.test(child.id));
1688
+ return included && !excluded;
1689
+ });
1690
+ }
1691
+ /**
1692
+ * Recursively match all descendants against a pattern
1693
+ * Pattern syntax same as glob(), plus:
1694
+ * ** - match at any depth (prefix with **\/ to find nodes at any level)
1695
+ */
1696
+ recursiveGlob(pattern) {
1697
+ const results = [];
1698
+ const isAnyDepth = pattern.startsWith("**/");
1699
+ const searchPattern = isAnyDepth ? pattern.slice(3) : pattern;
1700
+ const search = (node, depth) => {
1701
+ const children = node.children();
1702
+ for (const child of children) {
1703
+ if (isAnyDepth) {
1704
+ if (this.globToRegex(searchPattern).test(child.id)) {
1705
+ results.push(child);
1706
+ }
1707
+ } else if (depth === 0) {
1708
+ if (this.globToRegex(searchPattern).test(child.id)) {
1709
+ results.push(child);
1710
+ }
1711
+ }
1712
+ if (child.isNetwork()) {
1713
+ search(child, isAnyDepth ? depth : depth - 1);
1714
+ }
1715
+ }
1716
+ };
1717
+ if (pattern === "**") {
1718
+ const collectAll = (node) => {
1719
+ for (const child of node.children()) {
1720
+ results.push(child);
1721
+ if (child.isNetwork()) {
1722
+ collectAll(child);
1723
+ }
1724
+ }
1725
+ };
1726
+ collectAll(this);
1727
+ return results;
1728
+ }
1729
+ const pathDepth = isAnyDepth ? 0 : pattern.split("/").length - 1;
1730
+ search(this, pathDepth);
1731
+ return results;
1732
+ }
1733
+ // ============ Behavior Toggles ============
1734
+ setBypass(value) {
1735
+ this.bypass = value;
1736
+ this.markDirty();
1737
+ }
1738
+ setCook(value) {
1739
+ if (value && this.parent) {
1740
+ this.parent.children().filter((n) => n !== this && n.cook).forEach((n) => n.setCook(false));
1741
+ }
1742
+ this.cook = value;
1743
+ if (value) this.graph.cookingNodes.add(this);
1744
+ else this.graph.cookingNodes.delete(this);
1745
+ this.markDirty();
1746
+ }
1747
+ /**
1748
+ * Set time dependency flag (called by expression compiler)
1749
+ * Time-dependent nodes are marked dirty on each frame
1750
+ */
1751
+ setTimeDependent(value) {
1752
+ this.isTimeDependent = value;
1753
+ }
1754
+ /**
1755
+ * Re-derive time dependency from every binding on the node.
1756
+ *
1757
+ * A keyed parameter is time-dependent for exactly the reason a
1758
+ * time-referencing expression is: its value changes with the frame, so a
1759
+ * per-frame cook has to recook the subgraph it feeds. One key is enough —
1760
+ * the value it holds is still a function of the frame everywhere else.
1761
+ */
1762
+ updateTimeDependent() {
1763
+ const hasTimeRef = Object.values(this.props).some(
1764
+ (prop) => prop.expression && expressionEngine.hasTimeReference(prop.expression) || !isEmptyChannel(prop.channel)
1765
+ );
1766
+ this.setTimeDependent(hasTimeRef);
1767
+ }
1768
+ shouldExecute() {
1769
+ if (this.bypass) return false;
1770
+ if (this.graph.cookingNodes.size > 0) {
1771
+ return this.cook || this.graph.isDownstreamOfCooking(this);
1772
+ }
1773
+ return true;
1774
+ }
1775
+ executeBypass() {
1776
+ this.inputs.forEach((input) => {
1777
+ if (input.portType === "param") {
1778
+ const matchingOutput = this.outputs.find((out) => out.name === input.name || this.outputs.length === 1);
1779
+ if (matchingOutput) matchingOutput.setValue(input.value);
1780
+ }
1781
+ });
1782
+ }
1783
+ // ============ Dirty Tracking ============
1784
+ markDirty() {
1785
+ this.graph.scheduler.markStale(this);
1786
+ }
1787
+ /**
1788
+ * The signal for the cook in flight, or null outside one.
1789
+ *
1790
+ * **Until 2026-09-09 Studio handed every node `new AbortController().signal`
1791
+ * and never aborted it**, so `context.signal` was inert: a node that checked
1792
+ * it — `series-source` does — could not be cancelled, and every long node
1793
+ * ran to completion whether or not its result was still wanted. The
1794
+ * generation check in `execute` then discarded the result, which is the
1795
+ * expensive half in the wrong order: measured on `cloud-volumes`, an
1796
+ * abandoned cook had already encoded and uploaded a 1.55 MB PNG before
1797
+ * anything noticed it was superseded.
1798
+ *
1799
+ * So the signal is real, and a node with expensive work owes itself a check
1800
+ * before committing to it. That is the only place preemption can be cheap.
1801
+ */
1802
+ get cookSignal() {
1803
+ for (const controller of this.cookAborts) {
1804
+ if (!controller.signal.aborted) return controller.signal;
1805
+ }
1806
+ return null;
1807
+ }
1808
+ /**
1809
+ * @internal The signal of the newest cook in flight, aborted or not.
1810
+ *
1811
+ * Unlike `cookSignal`, this keeps answering after the cook is aborted, which
1812
+ * is what a stage started late needs: a call made after the supersede must
1813
+ * see an aborted signal and not start, not see no signal and run unguarded.
1814
+ */
1815
+ get inFlightCookSignal() {
1816
+ let latest = null;
1817
+ for (const controller of this.cookAborts) latest = controller;
1818
+ return latest?.signal ?? null;
1819
+ }
1820
+ /** @internal Scheduler-owned invalidation primitive. */
1821
+ invalidateCook() {
1822
+ this.cookGeneration++;
1823
+ this.abortCooks("superseded");
1824
+ this.setCookState("stale");
1825
+ }
1826
+ /**
1827
+ * @internal Abandon whatever this node is doing.
1828
+ *
1829
+ * Separate from `invalidateCook` because the scheduler needs to stop
1830
+ * background work WITHOUT marking it stale — a preempted instance is not
1831
+ * dirty, it is unfinished, and telling the graph it is stale would put it
1832
+ * straight back in the queue it was just taken out of.
1833
+ */
1834
+ cancelCook(reason) {
1835
+ this.abortCooks(reason);
1836
+ }
1837
+ abortCooks(reason) {
1838
+ for (const controller of this.cookAborts) {
1839
+ if (!controller.signal.aborted) controller.abort(new Error(reason));
1840
+ }
1841
+ }
1842
+ /** @internal Scheduler-owned state transition. */
1843
+ setCookState(state) {
1844
+ if (this.cookState === state) return;
1845
+ this.cookState = state;
1846
+ this.graph.scheduler.notifyNodeStateChange();
1847
+ }
1848
+ /**
1849
+ * What the inputs are, exactly: the same key the early cutoff uses. See
1850
+ * `inputFingerprint.ts` for why this no longer samples. A connected input is
1851
+ * its producers' output versions, which move on every commit that is not
1852
+ * provably the same value — O(1) however large the image. A value set
1853
+ * directly on the port has no producer, so it is hashed in full. A
1854
+ * connection to something that keeps no versions (an annotation) falls back
1855
+ * to hashing every input's value.
1856
+ */
1857
+ calculateInputHash() {
1858
+ return this.inputVersionKey() ?? this.inputs.map((input) => valueFingerprint(input.value)).join("|");
1859
+ }
1860
+ get isDirty() {
1861
+ if (!this.hasExecuted || this.cookState !== "clean") return true;
1862
+ return this.calculateInputHash() !== this.lastInputHash;
1863
+ }
1864
+ /**
1865
+ * The last cook threw, and nothing has changed since it did.
1866
+ *
1867
+ * Separate from `isDirty` on purpose, and this is the second attempt: folding
1868
+ * it into `isDirty` closed the Viewer's cook loop and broke a contract the
1869
+ * scheduler tests already pinned — `requestOutput` retries a failed node,
1870
+ * which is the transient case (a fetch that timed out, a device lost) and is
1871
+ * right. The two callers are not the same thing wearing different names. An
1872
+ * explicit request means *give me this output now* and should retry; the
1873
+ * Viewer's `requestAnimationFrame` pump is a **poll** that was reaching the
1874
+ * same entry point every 100 ms, and a poll asking again on identical inputs
1875
+ * cannot learn anything.
1876
+ *
1877
+ * So the fix belongs at the poll, and the poll needs a question only the node
1878
+ * can answer. Measured 2026-09-08 by MW-OBSERVATORY-ART against a live sketch:
1879
+ * selecting a failing node produced 93 errors in 10 s — 9.3/sec, median gap
1880
+ * 103.7 ms, identical stack every time — each one a full `scheduler.flush()`
1881
+ * re-running the failing execute and re-fetching its inputs over the network,
1882
+ * for as long as the node stayed selected. `markDirty` is not on that path,
1883
+ * which is why auditing its call sites came back clean, and the Viewer's
1884
+ * overlap guard is why it held 61 fps and never looked like a hang.
1885
+ *
1886
+ * `hasExecuted` is deliberately not consulted: a node that throws on its very
1887
+ * first cook never sets it, and that is the reproduction case.
1888
+ */
1889
+ get hasSettledFailure() {
1890
+ if (this.cookState !== "error" || this.failedInputHash === null) return false;
1891
+ return this.calculateInputHash() === this.failedInputHash;
1892
+ }
1893
+ /**
1894
+ * Mark all downstream nodes as dirty (lazy propagation)
1895
+ */
1896
+ markDownstreamDirty() {
1897
+ this.graph.scheduler.markDownstreamStale(this);
1898
+ }
1899
+ /**
1900
+ * Request this node's output - triggers lazy evaluation
1901
+ * Executes dirty upstream dependencies first, then this node
1902
+ */
1903
+ async requestOutput() {
1904
+ if (!this.isDirty) return;
1905
+ if (this.cookState === "clean") this.markDirty();
1906
+ await this.graph.scheduler.flush();
1907
+ }
1908
+ // ============ Lifecycle ============
1909
+ setup() {
1910
+ }
1911
+ setFunction(fn) {
1912
+ this.nodeFunction = fn;
1913
+ this.hasExecuted = false;
1914
+ this.markDirty();
1915
+ }
1916
+ async execute() {
1917
+ const hasExecution = !!this.nodeFunction || this.onCook !== _Node.prototype.onCook;
1918
+ if (!hasExecution) {
1919
+ this.hasExecuted = true;
1920
+ this.setCookState("clean");
1921
+ return;
1922
+ }
1923
+ const needsInitialization = !this.hasExecuted;
1924
+ if (!this.shouldExecute() && !needsInitialization && !this.isDirty) {
1925
+ this.executeBypass();
1926
+ this.setCookState("clean");
1927
+ return;
1928
+ }
1929
+ if (!needsInitialization && !this.isDirty) {
1930
+ return;
1931
+ }
1932
+ this.evaluateAllExpressions();
1933
+ const startTime = performance.now();
1934
+ const startMemory = performance.memory?.usedJSHeapSize ?? 0;
1935
+ const generation = this.cookGeneration;
1936
+ const staging = /* @__PURE__ */ new Map();
1937
+ this.stagingStack.push(staging);
1938
+ const abort = new AbortController();
1939
+ this.cookAborts.add(abort);
1940
+ this.setCookState("cooking");
1941
+ let timeoutId;
1942
+ try {
1943
+ await Promise.race([
1944
+ Promise.resolve(this.onCook()),
1945
+ new Promise((_, reject) => {
1946
+ timeoutId = setTimeout(
1947
+ () => reject(new Error(`Node execution timeout after ${this.executionTimeout}ms`)),
1948
+ this.executionTimeout
1949
+ );
1950
+ })
1951
+ ]);
1952
+ if (generation === this.cookGeneration) {
1953
+ await this.callLifecycleHooks(needsInitialization);
1954
+ for (const [port, value] of staging) {
1955
+ this.commitOutputValue(port, value);
1956
+ }
1957
+ for (const output of this.outputs) {
1958
+ if (!staging.has(output)) this.bumpOutputVersion(output.id);
1959
+ }
1960
+ this.cutoffKey = this.inputVersionKey();
1961
+ this.selfDirty = false;
1962
+ this.error = null;
1963
+ this.hasExecuted = true;
1964
+ this.lastInputHash = this.cutoffKey ?? this.calculateInputHash();
1965
+ this.failedInputHash = null;
1966
+ const carryReport = this.finishParameterCarryOver();
1967
+ if (carryReport) _Node.onParametersRetargeted?.(this.id, carryReport);
1968
+ this.setCookState("clean");
1969
+ } else {
1970
+ this.setCookState("stale");
1971
+ }
1972
+ this.updateCookInfo(startTime, startMemory);
1973
+ } catch (thrown) {
1974
+ const err = asError(thrown);
1975
+ if (generation === this.cookGeneration) {
1976
+ this.cutoffKey = null;
1977
+ this.error = err;
1978
+ this.failedInputHash = this.calculateInputHash();
1979
+ this.setCookState("error");
1980
+ } else {
1981
+ this.setCookState("stale");
1982
+ }
1983
+ this.updateCookInfo(startTime, startMemory);
1984
+ const cancelled = err.message.includes("timeout") || generation !== this.cookGeneration;
1985
+ if (!cancelled) {
1986
+ console.error(`Error executing node ${this.id}: ${err.message}`, err);
1987
+ }
1988
+ } finally {
1989
+ if (timeoutId !== void 0) clearTimeout(timeoutId);
1990
+ const index = this.stagingStack.indexOf(staging);
1991
+ if (index >= 0) this.stagingStack.splice(index, 1);
1992
+ this.cookAborts.delete(abort);
1993
+ }
1994
+ }
1995
+ /**
1996
+ * Override this method in class-based nodes to implement cooking logic.
1997
+ * Base implementation calls nodeFunction if set (for code-based nodes).
1998
+ * Cook timing is handled automatically by execute().
1999
+ */
2000
+ onCook() {
2001
+ if (this.nodeFunction) {
2002
+ return this.nodeFunction(this, this.graph);
2003
+ }
2004
+ }
2005
+ /**
2006
+ * Update cook info after execution
2007
+ * Protected so subclasses can call it for manual cook tracking
2008
+ */
2009
+ updateCookInfo(startTime, startMemory = 0) {
2010
+ const endTime = performance.now();
2011
+ const cookTime = endTime - startTime;
2012
+ this.cookInfo.lastCookTime = cookTime;
2013
+ this.cookInfo.totalCookTime += cookTime;
2014
+ this.cookInfo.cookCount++;
2015
+ this.cookInfo.averageCookTime = this.cookInfo.totalCookTime / this.cookInfo.cookCount;
2016
+ this.cookInfo.lastCookTimestamp = Date.now();
2017
+ const endMemory = performance.memory?.usedJSHeapSize ?? 0;
2018
+ if (startMemory && endMemory) {
2019
+ const memoryUsed = endMemory - startMemory;
2020
+ if (memoryUsed > this.cookInfo.peakMemory) {
2021
+ this.cookInfo.peakMemory = memoryUsed;
2022
+ }
2023
+ }
2024
+ }
2025
+ /**
2026
+ * Start cook timing - returns start time to pass to endCook()
2027
+ * Use for class-based nodes that don't go through execute()
2028
+ */
2029
+ startCook() {
2030
+ return performance.now();
2031
+ }
2032
+ /**
2033
+ * End cook timing - call with the value returned from startCook()
2034
+ */
2035
+ endCook(startTime) {
2036
+ this.updateCookInfo(startTime, 0);
2037
+ }
2038
+ /**
2039
+ * Reset cook statistics
2040
+ */
2041
+ resetCookInfo() {
2042
+ this.cookInfo = {
2043
+ lastCookTime: 0,
2044
+ totalCookTime: 0,
2045
+ cookCount: 0,
2046
+ averageCookTime: 0,
2047
+ lastCookTimestamp: 0,
2048
+ peakMemory: 0
2049
+ };
2050
+ }
2051
+ async callLifecycleHooks(isFirstRun) {
2052
+ const isBrowserAPIError = (err) => {
2053
+ const msg = err?.message || String(err);
2054
+ return msg.includes("document is not defined") || msg.includes("window is not defined") || msg.includes("HTMLCanvasElement") || msg.includes("HTMLImageElement");
2055
+ };
2056
+ if (isFirstRun && this.onSetup) {
2057
+ try {
2058
+ await this.onSetup();
2059
+ } catch (err) {
2060
+ if (!isBrowserAPIError(err)) console.error(`Error in onSetup for ${this.id}:`, err);
2061
+ }
2062
+ }
2063
+ if (this.onUpdate) {
2064
+ try {
2065
+ await this.onUpdate();
2066
+ } catch (err) {
2067
+ if (!isBrowserAPIError(err)) console.error(`Error in onUpdate for ${this.id}:`, err);
2068
+ }
2069
+ }
2070
+ if (this.onRender) {
2071
+ try {
2072
+ await this.onRender();
2073
+ } catch (err) {
2074
+ if (!isBrowserAPIError(err)) console.error(`Error in onRender for ${this.id}:`, err);
2075
+ }
2076
+ }
2077
+ if (isFirstRun && this.onReady) {
2078
+ try {
2079
+ this.onReady();
2080
+ } catch (err) {
2081
+ if (!isBrowserAPIError(err)) console.error(`Error in onReady for ${this.id}:`, err);
2082
+ }
2083
+ }
2084
+ }
2085
+ // ============ Rename ============
2086
+ rename(newId) {
2087
+ if (!newId?.trim()) return this.id;
2088
+ const sanitizedId = newId.trim().replace(/\s+/g, "");
2089
+ if (!sanitizedId) return this.id;
2090
+ const uniqueId = this.graph.generateUniqueNodeId(sanitizedId, this.id);
2091
+ if (this.graph.renameElement(this.id, uniqueId)) this.markDirty();
2092
+ return this.id;
2093
+ }
2094
+ isPreviewValue(value) {
2095
+ return typeof HTMLCanvasElement !== "undefined" && value instanceof HTMLCanvasElement || typeof HTMLImageElement !== "undefined" && value instanceof HTMLImageElement;
2096
+ }
2097
+ // ============ Package Manager ============
2098
+ async require(packageName, version) {
2099
+ if (!this.graph.packageManager) throw new Error("PackageManager not initialized");
2100
+ return this.graph.packageManager.load(packageName, version);
2101
+ }
2102
+ get assets() {
2103
+ return this.graph.assetManager;
2104
+ }
2105
+ // ============ Utilities ============
2106
+ log(...args) {
2107
+ console.log(`[${this.id}]`, ...args);
2108
+ }
2109
+ resetPortTracking() {
2110
+ this.portsUsedDuringSetup.clear();
2111
+ }
2112
+ // ============ State Preservation ============
2113
+ preserveState() {
2114
+ return {
2115
+ inputs: this.inputs.map((p) => ({ id: p.id, name: p.name, value: p.value, connections: p.connections.map((c) => c.id) })),
2116
+ outputs: this.outputs.map((p) => ({ id: p.id, name: p.name, value: p.value, connections: p.connections.map((c) => c.id) })),
2117
+ props: Object.fromEntries(Object.entries(this.props).map(([k, p]) => [k, p.value])),
2118
+ bypass: this.bypass,
2119
+ cook: this.cook
2120
+ };
2121
+ }
2122
+ restoreState(state) {
2123
+ state.inputs?.forEach((s) => {
2124
+ const port = this.inputs.find((p) => p.id === s.id || p.name === s.name);
2125
+ if (port) port.value = s.value;
2126
+ });
2127
+ state.outputs?.forEach((s) => {
2128
+ const port = this.outputs.find((p) => p.id === s.id || p.name === s.name);
2129
+ if (port) port.value = s.value;
2130
+ });
2131
+ if (state.props) {
2132
+ Object.entries(state.props).forEach(([k, v]) => {
2133
+ if (this.props[k]) this.props[k].value = v;
2134
+ });
2135
+ }
2136
+ if (state.bypass !== void 0) this.setBypass(state.bypass);
2137
+ if (state.cook !== void 0) this.setCook(state.cook);
2138
+ }
2139
+ // ============ Port Cleanup ============
2140
+ cleanupUnusedPorts() {
2141
+ const used = this.portsUsedDuringSetup;
2142
+ this.inputs = this.inputs.filter((port) => {
2143
+ if (!used.has(`input_${port.name}`)) {
2144
+ port.connections.forEach((c) => this.graph.disconnect(c.id));
2145
+ this._portMap.delete(port.id);
2146
+ return false;
2147
+ }
2148
+ return true;
2149
+ });
2150
+ this.outputs = this.outputs.filter((port) => {
2151
+ if (!used.has(`output_${port.name}`)) {
2152
+ port.connections.forEach((c) => this.graph.disconnect(c.id));
2153
+ this._portMap.delete(port.id);
2154
+ return false;
2155
+ }
2156
+ return true;
2157
+ });
2158
+ this.portsUsedDuringSetup.clear();
2159
+ }
2160
+ // ============ Serialization ============
2161
+ toJSON() {
2162
+ const fullType = typeToPackagePath(this.type);
2163
+ const result = {
2164
+ id: this.id,
2165
+ type: fullType,
2166
+ position: [this.position.x, this.position.y]
2167
+ };
2168
+ if (!isStandardLibraryNode(fullType) && this.code) {
2169
+ result.code = this.code;
2170
+ }
2171
+ if (this.comment?.trim()) result.comment = this.comment;
2172
+ const props = Object.fromEntries(
2173
+ Object.entries(this.props).map(([k, p]) => {
2174
+ let v = p.value;
2175
+ if (p.type === "color" && isColorValue(v)) {
2176
+ const n = normalizeColor(v);
2177
+ v = [n.r, n.g, n.b, n.a ?? 1];
2178
+ }
2179
+ if (p.expression) {
2180
+ return [k, { value: v, expression: p.expression }];
2181
+ }
2182
+ return [k, v];
2183
+ })
2184
+ );
2185
+ if (Object.keys(props).length > 0) result.props = props;
2186
+ if (this.bypass) result.bypass = true;
2187
+ if (this.cook) result.cook = true;
2188
+ return result;
2189
+ }
2190
+ };
2191
+
2192
+ export {
2193
+ registerNodeMetadata,
2194
+ getNodeDisplayName,
2195
+ registerNodeClasses,
2196
+ packagePathToType,
2197
+ isStandardLibraryNode,
2198
+ getNodeClass,
2199
+ normalizeColor,
2200
+ colorToCss,
2201
+ isColorValue,
2202
+ CORE_TYPES2 as CORE_TYPES,
2203
+ isCoreType,
2204
+ normalizeType,
2205
+ canConnect,
2206
+ Node
2207
+ };
2208
+ //# sourceMappingURL=chunk-FCDXJ6EK.js.map