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