@field/cascade 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/AGENTS.md +36 -3
  2. package/CHANGELOG.md +401 -0
  3. package/DESIGN.md +14 -0
  4. package/README.md +41 -26
  5. package/dist/assets/CodeEditor-4O7FQ__R.js +623 -0
  6. package/dist/assets/CodePanel-BU-autmq.js +2 -0
  7. package/dist/assets/{CustomNodeDialog-BQbEhIwL.js → CustomNodeDialog-BRpDfOV5.js} +1 -1
  8. package/dist/assets/OpenGraphDialog-BwbRlC0X.js +1 -0
  9. package/dist/assets/ProjectSettings-DLOXLxcA.js +2 -0
  10. package/dist/assets/SettingsDialog-Bz8JxISH.js +1 -0
  11. package/dist/assets/{VersionHistoryDialog-D6VBzSQ0.js → VersionHistoryDialog-D5Z0nJ5-.js} +2 -2
  12. package/dist/assets/{core-BInLG4gw.js → core-Yqi0Ni61.js} +8 -8
  13. package/dist/assets/{cssMode-DfR2qmYG.js → cssMode-CxUzc4vp.js} +1 -1
  14. package/dist/assets/definitions-GreEMXLV.js +1 -0
  15. package/dist/assets/{editor.api-Ck8_vgQV.js → editor.api-BNFb25CX.js} +1 -1
  16. package/dist/assets/{freemarker2-fVn10Wn3.js → freemarker2-0ZDnWxeM.js} +1 -1
  17. package/dist/assets/geo-u0n_jOsD.js +1 -0
  18. package/dist/assets/{handlebars-BCSchLT2.js → handlebars-Cy18MoaD.js} +1 -1
  19. package/dist/assets/{html-CVpSv_k9.js → html-B-2Dx7nq.js} +1 -1
  20. package/dist/assets/{htmlMode-Da4ESTv0.js → htmlMode-DUAc9B_d.js} +1 -1
  21. package/dist/assets/{image-BLn--oSF.js → image-cAa-Nu9O.js} +3 -3
  22. package/dist/assets/index-5ZtDAuy2.js +220 -0
  23. package/dist/assets/index-7vae6YFC.css +1 -0
  24. package/dist/assets/index-client-DqYmKstU.js +1 -0
  25. package/dist/assets/{javascript-BpX0Gvcf.js → javascript-DeQsDqP5.js} +1 -1
  26. package/dist/assets/{jsonMode-CsLqaou7.js → jsonMode-BiFwJVSk.js} +1 -1
  27. package/dist/assets/legacy-CTEeXeqF.js +2 -0
  28. package/dist/assets/{liquid-efUz51Wf.js → liquid-BLoHH68a.js} +1 -1
  29. package/dist/assets/{lspLanguageFeatures-DCi5-K0n.js → lspLanguageFeatures-CNFgm1Kv.js} +1 -1
  30. package/dist/assets/math-DioIQOPJ.js +1 -0
  31. package/dist/assets/{mdx-DP_sjGVy.js → mdx-CFXQVNfs.js} +1 -1
  32. package/dist/assets/pop-DC_TQG9t.js +1 -0
  33. package/dist/assets/{python-aAuP3LUi.js → python-Cf7S7p2E.js} +1 -1
  34. package/dist/assets/{razor-7M6sMmC5.js → razor-CMHBcG-g.js} +1 -1
  35. package/dist/assets/studioScenes-8sq9FfFg.js +1 -0
  36. package/dist/assets/{tsMode-Bf4CnoYK.js → tsMode-BEih0GDj.js} +1 -1
  37. package/dist/assets/{typescript-ddhYBGyT.js → typescript-COEKYD_a.js} +1 -1
  38. package/dist/assets/{workers-X5la8DDT.js → workers-BNhAhMNn.js} +1 -1
  39. package/dist/assets/{xml-BzbXa3Ps.js → xml-CPiMpnPl.js} +1 -1
  40. package/dist/assets/{yaml-CXs-VzxL.js → yaml-Bgp1ITLR.js} +1 -1
  41. package/dist/cli/Graph-ZJJTRQTM.js +2975 -0
  42. package/dist/cli/chunk-2KXB47FP.js +115 -0
  43. package/dist/cli/chunk-35D343DC.js +1447 -0
  44. package/dist/cli/chunk-36P5K3SD.js +631 -0
  45. package/dist/cli/chunk-3JTJHF7V.js +267 -0
  46. package/dist/cli/chunk-4UDFYF5G.js +132 -0
  47. package/dist/cli/chunk-53NMBJAO.js +35 -0
  48. package/dist/cli/chunk-A4OAMOZH.js +310 -0
  49. package/dist/cli/chunk-BIJO5OFS.js +71 -0
  50. package/dist/cli/chunk-BP7UKF36.js +376 -0
  51. package/dist/cli/chunk-CKS2FQWR.js +89 -0
  52. package/dist/cli/chunk-CO22XHNG.js +593 -0
  53. package/dist/cli/chunk-D6BUOHUG.js +634 -0
  54. package/dist/cli/chunk-DEQSEFFH.js +153 -0
  55. package/dist/cli/chunk-DUIUFVU2.js +241 -0
  56. package/dist/cli/chunk-EUIAC3U3.js +3006 -0
  57. package/dist/cli/chunk-FCDXJ6EK.js +2208 -0
  58. package/dist/cli/chunk-GFS6W3C5.js +308 -0
  59. package/dist/cli/chunk-HFHWYL55.js +139 -0
  60. package/dist/cli/chunk-HSNOBIVT.js +263 -0
  61. package/dist/cli/chunk-ICH7N5PN.js +26 -0
  62. package/dist/cli/chunk-J7VK7MNF.js +418 -0
  63. package/dist/cli/chunk-KH4MUBKF.js +1551 -0
  64. package/dist/cli/chunk-MIIEFUGG.js +623 -0
  65. package/dist/cli/chunk-OWWBYLHC.js +136 -0
  66. package/dist/cli/chunk-POMMNKZZ.js +672 -0
  67. package/dist/cli/chunk-QBBKRNGY.js +90 -0
  68. package/dist/cli/chunk-QQPA2ZHH.js +273 -0
  69. package/dist/cli/chunk-RLCAJBVR.js +224 -0
  70. package/dist/cli/chunk-RQZ4SX5R.js +1125 -0
  71. package/dist/cli/chunk-RUU3YWXU.js +99 -0
  72. package/dist/cli/chunk-RXDIMXN5.js +98 -0
  73. package/dist/cli/chunk-SGJAET2L.js +197 -0
  74. package/dist/cli/chunk-VVGSLVSF.js +151 -0
  75. package/dist/cli/chunk-WD62JIP6.js +118 -0
  76. package/dist/cli/chunk-WIH4TEIS.js +104 -0
  77. package/dist/cli/chunk-WKURDVYP.js +1119 -0
  78. package/dist/cli/chunk-WPUDGEFY.js +20 -0
  79. package/dist/cli/chunk-XWDSKG7N.js +1301 -0
  80. package/dist/cli/chunk-YAT5Y25U.js +121 -0
  81. package/dist/cli/chunk-ZL4JTG5Y.js +37 -0
  82. package/dist/cli/cliCommands-UQSFGEHF.js +3464 -0
  83. package/dist/cli/commands-Q23M26QM.js +1292 -0
  84. package/dist/cli/definitions-HF2FTHYU.js +32 -0
  85. package/dist/cli/doctorCommand-B7BPQMN6.js +76 -0
  86. package/dist/cli/edit-JHCPTQFT.js +28 -0
  87. package/dist/cli/editorCommands-UIF5YLUZ.js +33 -0
  88. package/dist/cli/encode-HAUZTYPT.js +17 -0
  89. package/dist/cli/frames-LSAUBZ7P.js +16 -0
  90. package/dist/cli/geo-4RNK3QVN.js +43 -0
  91. package/dist/cli/graphCommands-6BMAKGQE.js +184 -0
  92. package/dist/cli/graphFile-VPSQ6OF7.js +286 -0
  93. package/dist/cli/headlessCanvas-5XLDZT7M.js +15 -0
  94. package/dist/cli/helpCommand-3VRE3KVO.js +155 -0
  95. package/dist/cli/index.js +106 -14207
  96. package/dist/cli/math-ZRMLKU2D.js +16 -0
  97. package/dist/cli/mcp-ZLEEMYGF.js +164 -0
  98. package/dist/cli/parity-KINARCP2.js +328 -0
  99. package/dist/cli/pop-USDFXB7H.js +39 -0
  100. package/dist/cli/project-K3QIPGBB.js +12 -0
  101. package/dist/cli/projectDefinition-DMTZN2NE.js +27 -0
  102. package/dist/cli/projectRuntime-Z5PJOH47.js +48 -0
  103. package/dist/cli/registerStandardNodes-S2VLMF6J.js +3081 -0
  104. package/dist/cli/render-YURAPPUI.js +436 -0
  105. package/dist/cli/runCommand-PI764LEG.js +198 -0
  106. package/dist/cli/runner-RA6UKEPD.js +37 -0
  107. package/dist/cli/runtime/image.ts +277 -0
  108. package/dist/cli/runtime/stage.ts +74 -11
  109. package/dist/cli/stageRunner-KUNIMQKJ.js +10 -0
  110. package/dist/cli/verbs-RCE3K4VQ.js +19 -0
  111. package/dist/cli/webBuild-GT4PZT5D.js +37 -0
  112. package/dist/contracts/bundle.d.ts +75 -0
  113. package/dist/contracts/bundle.js +112 -0
  114. package/dist/contracts/definition.d.ts +67 -4
  115. package/dist/contracts/document.d.ts +29 -0
  116. package/dist/contracts/documentFormat.d.ts +18 -0
  117. package/dist/contracts/documentFormat.js +195 -0
  118. package/dist/contracts/editor.d.ts +189 -0
  119. package/dist/contracts/editor.js +2 -0
  120. package/dist/contracts/index.d.ts +3 -0
  121. package/dist/contracts/index.js +3 -0
  122. package/dist/contracts/validate.js +59 -3
  123. package/dist/contracts/values.d.ts +3 -3
  124. package/dist/contracts/values.js +1 -0
  125. package/dist/image.d.ts +141 -0
  126. package/dist/image.js +127 -0
  127. package/dist/index.html +4 -4
  128. package/dist/player/app.js +17 -5
  129. package/dist/player/editor.js +1919 -0
  130. package/dist/player/editorEngine.js +477 -0
  131. package/dist/runtime/builtins/core/bundle.d.ts +70 -0
  132. package/dist/runtime/builtins/core/bundle.js +81 -0
  133. package/dist/runtime/builtins/core/index.d.ts +3 -2
  134. package/dist/runtime/builtins/core/index.js +6 -2
  135. package/dist/runtime/builtins/core/routing.d.ts +27 -0
  136. package/dist/runtime/builtins/core/routing.js +28 -0
  137. package/dist/runtime/builtins/headless.d.ts +19 -0
  138. package/dist/runtime/builtins/headless.js +25 -0
  139. package/dist/runtime/builtins/image/contactSheet.d.ts +226 -0
  140. package/dist/runtime/builtins/image/contactSheet.js +199 -0
  141. package/dist/runtime/builtins/image/index.d.ts +9 -0
  142. package/dist/runtime/builtins/image/index.js +9 -0
  143. package/dist/runtime/edit/document.d.ts +94 -0
  144. package/dist/runtime/edit/document.js +194 -0
  145. package/dist/runtime/edit/graphEditor.d.ts +223 -0
  146. package/dist/runtime/edit/graphEditor.js +893 -0
  147. package/dist/runtime/edit/index.d.ts +12 -0
  148. package/dist/runtime/edit/index.js +6 -0
  149. package/dist/runtime/edit/layout.d.ts +50 -0
  150. package/dist/runtime/edit/layout.js +126 -0
  151. package/dist/runtime/edit/operations.d.ts +65 -0
  152. package/dist/runtime/edit/operations.js +57 -0
  153. package/dist/runtime/edit/parameters.d.ts +75 -0
  154. package/dist/runtime/edit/parameters.js +284 -0
  155. package/dist/runtime/edit/read.d.ts +35 -0
  156. package/dist/runtime/edit/read.js +65 -0
  157. package/dist/runtime/editor/index.d.ts +104 -0
  158. package/dist/runtime/editor/index.js +439 -0
  159. package/dist/runtime/expressions/bundlePaths.d.ts +37 -0
  160. package/dist/runtime/expressions/bundlePaths.js +55 -0
  161. package/dist/runtime/expressions/index.d.ts +2 -0
  162. package/dist/runtime/expressions/index.js +1 -0
  163. package/dist/runtime/index.d.ts +2 -0
  164. package/dist/runtime/index.js +2 -0
  165. package/dist/runtime/params/animator.d.ts +5 -0
  166. package/dist/runtime/params/animator.js +16 -0
  167. package/dist/runtime/runtime.js +238 -12
  168. package/dist/runtime/seed.d.ts +29 -0
  169. package/dist/runtime/seed.js +52 -0
  170. package/dist/runtime/types.d.ts +12 -0
  171. package/dist/stage.js +43 -9
  172. package/dist/studio/panel.d.ts +2 -0
  173. package/doc/CLI_REFERENCE.md +171 -0
  174. package/doc/EDITOR_EXPORT.md +235 -0
  175. package/doc/HEADLESS_GPU.md +27 -0
  176. package/doc/MCP.md +69 -0
  177. package/doc/NODE_REFERENCE.md +208 -0
  178. package/doc/PROJECT_AUTHORING.md +258 -1
  179. package/package.json +29 -8
  180. package/dist/assets/CodeEditor-BEk-kIBd.js +0 -623
  181. package/dist/assets/CodePanel-CeSdjXkg.js +0 -2
  182. package/dist/assets/ExportDialog-CjTbbTZJ.js +0 -358
  183. package/dist/assets/ExportDialog-DOq0flMV.css +0 -1
  184. package/dist/assets/OpenGraphDialog-DCwH9itM.js +0 -1
  185. package/dist/assets/ProjectSettings-Dg7M5xt9.js +0 -2
  186. package/dist/assets/SettingsDialog-e7GgsjB9.js +0 -1
  187. package/dist/assets/definitions-DZpOjWKR.js +0 -1
  188. package/dist/assets/geo-D2VPjAdt.js +0 -1
  189. package/dist/assets/index-CMiBdEuT.js +0 -212
  190. package/dist/assets/index-DMIuml9a.css +0 -1
  191. package/dist/assets/index-client-BI2H82FJ.js +0 -1
  192. package/dist/assets/legacy-ZA7textG.js +0 -2
  193. package/dist/assets/math-B6hpxPJO.js +0 -1
  194. package/dist/assets/pop-CYUIjT6x.js +0 -1
  195. package/dist/assets/projectSettingsApi-C1WvIwfP.js +0 -1
package/doc/MCP.md ADDED
@@ -0,0 +1,69 @@
1
+ # MCP server
2
+
3
+ `cascade mcp [project-directory]` serves the CLI as MCP tools over stdio, so an agent calls `set` instead of shelling out and parsing text. The tools are the CLI's commands under the same names, and every result is the same `--json` envelope (`{ ok, data, warnings }`, or `{ ok: false, error, warnings }` with `isError` set). Graph paths are relative to the project directory and may not leave it.
4
+
5
+ ## Set it up in Claude Code
6
+
7
+ From the project directory:
8
+
9
+ ```sh
10
+ claude mcp add cascade -- npx --no-install cascade mcp .
11
+ ```
12
+
13
+ Or in the project's `.mcp.json`, so every session in the project has it:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "cascade": { "command": "npx", "args": ["--no-install", "cascade", "mcp", "."] }
19
+ }
20
+ }
21
+ ```
22
+
23
+ With a global install (`npm i -g @field/cascade`), use `"command": "cascade", "args": ["mcp", "."]`. The tools then appear as `mcp__cascade__set`, `mcp__cascade__render` and so on.
24
+
25
+ ## Tools
26
+
27
+ | Tool | CLI equivalent |
28
+ | --- | --- |
29
+ | `graph_list`, `graph_read`, `graph_create` | `cascade graph list \| read \| create` |
30
+ | `node_add`, `node_remove` | `cascade node add \| rm` |
31
+ | `connect`, `disconnect` | `cascade connect \| disconnect` |
32
+ | `set` | `cascade set` (value, `expression`, `keys`, `removeKeys`, `interpolation`, `unset`) |
33
+ | `subnet_create`, `subnet_move`, `subnet_dissolve` | `cascade subnet create \| move \| dissolve` |
34
+ | `edit` | `cascade edit` (a batch of operations, all or nothing) |
35
+ | `check`, `inspect` | `cascade check \| inspect` |
36
+ | `cook` | `cascade run <graph> [--node <id>]` |
37
+ | `render` | `cascade run <graph> --frames … [--fps] [--out] [--node]… [--scene]` |
38
+ | `export` | `cascade export <graph> --plan \| --out <dir>` |
39
+ | `lock`, `replay`, `locks` | `cascade lock <graph> <node> --name`, `cascade replay <id> [--live]`, `cascade locks` |
40
+ | `ladder`, `pick` | `cascade ladder <graph> <node> --name --vary node.prop=a,b,c \| --seeds a,b,c` (`vary` is `{ "node.prop": [a, b, c] }`), `cascade pick <ladder> <n>` |
41
+ | `node_help` | `cascade help node [<type>]` (a node type's declaration; every type without one) |
42
+ | `doctor` | `cascade doctor [--fix]` (Python environment, stage interpreter, ffmpeg, canvas; `fix` builds the environment) |
43
+
44
+ The editing tools take `dryRun`. Reads and edits run inside the server through the same functions the CLI commands call. Cooking, rendering, checking, exporting and the lock tools run `cascade <command> --json` as one child process each, so a long render leaves nothing behind in the server and returns exactly what the CLI would print. Calls run one at a time, so two edits to one file never interleave.
45
+
46
+ Each tool is an `mcp` entry on its command's row in `src/cli/verbs.ts`, with its description and input schema, so a tool cannot name a command this build does not have (`tests/cli-verbs.test.ts` checks every tool against the table and this page).
47
+
48
+ ## A session
49
+
50
+ ```text
51
+ graph_read { "graph": "index.cascade" }
52
+ node_add { "graph": "index.cascade", "type": "Transform", "set": { "rotate": 0.4 } }
53
+ connect { "graph": "index.cascade", "from": "motif.output_0", "to": "transform.geometry" }
54
+ set { "graph": "index.cascade", "target": "render.opacity", "keys": [{ "frame": 1, "value": 0.25 }, { "frame": 48, "value": 1 }] }
55
+ render { "graph": "index.cascade", "frames": "1-48", "out": "renders/check" }
56
+ ```
57
+
58
+ See `doc/CLI_REFERENCE.md` for what each operation guarantees about diffs, placement and subnet boundaries.
59
+
60
+ ## Another project, per call
61
+
62
+ Every tool takes an optional `project`: the project directory for that call, absolute or relative to the directory the server was started in. Without it nothing changes. With it, the call's paths (`graph`, `out`, `scene`, `directory`) resolve against that project and may not leave it, and `cook`, `render`, `check` and the others run their CLI child there. So one server started in `commons` can edit and render a sketch next to it:
63
+
64
+ ```text
65
+ graph_read { "project": "/Users/me/Documents/Cascade/cloud-volumes", "graph": "index.cascade" }
66
+ cook { "project": "../cloud-volumes", "graph": "index.cascade", "out": "renders/check" }
67
+ ```
68
+
69
+ A `project` that is not a directory is refused.
@@ -79,6 +79,24 @@ Runs on `portable`.
79
79
  | `result` | `any` | | |
80
80
  | `history` | `array` | | |
81
81
 
82
+ ### `cascade.core.Frozen`
83
+
84
+ Frozen — Emits a frozen snapshot of a data source's chosen values
85
+
86
+ Runs on `portable`.
87
+
88
+ **Props**
89
+
90
+ | name | type | default | range |
91
+ | --- | --- | --- | --- |
92
+ | `value` | `any` | `null` | |
93
+
94
+ **Outputs**
95
+
96
+ | name | type | default | range |
97
+ | --- | --- | --- | --- |
98
+ | `value` | `any` | | |
99
+
82
100
  ### `cascade.core.Input`
83
101
 
84
102
  Input — Defines a public graph or subnet input
@@ -173,6 +191,54 @@ Runs on `portable`.
173
191
  | --- | --- | --- | --- |
174
192
  | `output` | `any` | | |
175
193
 
194
+ ### `cascade.core.Pack`
195
+
196
+ Pack — Collects inputs into a bundle, one named field each
197
+
198
+ Runs on `portable`.
199
+
200
+ **Inputs**
201
+
202
+ | name | type | default | range |
203
+ | --- | --- | --- | --- |
204
+ | `inputs` | `any` | | |
205
+
206
+ **Props**
207
+
208
+ | name | type | default | range |
209
+ | --- | --- | --- | --- |
210
+ | `names` | `string` | `""` | |
211
+
212
+ **Outputs**
213
+
214
+ | name | type | default | range |
215
+ | --- | --- | --- | --- |
216
+ | `bundle` | `bundle` | | |
217
+
218
+ ### `cascade.core.Pick`
219
+
220
+ Pick — Reads one field of a bundle
221
+
222
+ Runs on `portable`.
223
+
224
+ **Inputs**
225
+
226
+ | name | type | default | range |
227
+ | --- | --- | --- | --- |
228
+ | `bundle` | `bundle` | | |
229
+
230
+ **Props**
231
+
232
+ | name | type | default | range |
233
+ | --- | --- | --- | --- |
234
+ | `field` | `string` | `""` | |
235
+
236
+ **Outputs**
237
+
238
+ | name | type | default | range |
239
+ | --- | --- | --- | --- |
240
+ | `value` | `any` | | |
241
+
176
242
  ### `cascade.core.Previous`
177
243
 
178
244
  Previous — The previous step's state, inside a Feedback container. The first step gets the container's initial value.
@@ -523,6 +589,39 @@ Runs on `portable`.
523
589
  | --- | --- | --- | --- |
524
590
  | `geometry` | `geometry` | | |
525
591
 
592
+ ### `cascade.image.ContactSheet`
593
+
594
+ Contact Sheet — Lay images out as one labelled grid.
595
+
596
+ Runs on `portable`.
597
+
598
+ **Inputs**
599
+
600
+ | name | type | default | range |
601
+ | --- | --- | --- | --- |
602
+ | `images` | `image` | | |
603
+ | `list` | `array` | | |
604
+
605
+ **Props**
606
+
607
+ | name | type | default | range |
608
+ | --- | --- | --- | --- |
609
+ | `columns` | `int` | `0` | 0…64 |
610
+ | `gap` | `int` | `16` | 0…512 |
611
+ | `background` | `color` | `[1,1,1,1]` | |
612
+ | `cellWidth` | `int` | `0` | 0…8192 |
613
+ | `labels` | `string` | `""` | |
614
+ | `labelSize` | `int` | `14` | 6…128 |
615
+ | `labelColor` | `color` | `[0.1,0.1,0.1,1]` | |
616
+ | `filename` | `string` | `"contact-sheet.png"` | |
617
+
618
+ **Outputs**
619
+
620
+ | name | type | default | range |
621
+ | --- | --- | --- | --- |
622
+ | `image` | `image` | | |
623
+ | `asset` | `asset` | | |
624
+
526
625
  ### `cascade.math.Binary`
527
626
 
528
627
  Binary Math — Two values, one operation. Component-wise, and a scalar broadcasts against a vector.
@@ -907,6 +1006,115 @@ Runs on `portable`.
907
1006
  | --- | --- | --- | --- |
908
1007
  | `scene` | `scene` | | |
909
1008
 
1009
+ ## Assertions, headless only
1010
+
1011
+ `cascade.assert.*` fail a `cascade run` (non-zero exit, the message in
1012
+ `run --json`'s `error.message`) when their check does not hold, and pass
1013
+ their input through. The CLI registers them; Studio does not yet, and shows
1014
+ them as unresolved. An image wired only into assertions still counts as the
1015
+ graph's final image for `run --out`.
1016
+
1017
+ ### `cascade.assert.Equals`
1018
+
1019
+ Assert Equals — Fails the run unless value equals expected (JSON-equal; numbers within tolerance); passes value through
1020
+
1021
+ Runs on `portable`.
1022
+
1023
+ **Inputs**
1024
+
1025
+ | name | type | default | range |
1026
+ | --- | --- | --- | --- |
1027
+ | `value` | `any` | `null` | |
1028
+
1029
+ **Props**
1030
+
1031
+ | name | type | default | range |
1032
+ | --- | --- | --- | --- |
1033
+ | `expected` | `any` | `null` | |
1034
+ | `tolerance` | `float` | `0` | |
1035
+ | `message` | `string` | `""` | |
1036
+
1037
+ **Outputs**
1038
+
1039
+ | name | type | default | range |
1040
+ | --- | --- | --- | --- |
1041
+ | `value` | `any` | | |
1042
+
1043
+ ### `cascade.assert.NotBlank`
1044
+
1045
+ Assert Not Blank — Fails the run when an image (PNG or PPM) is one flat colour or fully transparent; passes the image through
1046
+
1047
+ Runs on `portable`.
1048
+
1049
+ **Inputs**
1050
+
1051
+ | name | type | default | range |
1052
+ | --- | --- | --- | --- |
1053
+ | `image` | `image` | | |
1054
+
1055
+ **Props**
1056
+
1057
+ | name | type | default | range |
1058
+ | --- | --- | --- | --- |
1059
+ | `tolerance` | `int` | `0` | 0…255 |
1060
+ | `message` | `string` | `""` | |
1061
+
1062
+ **Outputs**
1063
+
1064
+ | name | type | default | range |
1065
+ | --- | --- | --- | --- |
1066
+ | `image` | `image` | | |
1067
+
1068
+ ### `cascade.assert.Range`
1069
+
1070
+ Assert Range — Fails the run unless value is a finite number in [min, max]; passes value through
1071
+
1072
+ Runs on `portable`.
1073
+
1074
+ **Inputs**
1075
+
1076
+ | name | type | default | range |
1077
+ | --- | --- | --- | --- |
1078
+ | `value` | `float` | `0` | |
1079
+
1080
+ **Props**
1081
+
1082
+ | name | type | default | range |
1083
+ | --- | --- | --- | --- |
1084
+ | `min` | `float` | `0` | |
1085
+ | `max` | `float` | `1` | |
1086
+ | `message` | `string` | `""` | |
1087
+
1088
+ **Outputs**
1089
+
1090
+ | name | type | default | range |
1091
+ | --- | --- | --- | --- |
1092
+ | `value` | `float` | | |
1093
+
1094
+ ### `cascade.assert.True`
1095
+
1096
+ Assert True — Fails the run unless value is true (wire a condition, or give the prop an expression); passes value through
1097
+
1098
+ Runs on `portable`.
1099
+
1100
+ **Inputs**
1101
+
1102
+ | name | type | default | range |
1103
+ | --- | --- | --- | --- |
1104
+ | `value` | `bool` | `false` | |
1105
+
1106
+ **Props**
1107
+
1108
+ | name | type | default | range |
1109
+ | --- | --- | --- | --- |
1110
+ | `message` | `string` | `""` | |
1111
+
1112
+ **Outputs**
1113
+
1114
+ | name | type | default | range |
1115
+ | --- | --- | --- | --- |
1116
+ | `value` | `bool` | | |
1117
+
910
1118
  ## Studio node libraries
911
1119
 
912
1120
  Class-based nodes, available in Studio. Their parameters are declared in the
@@ -83,6 +83,13 @@ my-artwork/
83
83
 
84
84
  Keep generated output in a project-specific directory such as `renders/`. Keep reusable algorithms in `nodes/` or a normal TypeScript package rather than in Studio components.
85
85
 
86
+ Before writing OKLCH conversions, a seeded generator, noise, paper, a pen plot,
87
+ an ffmpeg call, a Cairn or Observatory loader or a disk cache, use the sketch
88
+ kit, `@field/cascade-kit` (`packages/kit`, a `file:` dependency). It also
89
+ generates the stubs for using another project's nodes without a symlink
90
+ (`npx cascade-kit nodes <provider>`). Read [KIT.md](KIT.md). The kit is
91
+ outside the core: Cascade never imports it.
92
+
86
93
  ## Add a project panel
87
94
 
88
95
  A project can provide an optional dockable Studio panel without adding UI code to Cascade. Create `panels/asset-browser/index.ts`:
@@ -191,6 +198,19 @@ image-api:
191
198
 
192
199
  The proxy accepts HTTPS only, refuses undeclared hosts, strips caller-supplied authorization headers, and never returns the credential value to the page. Studio defers its initial cook when a required credential is missing and links to File → Project Settings.
193
200
 
201
+ ## Design the graph, not one big node
202
+
203
+ A piece is several nodes, each doing one job: load the data, lay it out, colour it, draw it, export it. The usage survey of November 2026 found the opposite in most projects (1 to 5 nodes of 300+ lines each over `lib/`), which leaves the graph as a parameter panel around opaque code. The graph is the record Marcus inspects, so each node boundary should be a place worth looking at, with the parameters that matter on it.
204
+
205
+ Clean graph design is clean code design:
206
+
207
+ - **One responsibility per node**, named for what it does (`layout_rings`, not `main`).
208
+ - **Data flows through typed ports**, never through module-level state or files one node writes and another reads behind the graph's back.
209
+ - **What varies is a parameter**; seeds, counts and colours are on the node, not constants in the code.
210
+ - **Shared helpers go in `lib/` or the kit** (`KIT.md`); the node is the unit you compose and reuse.
211
+
212
+ Split a node when it does two of load, generate, style, render and export, or when its `execute` grows past about 150 lines.
213
+
194
214
  ## Add a custom node
195
215
 
196
216
  Before writing one, check whether you need one. A parameter can hold an expression, so anything time-based is usually a parameter rather than a node. See [Animate a parameter](#animate-a-parameter). A node earns its place when it produces geometry or pixels, or when several parameters share a computation. The built-in library is listed in `NODE_REFERENCE.md`, generated from the definitions themselves; read that before searching for what a node does.
@@ -269,6 +289,58 @@ The reason is not tidiness. Two floats cannot connect to a `vec2` output, take t
269
289
 
270
290
  Keep them separate only when the components differ in kind or in range — a `width` and a `depth` that mean different things are two props, not a `vec2`.
271
291
 
292
+ #### Bundle ports
293
+
294
+ When a node would fan out more than three or four outputs that travel together, export one `bundle` instead: a single port carrying named fields. Each field is any Cascade type; an image or cache entry travels as its reference (`ImageRef`, `AssetRef`), never its pixels.
295
+
296
+ ```ts
297
+ // Producer: declares the fields it carries, and optionally a schema name.
298
+ outputs: {
299
+ format: {
300
+ kind: 'data', type: 'bundle', schema: 'format',
301
+ fields: {
302
+ size: 'vec2i',
303
+ size_mm: 'vec2',
304
+ dpi: { type: 'float', description: 'Resolution of the working canvas' },
305
+ paper: 'string',
306
+ },
307
+ },
308
+ },
309
+ // in execute:
310
+ context.outputs.format.set({ size, size_mm, dpi, paper });
311
+ ```
312
+
313
+ ```ts
314
+ // Consumer: declares the fields it reads.
315
+ inputs: {
316
+ format: { kind: 'data', type: 'bundle', schema: 'format', fields: ['size', 'paper'] },
317
+ },
318
+ // in execute:
319
+ const { size, paper } = context.inputs.format ?? {};
320
+ ```
321
+
322
+ - **`fields` on an output** maps each field to its type. A producer may set only declared fields; a declared field may be left unset. Without `fields` the bundle is untyped and nothing can check it.
323
+ - **`fields` on an input** lists the fields the node reads. The node then sees only those, and Studio re-cooks it only when one of them changed: a change to `dpi` does not re-cook a consumer that reads `size` and `paper`. Without `fields` a consumer depends on the whole bundle. Field access is not tracked at run time, so declare the selection when the node is expensive.
324
+ - **`schema`** names the bundle's shape. Two named schemas must match to connect; a port without one takes any bundle.
325
+ - **Expressions** read a field of a wired bundle input with a dotted path: `ch("format.dpi")`, `chv("format.size_mm")`, `chs("format.paper")`. The path names an input on the same node, not another node's output, so the wire is the dependency and the producer always cooks first. `ch("/spread-format/format.dpi")` is refused by `cascade check`, which says which wire to add. A field read in an expression counts as a declared field for caching.
326
+ - **`cascade.core.Pick`** reads one field (`field` prop) for a node that wants a single value, and **`cascade.core.Pack`** builds a bundle from loose values, one name per input in its comma-separated `names` prop. Pick is cheap and its output only changes when the picked field does, so it is also how a node that cannot declare fields depends on one.
327
+ - **`cascade check`** reports a field that the producer does not declare, in a consumer's `fields`, in an expression, or on a Pick, with the nearest declared name: `dot-layer.radius reads unknown bundle field "dpu" — did you mean "dpi"?`.
328
+ - **Studio** draws a bundle as one port in the neutral wire colour. Hovering it lists every field with its type and current value, and the Inspector shows each field with its own control.
329
+
330
+ A bundle passed to a Python stage is an ordinary JSON object, so the worker receives a `dict`; reference fields arrive as their small JSON form, e.g. `args["format"]["preview"]["path"]`. A stage that returns a dict can be set straight onto a bundle output:
331
+
332
+ ```ts
333
+ const result = await runStage('signals', { format: context.inputs.format });
334
+ context.outputs.signals.set(result as Record<string, unknown>);
335
+ ```
336
+
337
+ ```python
338
+ def signals(args):
339
+ fmt = args["format"] # a dict
340
+ w, h = fmt["size"]
341
+ return {"brightness": {"path": cache_png(...), "size": [w, h], ...}, "edge_count": 412}
342
+ ```
343
+
272
344
  ### dynamic
273
345
 
274
346
  A dynamic module declares its interface imperatively, inside `execute`, which runs once to discover the interface and again on every cook. There is no `definition` export; `runsOn` and `icon` are plain exports.
@@ -293,7 +365,7 @@ export async function execute(node: any) {
293
365
 
294
366
  `param()` is a method on the node the engine hands to `execute`, not an import. It is re-declared on every cook, so it never resets a value, an expression, or a keyframe someone set: reading `.value` gives the *resolved* value for the current frame, whichever of the three bindings is in force. The loader imports the module as real JavaScript, so top-level code does run at import time. Keep dynamic modules free of top-level effects and put interface declaration and cooking inside `execute`.
295
367
 
296
- Prefer `new OffscreenCanvas(width, height)` to `document.createElement('canvas')` when both hosts can run the node. A portable declaration still requires output parity checks; installing Skia does not override a browser-only declaration. Use Python for libraries or tools that require a server. Measure transport, serialization, and cold-start costs; persistent workers can avoid repeated process/model startup.
368
+ Prefer `new OffscreenCanvas(width, height)` to `document.createElement('canvas')` when both hosts can run the node; a definition-v1 node uses `cascade/image` (see "Draw an image in any host"). A portable declaration still requires output parity checks; installing Skia does not override a browser-only declaration. Use Python for libraries or tools that require a server. Measure transport, serialization, and cold-start costs; persistent workers can avoid repeated process/model startup.
297
369
 
298
370
  Choose the narrowest execution locus:
299
371
 
@@ -315,6 +387,64 @@ export const definition = {
315
387
 
316
388
  The runtime refuses unsupported host/capability combinations before executing the graph.
317
389
 
390
+ ### Draw an image in any host
391
+
392
+ A node that draws, reads or writes an image uses `cascade/image`, and then one
393
+ body cooks in Studio, under `cascade run`/`render`/MCP, and in an exported
394
+ editor's worker. Declare `runsOn: 'portable'` and `capabilities: ['assets']`,
395
+ and import nothing host-specific: no `node:*`, no `@napi-rs/canvas`, no
396
+ `document`.
397
+
398
+ ```ts
399
+ import type { NodeDefinition, NodeExecutionContext } from 'cascade/contracts';
400
+ import { readBytes, readImage, registerFont, surface, writeImage } from 'cascade/image';
401
+
402
+ export const definition = {
403
+ apiVersion: 1,
404
+ label: 'Poster',
405
+ runsOn: 'portable',
406
+ capabilities: ['assets'],
407
+ inputs: { photo: { kind: 'data', type: 'image' } },
408
+ outputs: { image: { kind: 'data', type: 'image' } },
409
+ props: { title: { type: 'string', default: 'Hello' }, font: { type: 'string', default: 'assets/fonts/Display.otf' } },
410
+ } as const satisfies NodeDefinition;
411
+
412
+ export async function execute(context: NodeExecutionContext<typeof definition>) {
413
+ const photo = await readImage(context, context.inputs.photo); // an image input, asset or project path
414
+ await registerFont('Display', await readBytes(context, context.props.font));
415
+ const { canvas, context: g } = surface(photo.width, photo.height); // OffscreenCanvas, CPU-backed 2D
416
+ g.drawImage(photo, 0, 0);
417
+ g.font = '96px Display';
418
+ g.fillText(context.props.title, 40, 140);
419
+ context.outputs.image.set(await writeImage(context, canvas, `${context.nodeId}.png`));
420
+ }
421
+ ```
422
+
423
+ | | Browser (Studio, exported editor) | Headless (CLI, MCP) |
424
+ | --- | --- | --- |
425
+ | `createCanvas`, `surface` | the realm's `OffscreenCanvas` | Skia's, from `@napi-rs/canvas` |
426
+ | `decodeImage`, `readImage` | `createImageBitmap` | Skia's decoder |
427
+ | `writeImage` | an in-memory generated asset | `.cascade-cache/<name>.<hash>.png` |
428
+ | `registerFont` | `FontFace` added to the page's or worker's font set | Skia's `GlobalFonts` |
429
+ | `readBytes` | shipped files only (`editor.assets`, literal asset props, frozen files) | any project file |
430
+
431
+ - The host chooses where a written image lands; never name a path or read an
432
+ output file back by a path you built. `cachePath` and `saveImage` from
433
+ `cascade/io` remain for dynamic nodes.
434
+ - `surface(w, h)` asks for a `willReadFrequently` context, which keeps Chrome
435
+ on the same software Skia rasteriser the headless host uses. Expect the same
436
+ image, not the same bytes: on commons `plotter-social` the paper, foil and
437
+ wordmark match exactly and scanline marks differ at stroke edges (mean
438
+ channel delta under 0.5 of 255 with the same fonts).
439
+ - A font only matches across hosts when both register the same bytes. A file
440
+ that is not there (a licensed face the export never ships) should fall back
441
+ to a named system face, not fail; read it with `readBytes` in a `try`.
442
+ - What has no browser form is refused, not imitated: no canvas in the realm,
443
+ or `assets` not declared, throws a sentence saying which. A node that needs
444
+ the filesystem, a network fetch or ffmpeg stays `runsOn: 'server'`; to put
445
+ its output in an editor, freeze it (`cascade freeze <graph> <node> --fields
446
+ path,size,channels,depth,space` ships the image file with the snapshot).
447
+
318
448
  ### GPU nodes today
319
449
 
320
450
  A definition-v1 node may declare `capabilities: ['gpu']`. Studio and the optional
@@ -512,6 +642,102 @@ It may also accept inline JSON after `--args` for manual compatibility. Projects
512
642
  with expensive imports may additionally declare a newline-delimited JSON
513
643
  `worker`; Cascade keeps that process warm and correlates requests by `id`.
514
644
 
645
+ Cascade sends the worker one request at a time. When a newer edit supersedes a
646
+ running stage, Cascade withdraws it: a queued request is never sent, and the
647
+ one in flight gets `SIGINT`. A worker that catches `KeyboardInterrupt` around a
648
+ request should answer `{"id": <id>, "ok": false, "cancelled": true}` and keep
649
+ serving, so warm models survive a scrub; it should also ignore an interrupt
650
+ that arrives between requests. A worker that does not handle it exits and is
651
+ respawned on the next call, and one that has not answered within three seconds
652
+ is killed. Whatever a cancelled request returns is discarded. Write cache
653
+ entries atomically (temp file plus rename), since a stage can now be
654
+ interrupted part-way. Nodes can pass `{ signal }` as `runStage`'s third
655
+ argument; without it the signal of the cook in flight is used.
656
+
657
+ #### Stage code, cache keys and the run trace
658
+
659
+ Cascade keys every stage call on four things: the stage code (a hash of every
660
+ `.py` file under `exec.stages.sources`), the arguments with each input file
661
+ replaced by a hash of its bytes, those input hashes, and the Cascade version.
662
+ The key and the cache state are reported on every call, hit or miss: in the
663
+ server log (`[stage] ink: miss (no entry) key 3f2a… code 99dad29d… 1204 ms`),
664
+ in `cascade run --json` under `stages`, and in the run trace.
665
+
666
+ ```json
667
+ {
668
+ "exec": {
669
+ "stages": {
670
+ "entrypoint": "runtime/node_cli.py",
671
+ "worker": "runtime/worker.py",
672
+ "sources": ["runtime", "src", "shared/bridge"],
673
+ "cache": { "exclude": ["plot-status"], "outputs": ["out", "svg_out"] }
674
+ }
675
+ }
676
+ }
677
+ ```
678
+
679
+ - `sources` lists the files and folders whose `.py` files are the stage code.
680
+ Without it: the folders of the entrypoint and the worker, plus `src/`. A
681
+ symlinked folder (such as `shared/`) is followed only when it is listed.
682
+ - The warm worker fingerprints those sources when it starts and again before
683
+ every request, and restarts first when they changed, so an edited stage never
684
+ keeps running its old code in a live Studio. Its bytecode cache is keyed by
685
+ the code hash, because Python accepts a `.pyc` whose source kept its size
686
+ within the same second.
687
+ - `cache` controls Cascade's own result cache. A stored result is served only
688
+ while every file it names still has the bytes it had, so a stage that writes
689
+ a fixed path and was re-run with other inputs misses rather than serving the
690
+ overwritten file. See "The stage cache default" below.
691
+
692
+ #### The stage cache default
693
+
694
+ Since 6 Nov 2026 the cache is **on by default for the calls a node's cook
695
+ makes**, and off for everything else. A node is a pure function of its inputs,
696
+ so a stage it calls through `runStage` from `cascade/stage` is treated as pure:
697
+ same stage code, same arguments, same input file bytes, same answer. A second
698
+ identical cook is served from `.cascade/cache/stages/` without starting Python
699
+ at all; on `cloud-plots/relief.cascade` that takes a re-cook from about 4.2 s
700
+ to 0.45 s. A call from a panel (`api.runStage`) or a hand-rolled
701
+ `fetch('/api/exec')` is an action — `plot-start`, `plot-status` — and is not
702
+ cached unless you say so.
703
+
704
+ What is not pure, and must be opted out:
705
+
706
+ - a stage whose answer changes under an identical call: it reads the clock,
707
+ draws fresh randomness, queries a device or a job's progress, or calls a
708
+ network service whose answer you want fresh each time;
709
+ - a stage that reads a file its arguments do not name (a config file, a folder
710
+ it scans, a model downloaded on the side). Only files named in the arguments
711
+ are hashed into the key, so an edit to an unnamed file is invisible;
712
+ - a stage whose purpose is a side effect (copying to a named place, posting
713
+ somewhere). A result whose named files still have their bytes is served, so
714
+ a copy that already happened is not repeated; when the effect must happen
715
+ every time, opt the stage out.
716
+
717
+ How to opt out:
718
+
719
+ ```json
720
+ "cache": { "exclude": ["plot-status", "camera-grab"], "outputs": ["out", "svg_out"] }
721
+ ```
722
+
723
+ | Declaration | What is cached |
724
+ |---|---|
725
+ | absent | calls from node cooks, except `exclude` (none) |
726
+ | `{ "exclude": [...], "outputs": [...] }` | calls from node cooks, except the named stages |
727
+ | `{ "scope": "all", ... }` or `true` | every call, panel actions included, except `exclude` |
728
+ | `false` or `{ "enabled": false }` | nothing |
729
+
730
+ `CASCADE_STAGE_CACHE=off` in the environment still turns it off for one
731
+ process (`cascade parity` sets it). Every call's trace says what happened and
732
+ why: `hit`, `miss (no entry)`, `off (excluded)`, `off (not a cook call)`,
733
+ `off (not enabled)`. A project that keeps its own Python-side cache can keep
734
+ it; Cascade's sits in front, and on a hit the project's code is not called.
735
+ - A request carries `"cascade": {"key", "code", "cache"}` beside `args` (the
736
+ worker), or `CASCADE_STAGE_ENVELOPE` in the environment (one-shot). A
737
+ project that keeps its own cache should stand aside when `cache` is `"off"`,
738
+ which `cascade parity` sets, and may answer `"cache": "hit" | "miss"` beside
739
+ `result` so the trace shows its state too.
740
+
515
741
  ## Work with `.cascade` files
516
742
 
517
743
  A graph document stores authored elements separately from runtime state. Nodes and annotations remain flat even inside subnets:
@@ -550,6 +776,11 @@ cascade run index.cascade --frames 1-100x2 --fps 25 --out frames
550
776
  cascade run index.cascade --frames 42 --entry-node logo-1024
551
777
  ```
552
778
 
779
+ Every run writes a trace to `.cascade/runs/<time>-<graph>.json` (the last 200
780
+ are kept): each node that cooked, its time, and every stage call it made with
781
+ the key and cache state. `--verbose` prints the stage lines as they happen, and
782
+ `--json` prints the status, the seed, the trace path and the stage calls.
783
+
553
784
  `--frames` takes a range, a stepped range, or a single frame, and refuses a range it cannot parse rather than repairing it. Files are written as `<out>/<node id>.<frame>.<ext>`, zero-padded to at least four digits, into `renders/` unless `--out` says otherwise. One rule whether the graph has one output or three. Every image output with nothing connected downstream is saved, or the outputs of `--entry-node` when one is named. Cascade stops at the image sequence; a directory of numbered frames is what an encoder takes.
554
785
 
555
786
  Offline, drawing goes through Skia: the optional `@napi-rs/canvas` supplies `OffscreenCanvas`, `Image`, `Path2D` and the rest as globals in the Node process, deliberately not `document`, because that is how a node tells which host it is in. This is why a node that draws on an `OffscreenCanvas` needs no second implementation for headless runs, and why the built-in `cascade.image.*` nodes render under `cascade run` as well as in Studio. A graph that draws nothing runs without the renderer installed.
@@ -583,6 +814,32 @@ testing the exported artifact for the particular graph. Prefer `cascade build`
583
814
  for definition-v1 browser graphs, or embed `cascade/runtime` directly when an
584
815
  application needs custom capabilities and presentation.
585
816
 
817
+ ## Seeds and parity
818
+
819
+ A graph has one `seed` (an unsigned 32-bit integer, 0 when absent) and each
820
+ node gets `context.seed`, derived from it and the node id, plus
821
+ `context.random()`, a stream seeded with it that restarts at every cook. Both
822
+ hosts derive them the same way, so a node that draws with `context.random`
823
+ draws the same thing headless and in Studio. `Math.random` cannot be
824
+ reproduced by any host; pass `context.seed` on to a Python stage instead of
825
+ seeding there from the clock. `cascade run --seed N` cooks with another seed
826
+ without editing the file.
827
+
828
+ ```bash
829
+ cascade parity index.cascade # every node
830
+ cascade parity index.cascade --node ink # one node and what feeds it
831
+ cascade parity index.cascade --tolerance 2 --json
832
+ ```
833
+
834
+ `parity` cooks three times with every cache off: headless, headless again, and
835
+ through Studio's own executor (the compatibility graph and its definition
836
+ adapter) run headlessly with the same Dawn GPU and project capabilities. It
837
+ prints one row per node output with both comparisons side by side (`same`,
838
+ `within tolerance`, or the difference: pixels and the largest channel delta
839
+ for an image, both values for a number) and exits 1 when anything differs.
840
+ See [Headless GPU](HEADLESS_GPU.md#determinism) for what makes a GPU stage
841
+ repeatable.
842
+
586
843
  ## Verify a project
587
844
 
588
845
  Before committing a custom node or graph: