@field/cascade 0.3.0 → 0.4.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 (75) hide show
  1. package/AGENTS.md +14 -9
  2. package/ARCHITECTURE.md +22 -6
  3. package/CHANGELOG.md +69 -1
  4. package/CONTRIBUTING.md +71 -0
  5. package/DESIGN.md +19 -13
  6. package/README.md +42 -11
  7. package/dist/assets/{CodeEditor-BADFbTJS.js → CodeEditor-BNOJIDzL.js} +4 -4
  8. package/dist/assets/{CodePanel-faK6PRfz.js → CodePanel-2wp2G0Fj.js} +2 -2
  9. package/dist/assets/{CustomNodeDialog-DhlKuviy.js → CustomNodeDialog-DNqaVBqA.js} +1 -1
  10. package/dist/assets/{ProjectSettings-D6a6kjqR.js → ProjectSettings-DcdYF2E1.js} +1 -1
  11. package/dist/assets/{SettingsDialog-DJZdIvLC.js → SettingsDialog-D2xVvyiR.js} +1 -1
  12. package/dist/assets/{VersionHistoryDialog-C1X3HOhc.js → VersionHistoryDialog-DW288ykF.js} +1 -1
  13. package/dist/assets/{core-CiuiuWZi.js → core-B1y38i95.js} +1 -1
  14. package/dist/assets/{cssMode-D-NwZSMJ.js → cssMode-BLucnaln.js} +1 -1
  15. package/dist/assets/{editor.api-DT3WEvBd.js → editor.api-B3Et3Pph.js} +1 -1
  16. package/dist/assets/{freemarker2-eKNYQaiz.js → freemarker2-DVlPoano.js} +1 -1
  17. package/dist/assets/geo-DhyFdBXX.js +1 -0
  18. package/dist/assets/{handlebars-C2w1ggK2.js → handlebars-B5FZBAMR.js} +1 -1
  19. package/dist/assets/{html-DQb41jyo.js → html-FodD8KbR.js} +1 -1
  20. package/dist/assets/{htmlMode-IhNlh6Lv.js → htmlMode-CsG57VCD.js} +1 -1
  21. package/dist/assets/{image-DMCXBY-_.js → image-B3F-nq25.js} +1 -1
  22. package/dist/assets/index-CC6YQw2n.js +212 -0
  23. package/dist/assets/index-Y2ock1cw.css +1 -0
  24. package/dist/assets/{javascript-BIT6jMLC.js → javascript-B3gonF_H.js} +1 -1
  25. package/dist/assets/{jsonMode-CNfyB1ro.js → jsonMode-Y3w1ahZg.js} +1 -1
  26. package/dist/assets/{liquid-CmSmJmOG.js → liquid-CTpWXIYO.js} +1 -1
  27. package/dist/assets/{lspLanguageFeatures-BqMm3uMP.js → lspLanguageFeatures-CODBdKEZ.js} +1 -1
  28. package/dist/assets/{mdx-6r6YgOFw.js → mdx-CDAQJZIz.js} +1 -1
  29. package/dist/assets/{python-x1H0TQuI.js → python-LOIlxJU-.js} +1 -1
  30. package/dist/assets/{razor-Dzl1vZFm.js → razor-CllPQSHo.js} +1 -1
  31. package/dist/assets/{tsMode-n6DMMe3a.js → tsMode-CO28ut8t.js} +1 -1
  32. package/dist/assets/{typescript-ziEz65xN.js → typescript-ruU9wKQ_.js} +1 -1
  33. package/dist/assets/{workers-D-qUdp-r.js → workers-Bc1Ywp4-.js} +1 -1
  34. package/dist/assets/{xml-DMZjt5lE.js → xml-DoXP2Dh1.js} +1 -1
  35. package/dist/assets/{yaml-DFe5qZSO.js → yaml-D9YPyGiJ.js} +1 -1
  36. package/dist/cli/index.js +1240 -158
  37. package/dist/cli/runtime/gpu.ts +147 -0
  38. package/dist/contracts/camera.d.ts +70 -0
  39. package/dist/contracts/camera.js +27 -0
  40. package/dist/contracts/capabilities.d.ts +69 -6
  41. package/dist/contracts/definition.d.ts +61 -2
  42. package/dist/contracts/index.d.ts +2 -0
  43. package/dist/contracts/index.js +2 -0
  44. package/dist/contracts/schema.js +33 -7
  45. package/dist/contracts/series.d.ts +75 -0
  46. package/dist/contracts/series.js +2 -0
  47. package/dist/contracts/validate.js +29 -4
  48. package/dist/contracts/values.d.ts +3 -2
  49. package/dist/contracts/values.js +1 -0
  50. package/dist/gpu.d.ts +88 -0
  51. package/dist/gpu.js +113 -0
  52. package/dist/index.html +2 -2
  53. package/dist/runtime/builtins/core/camera.d.ts +252 -0
  54. package/dist/runtime/builtins/core/camera.js +149 -0
  55. package/dist/runtime/builtins/core/index.d.ts +2 -1
  56. package/dist/runtime/builtins/core/index.js +3 -1
  57. package/dist/runtime/camera/index.d.ts +60 -0
  58. package/dist/runtime/camera/index.js +234 -0
  59. package/dist/runtime/definition/architecture.js +36 -1
  60. package/dist/runtime/gpu.d.ts +33 -0
  61. package/dist/runtime/gpu.js +218 -0
  62. package/dist/runtime/index.d.ts +3 -0
  63. package/dist/runtime/index.js +3 -0
  64. package/dist/runtime/runtime.d.ts +23 -3
  65. package/dist/runtime/runtime.js +181 -30
  66. package/dist/runtime/series/index.d.ts +41 -0
  67. package/dist/runtime/series/index.js +167 -0
  68. package/dist/runtime/types.d.ts +3 -3
  69. package/doc/HEADLESS_GPU.md +160 -0
  70. package/doc/NODE_REFERENCE.md +36 -0
  71. package/doc/PROJECT_AUTHORING.md +106 -20
  72. package/package.json +14 -2
  73. package/dist/assets/geo-DUvVbLnx.js +0 -1
  74. package/dist/assets/index-BDV5xGBZ.css +0 -1
  75. package/dist/assets/index-HTCN44kj.js +0 -212
package/dist/cli/index.js CHANGED
@@ -21,6 +21,26 @@ var init_animation = __esm({
21
21
  }
22
22
  });
23
23
 
24
+ // packages/contracts/src/camera.ts
25
+ var CAMERA_DEFAULTS;
26
+ var init_camera = __esm({
27
+ "packages/contracts/src/camera.ts"() {
28
+ "use strict";
29
+ CAMERA_DEFAULTS = Object.freeze({
30
+ translate: [0, 0, 0],
31
+ rotate: [0, 0, 0],
32
+ focal: 50,
33
+ aperture: 41.4214,
34
+ near: 1e-3,
35
+ far: 1e4,
36
+ resolution: [1280, 720],
37
+ aspect: 1,
38
+ projection: "perspective",
39
+ orthowidth: 2
40
+ });
41
+ }
42
+ });
43
+
24
44
  // packages/contracts/src/capabilities.ts
25
45
  var init_capabilities = __esm({
26
46
  "packages/contracts/src/capabilities.ts"() {
@@ -540,6 +560,13 @@ var init_geometry = __esm({
540
560
  }
541
561
  });
542
562
 
563
+ // packages/contracts/src/series.ts
564
+ var init_series = __esm({
565
+ "packages/contracts/src/series.ts"() {
566
+ "use strict";
567
+ }
568
+ });
569
+
543
570
  // packages/contracts/src/values.ts
544
571
  function normalizeCascadeType(type) {
545
572
  if (!type) return "any";
@@ -577,6 +604,7 @@ var init_values = __esm({
577
604
  "mesh",
578
605
  "rects",
579
606
  "color",
607
+ "camera",
580
608
  "asset",
581
609
  "array",
582
610
  "object",
@@ -670,17 +698,17 @@ function validateNodeDefinition(value) {
670
698
  "capabilities"
671
699
  );
672
700
  const seen = /* @__PURE__ */ new Set();
673
- for (const [index, capability] of (Array.isArray(value.capabilities) ? value.capabilities : []).entries()) {
701
+ for (const [index, capability2] of (Array.isArray(value.capabilities) ? value.capabilities : []).entries()) {
674
702
  const path23 = `capabilities.${index}`;
675
- if (!seen.has(capability) && !allowed.includes(capability))
703
+ if (!seen.has(capability2) && !allowed.includes(capability2))
676
704
  add(
677
705
  "capability-not-allowed",
678
- `${String(capability)} is not available on ${String(environment)}`,
706
+ `${String(capability2)} is not available on ${String(environment)}`,
679
707
  path23
680
708
  );
681
- if (seen.has(capability))
682
- add("duplicate-capability", `${String(capability)} is duplicated`, path23);
683
- seen.add(capability);
709
+ if (seen.has(capability2))
710
+ add("duplicate-capability", `${String(capability2)} is duplicated`, path23);
711
+ seen.add(capability2);
684
712
  }
685
713
  const inward = /* @__PURE__ */ new Map();
686
714
  const outward = /* @__PURE__ */ new Map();
@@ -743,6 +771,29 @@ function validateNodeDefinition(value) {
743
771
  "options require the select control",
744
772
  `${path23}.options`
745
773
  );
774
+ if (Array.isArray(raw.options))
775
+ for (const [index, option] of raw.options.entries()) {
776
+ if (option === null || typeof option !== "object") continue;
777
+ const entry = option;
778
+ if (!("value" in entry))
779
+ add(
780
+ "invalid-option",
781
+ "a labelled option needs a value",
782
+ `${path23}.options[${index}]`
783
+ );
784
+ if (typeof entry.label !== "string" || !entry.label)
785
+ add(
786
+ "invalid-option",
787
+ "a labelled option needs a non-empty label",
788
+ `${path23}.options[${index}].label`
789
+ );
790
+ if ("disabled" in entry && typeof entry.disabled !== "boolean")
791
+ add(
792
+ "invalid-option",
793
+ "disabled must be a boolean",
794
+ `${path23}.options[${index}].disabled`
795
+ );
796
+ }
746
797
  const type = raw.type;
747
798
  if (!validType(type)) {
748
799
  add(
@@ -782,6 +833,20 @@ function validateNodeDefinition(value) {
782
833
  );
783
834
  if ("step" in raw && (typeof raw.step !== "number" || !Number.isFinite(raw.step) || raw.step <= 0))
784
835
  add("invalid-step", "step must be greater than zero", `${path23}.step`);
836
+ if (section === "props" && "expression" in raw && (typeof raw.expression !== "string" || raw.expression.length === 0))
837
+ add(
838
+ "invalid-expression",
839
+ "expression must be a non-empty string",
840
+ `${path23}.expression`
841
+ );
842
+ for (const field of ["min", "max"]) {
843
+ if (field in raw && (typeof raw[field] !== "number" || !Number.isFinite(raw[field])))
844
+ add(
845
+ `invalid-${field}`,
846
+ `${field} must be a finite number`,
847
+ `${path23}.${field}`
848
+ );
849
+ }
785
850
  if (typeof raw.min === "number" && typeof raw.max === "number" && raw.min > raw.max)
786
851
  add("invalid-range", "min must not exceed max", path23);
787
852
  }
@@ -800,9 +865,9 @@ var init_validate = __esm({
800
865
  CORE = new Set(CORE_TYPES);
801
866
  CONTROLS = /* @__PURE__ */ new Set(["number", "slider", "range", "int", "boolean", "text", "textarea", "select", "vector", "matrix", "color", "image", "asset"]);
802
867
  CAPABILITIES_BY_ENVIRONMENT = {
803
- portable: ["assets", "media"],
804
- browser: ["assets", "media", "webgl"],
805
- server: ["files", "assets", "media", "python", "shell"]
868
+ portable: ["assets", "media", "gpu"],
869
+ browser: ["assets", "media", "gpu"],
870
+ server: ["files", "assets", "media", "python", "shell", "gpu"]
806
871
  };
807
872
  ALLOWED = CAPABILITIES_BY_ENVIRONMENT;
808
873
  fields = (names) => new Set(names.split(" "));
@@ -814,7 +879,7 @@ var init_validate = __esm({
814
879
  );
815
880
  OUTPUT_FIELDS = fields("kind type description");
816
881
  PROP_FIELDS = fields(
817
- "type default label description min max step accept control options"
882
+ "type default label description min max step accept control options expression action"
818
883
  );
819
884
  }
820
885
  });
@@ -824,11 +889,13 @@ var init_src = __esm({
824
889
  "packages/contracts/src/index.ts"() {
825
890
  "use strict";
826
891
  init_animation();
892
+ init_camera();
827
893
  init_capabilities();
828
894
  init_color();
829
895
  init_definition();
830
896
  init_document();
831
897
  init_geometry();
898
+ init_series();
832
899
  init_validate();
833
900
  init_values();
834
901
  }
@@ -2872,7 +2939,7 @@ var init_panels = __esm({
2872
2939
  });
2873
2940
 
2874
2941
  // server/src/pythonWorker.ts
2875
- import { spawn as spawn2 } from "child_process";
2942
+ import { spawn as spawn3 } from "child_process";
2876
2943
  import readline from "readline";
2877
2944
  var REQUEST_TIMEOUT_MS, PythonWorker;
2878
2945
  var init_pythonWorker = __esm({
@@ -2892,7 +2959,7 @@ var init_pythonWorker = __esm({
2892
2959
  ensureStarted() {
2893
2960
  if (this.proc && !this.proc.killed) return this.proc;
2894
2961
  const scriptPath = this.project.resolve(this.entrypoint);
2895
- const proc = spawn2(this.python, [scriptPath], { cwd: this.project.root, env: this.env });
2962
+ const proc = spawn3(this.python, [scriptPath], { cwd: this.project.root, env: this.env });
2896
2963
  readline.createInterface({ input: proc.stdout }).on("line", (line2) => this.handleLine(line2));
2897
2964
  proc.stderr.on("data", (chunk) => console.error("[python-worker]", chunk.toString().trimEnd()));
2898
2965
  proc.on("exit", (code) => {
@@ -2987,8 +3054,8 @@ function createProjectRequestBoundary(options) {
2987
3054
  function createBrowserCapabilityBoundary(options, label, header) {
2988
3055
  const origins = new Set(options.trustedOrigins.map((origin) => validateOrigin(origin, label)));
2989
3056
  const authorities = allowedAuthorities(options, origins);
2990
- const capability = randomBytes(32).toString("base64url");
2991
- const expectedBytes = Buffer.from(capability);
3057
+ const capability2 = randomBytes(32).toString("base64url");
3058
+ const expectedBytes = Buffer.from(capability2);
2992
3059
  return {
2993
3060
  guardOrigin(req, res, next) {
2994
3061
  const origin = req.get("Origin");
@@ -3008,7 +3075,7 @@ function createBrowserCapabilityBoundary(options, label, header) {
3008
3075
  issueCapability(_req, res) {
3009
3076
  res.setHeader("Cache-Control", "no-store, private");
3010
3077
  res.setHeader("Pragma", "no-cache");
3011
- res.json({ capability });
3078
+ res.json({ capability: capability2 });
3012
3079
  },
3013
3080
  requireCapability(req, res, next) {
3014
3081
  const supplied = req.get(header);
@@ -3026,12 +3093,36 @@ function allowedAuthorities(options, origins) {
3026
3093
  if (isLoopbackHost(options.host)) {
3027
3094
  authorities.add(`127.0.0.1:${options.port}`);
3028
3095
  authorities.add(`localhost:${options.port}`);
3029
- } else if (options.trustedHosts.length === 0) {
3096
+ } else if (!isWildcardHost(options.host)) {
3030
3097
  authorities.add(authority(options.host, options.port));
3031
3098
  }
3032
- for (const host of options.trustedHosts) authorities.add(authority(host, options.port));
3099
+ for (const host of options.trustedHosts) {
3100
+ const value = trustedAuthority(host, options.port);
3101
+ authorities.add(value);
3102
+ const bare = value.replace(/:(?:80|443)$/, "");
3103
+ if (bare !== value) authorities.add(bare);
3104
+ }
3033
3105
  return authorities;
3034
3106
  }
3107
+ function isWildcardHost(host) {
3108
+ const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
3109
+ return normalized === "0.0.0.0" || normalized === "::" || normalized === "*";
3110
+ }
3111
+ function trustedAuthority(value, defaultPort) {
3112
+ const trimmed = value.trim().toLowerCase();
3113
+ const bracketed = /^\[([^\]]+)\]:(\d+)$/.exec(trimmed);
3114
+ if (bracketed) return authority(`[${bracketed[1]}]`, checkedPort(bracketed[2]));
3115
+ const hostAndPort = /^([^:]+):(\d+)$/.exec(trimmed);
3116
+ if (hostAndPort) return authority(hostAndPort[1], checkedPort(hostAndPort[2]));
3117
+ return authority(trimmed, defaultPort);
3118
+ }
3119
+ function checkedPort(raw) {
3120
+ const port = Number(raw);
3121
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
3122
+ throw new Error(`Invalid trusted host port: ${raw}`);
3123
+ }
3124
+ return port;
3125
+ }
3035
3126
  function authority(host, port) {
3036
3127
  const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
3037
3128
  if (!normalized || /[\s/?#@]/.test(normalized)) throw new Error(`Invalid trusted host: ${host}`);
@@ -3389,7 +3480,7 @@ var init_allowlist = __esm({
3389
3480
  });
3390
3481
 
3391
3482
  // server/src/agent/session.ts
3392
- import { spawn as spawn3 } from "node:child_process";
3483
+ import { spawn as spawn4 } from "node:child_process";
3393
3484
  function childEnvironment() {
3394
3485
  const env = { ...process.env };
3395
3486
  for (const key2 of Object.keys(env)) {
@@ -3619,7 +3710,7 @@ var init_session = __esm({
3619
3710
  const resumed = Boolean(state.sessionId);
3620
3711
  const args = argsFor(request.agent, request.prompt, state.sessionId, agentExtraArgs(this.project.root, request.agent));
3621
3712
  run.emit({ type: "started", agent: request.agent, sketch: request.sketch, resumed });
3622
- const child = spawn3(executable, args, {
3713
+ const child = spawn4(executable, args, {
3623
3714
  cwd: this.project.root,
3624
3715
  env: childEnvironment(),
3625
3716
  shell: false,
@@ -4283,7 +4374,27 @@ function startServer(project, opts = {}) {
4283
4374
  const trustedHosts = Object.freeze([...opts.trustedHosts ?? []]);
4284
4375
  const defaultOrigins = [
4285
4376
  ...isLoopbackHost(HOST) ? [`http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`] : [],
4286
- ...trustedHosts.map((host) => `http://${authority(host, PORT)}`)
4377
+ // Both schemes for a trusted host, because the server cannot know whether
4378
+ // something is terminating TLS in front of it.
4379
+ //
4380
+ // This was `http://` only, and behind a TLS proxy a browser sends
4381
+ // `Origin: https://name:port` — so the Host check passed and the **Origin
4382
+ // check refused it**, which 403s every POST and PUT while GETs without an
4383
+ // Origin header sail through. That is a Studio that loads, renders and
4384
+ // then silently cannot save. Measured before fixing: `httpsOrigin: 403`,
4385
+ // `httpOrigin: 200` on the same server.
4386
+ //
4387
+ // Not a widening: the Host allowlist already decides which authorities are
4388
+ // acceptable, and this only says the same authority may arrive over either
4389
+ // scheme.
4390
+ ...trustedHosts.flatMap((host) => {
4391
+ const value = trustedAuthority(host, PORT);
4392
+ const bare = value.replace(/:(?:80|443)$/, "");
4393
+ return [.../* @__PURE__ */ new Set([value, bare])].flatMap((authority3) => [
4394
+ `http://${authority3}`,
4395
+ `https://${authority3}`
4396
+ ]);
4397
+ })
4287
4398
  ];
4288
4399
  const security = Object.freeze({
4289
4400
  host: HOST,
@@ -4342,7 +4453,7 @@ function startServer(project, opts = {}) {
4342
4453
  server.listen(PORT, HOST, () => {
4343
4454
  console.log(`\u{1F680} Cascade running on http://${HOST}:${PORT}`);
4344
4455
  console.log(`\u{1F4C1} Project: ${project.root}${project.isGitRepo ? " (git)" : " (not a git repo yet)"}`);
4345
- if (trustedHosts.length > 0) console.log(`\u{1F510} Trusted Studio URL: http://${authority(trustedHosts[0], PORT)} (all reachable VPN/interface peers are trusted)`);
4456
+ if (trustedHosts.length > 0) console.log(`\u{1F510} Trusted Studio URL: http://${trustedAuthority(trustedHosts[0], PORT)} (all reachable VPN/interface peers are trusted)`);
4346
4457
  });
4347
4458
  return server;
4348
4459
  }
@@ -4441,10 +4552,11 @@ Read \`node_modules/cascade/AGENTS.md\` for the framework itself. What follows i
4441
4552
 
4442
4553
  ## Read this first
4443
4554
 
4444
- \`node_modules/cascade/doc/NODE_REFERENCE.md\` \u2014 every built-in node with its inputs, props and outputs, generated from the definitions themselves. Under 2,500 tokens for the whole catalogue. **Read it instead of searching the codebase for what a node does**, and note before you start that anything time-based is an expression rather than a node.
4555
+ \`node_modules/cascade/doc/NODE_REFERENCE.md\` \u2014 every built-in node with its inputs, props and outputs, generated from the definitions themselves. Read it before creating a wrapper for an existing node. Time-based parameter changes usually need an expression or channel.
4445
4556
 
4446
4557
  ## Common commands
4447
4558
 
4559
+ - Run \`npm install\` in this directory first. Scripts use the project's installed Cascade version; use \`npx --no-install cascade\` for direct local CLI commands.
4448
4560
  - \`npm run check\` \u2014 type-check custom nodes.
4449
4561
  - \`npm run check:graph\` \u2014 statically check the graph and node definitions.
4450
4562
  - \`npm run validate\` \u2014 validate \`index.cascade\`.
@@ -4457,7 +4569,7 @@ both the explicit bind address and the exact trusted browser hostname.
4457
4569
 
4458
4570
  ## The \`.cascade\` document format
4459
4571
 
4460
- Hand-editing this file is normal and expected. The shape is not what you would guess, and getting it wrong fails **silently** \u2014 the graph loads, reports its nodes, says it executed, and does nothing.
4572
+ Hand-editing is supported. Preserve metadata, source fields, IDs, and valid connection endpoints, then run the static checks. This example uses the node below; save it at \`nodes/Multiply/index.ts\` before running the graph.
4461
4573
 
4462
4574
  \`\`\`json
4463
4575
  {
@@ -4465,28 +4577,25 @@ Hand-editing this file is normal and expected. The shape is not what you would g
4465
4577
  "metadata": { "name": "My Sketch" },
4466
4578
  "nodes": [
4467
4579
  {
4468
- "id": "logo-1024",
4469
- "module": "project.field-io-gradient-logo",
4580
+ "id": "multiply",
4581
+ "module": "project.Multiply",
4470
4582
  "position": [240, 200],
4471
4583
  "source": "project",
4472
- "params": [{ "name": "size", "value": 1024 }],
4473
- "props": { "angle": { "value": 0, "expression": "$T * 40" } }
4584
+ "props": { "factor": { "value": 2, "expression": "2 + sin($T)" } }
4474
4585
  }
4475
4586
  ],
4476
- "connections": [
4477
- [["oscillator", 0, "value"], ["logo-1024", 0, "angle"]]
4478
- ],
4587
+ "connections": [],
4479
4588
  "annotations": []
4480
4589
  }
4481
4590
  \`\`\`
4482
4591
 
4483
4592
  Five things that are easy to get wrong:
4484
4593
 
4485
- - **\`module\`, not \`type\`.** And \`"source": "project"\` is required for a project module \u2014 without it the engine never loads the module at all, and nothing complains.
4594
+ - **Use \`module\` for the module identity and \`"source": "project"\` for project modules.** Unresolved modules need investigation even when validation only warns.
4486
4595
  - **\`position\` is \`[x, y]\`**, an array, not \`{x, y}\`.
4487
- - **\`params\` is an array** of \`{name, value}\`, not an object.
4596
+ - **Definition-v1 stored parameter values belong under \`props\`.** A leftover \`params\` array is not read by the deterministic runtime; \`runtime/stray-params\` warns about stranded values.
4488
4597
  - **A connection is a pair of triples**: \`[[fromNode, portIndex, portName], [toNode, portIndex, portName]]\`. Direction comes from *position in the pair* \u2014 first is the source. The middle number is the **port index**, not a direction.
4489
- - **An expression lives under \`props\`** as \`{ value, expression }\`. \`params\` holds plain values.
4598
+ - **A prop is a bare value or \`{ value, expression?, channel? }\`.** Channels take precedence over expressions, then stored values. Save the binding, not just its value at the playhead.
4490
4599
 
4491
4600
  ## Before writing a node, check whether you need one
4492
4601
 
@@ -4515,25 +4624,22 @@ import type { NodeDefinition, NodeExecutionContext } from 'cascade/contracts';
4515
4624
 
4516
4625
  export const definition = {
4517
4626
  apiVersion: 1,
4518
- label: 'Grain',
4627
+ label: 'Multiply',
4519
4628
  icon: 'Circle', // a Lucide icon name
4520
4629
  runsOn: 'portable', // 'portable' | 'browser' | 'server'
4521
4630
  inputs: {
4522
- value: { kind: 'data', type: 'float', default: 0 },
4631
+ value: { kind: 'data', type: 'float', default: 3 },
4523
4632
  },
4524
4633
  outputs: {
4525
- image: { kind: 'data', type: 'image' },
4634
+ result: { kind: 'data', type: 'float' },
4526
4635
  },
4527
4636
  props: {
4528
- size: { type: 'int', default: 1024, min: 16, max: 4096, step: 16 },
4637
+ factor: { type: 'float', default: 2, min: 0, max: 10, step: 0.1 },
4529
4638
  },
4530
4639
  } as const satisfies NodeDefinition;
4531
4640
 
4532
4641
  export function execute(context: NodeExecutionContext<typeof definition>) {
4533
- const { value } = context.inputs;
4534
- const { size } = context.props;
4535
- // ... work ...
4536
- context.outputs.image.set({ path, size: [size, size], channels: 'rgb', depth: 'u8', space: 'srgb' });
4642
+ context.outputs.result.set(context.inputs.value * context.props.factor);
4537
4643
  }
4538
4644
  \`\`\`
4539
4645
 
@@ -4541,7 +4647,7 @@ The declaration is the point: ports, types, props and the execution locus are re
4541
4647
 
4542
4648
  ### Use the vector types
4543
4649
 
4544
- Anything with an x and a y is **one** \`vec2\`, not two floats. Same for \`vec3\` and \`vec4\`, with \`vec2i\`, \`vec3i\` and \`vec4i\` for integer counts and pixel sizes. A position, an offset, a size, a scale, a colour with alpha, a resolution \u2014 all single vector ports.
4650
+ Anything with an x and a y is **one** \`vec2\`, not two floats. Same for \`vec3\` and \`vec4\`, with \`vec2i\`, \`vec3i\` and \`vec4i\` for integer counts and pixel sizes. Positions, offsets, scales, and resolutions use vector ports; colours use the core \`color\` type.
4545
4651
 
4546
4652
  \`\`\`ts
4547
4653
  // Wrong
@@ -4553,22 +4659,26 @@ props: { offset: { type: 'vec2', default: [0, 0] } }
4553
4659
 
4554
4660
  Not a style preference. Two floats that are really one vector cannot be connected to a \`vec2\` output, get two rows in the Inspector instead of one control, need two keyframes to animate one movement, and let a graph carry an x without its y. The type system knows what a \`vec2\` is; it cannot know that \`offset_x\` and \`offset_y\` belong together.
4555
4661
 
4556
- Split them only when the components genuinely differ in kind or in range \u2014 a \`width\` and a \`depth\` that are separately meaningful are two props, not a \`vec2\`.
4662
+ Split only when the components differ in kind. Vector props support \`min\`, \`max\`, and \`step\`, with one range across their components.
4663
+
4664
+ Studio and a compatible CLI host cook this style. **Studio** builds ports from the compiled definition and calls \`execute\` with a real \`NodeExecutionContext\`; **\`cascade run\`** uses the deterministic runtime when the node's execution locus and capabilities are supported. A browser-only declaration still prevents a Node-host run.
4557
4665
 
4558
- Both hosts cook this style. **Studio** builds the node's ports from the literal and calls \`execute\` with a real \`NodeExecutionContext\`; **\`cascade run\`** runs it through the deterministic runtime. Studio reads the definition from the compiled module, so a definition edit shows up on the next save like any other change.
4666
+ The **dynamic** style \u2014 \`execute(node, graph)\` declaring ports with \`node.in\`, \`node.param\`, and \`node.out\` \u2014 remains supported by Studio and the CLI compatibility engine. It cannot provide the same static checks. Studio may mix both styles; the CLI rejects mixed graphs. Convert deliberately with saved-value and output comparisons.
4559
4667
 
4560
- The older **dynamic** style \u2014 \`export async function execute(node, graph)\` declaring its ports imperatively inside itself with \`node.in\`, \`node.param\` and \`node.out\` \u2014 still cooks in Studio and under \`cascade run\`, so an existing node keeps working. It is not the style to write something new in: nothing can read what a dynamic node takes or returns until it has cooked, so \`cascade check\` cannot check it and the Definition panel has nothing to show. If you are editing one heavily, convert it.
4668
+ Definition-v1 props support default \`expression\` values. An explicitly saved value overrides that default, even when equal to the numeric default. Both fully definition-v1 and supported dynamic graphs can render sequences with \`cascade run index.cascade --frames 1-100\`, subject to host capabilities.
4561
4669
 
4562
4670
  Either way the module is a real ES module and the loader needs the \`execute\` export. Top-level side effects do not belong in it.
4563
4671
 
4564
4672
  ## Rendering
4565
4673
 
4566
- **Web technology first** \u2014 Canvas 2D, WebGL, WebGPU \u2014 per \`DESIGN.md\` in the Cascade repo. Reach for a Python stage only for work that genuinely cannot run in a browser, such as an exotic ML library. A Python stage costs a process spawn and a round trip per cook, which is invisible on one still frame and fatal to dragging a parameter.
4674
+ Prefer Canvas 2D, WebGL, and WebGPU for interactive rendering. Use Python or another server stage for libraries and tools that require it. Transport, serialization, and cold starts can affect parameter-drag latency; persistent workers can avoid repeated process/model startup.
4567
4675
 
4568
- An \`OffscreenCanvas\` plus \`saveImage(canvas, cachePath(node.id, '.png'))\` is the normal portable shape; the headless host supplies the Canvas APIs needed by \`cascade run\`.
4676
+ Definition-v1 image nodes can use \`saveImage(canvas, cachePath(context.nodeId, '.png'))\` from \`cascade/io\`. The instance ID provides a cache namespace, not graph access. Final artefacts may instead use an explicit filename. Optional Skia supplies Canvas2D APIs; optional Dawn separately supplies WebGPU. Neither overrides a browser-only declaration or supplies the DOM.
4569
4677
 
4570
4678
  Use \`browser\` only for code that genuinely needs the page, such as the DOM or WebGL. Prefer \`new OffscreenCanvas(width, height)\` to \`document.createElement('canvas')\` when both hosts can run the same node.
4571
4679
 
4680
+ WebGPU nodes declare \`capabilities: ['gpu']\` and use \`context.capabilities.gpu\` for the shared device and per-node cache. Portable GPU nodes run in Studio or the optional Dawn CLI host. Import usage constants and explicit RGBA8 \`readTexture\` from \`cascade/gpu\`, then encode through the image/IO host. Graph texture exchange remains future work. For agent image feedback use \`cascade run index.cascade --frames 1 --json --timeout 60000\`; see \`node_modules/cascade/doc/HEADLESS_GPU.md\` for limitations.
4681
+
4572
4682
  ## Finding out what exists
4573
4683
 
4574
4684
  - \`node_modules/cascade/doc/PROJECT_AUTHORING.md\` \u2014 project layout and the node contract.
@@ -4577,11 +4687,11 @@ Use \`browser\` only for code that genuinely needs the page, such as the DOM or
4577
4687
  - \`node_modules/cascade/dist/runtime/builtins/\` \u2014 the built-in node definitions, as the shipped type declarations. (\`packages/\` is not in the published \`files\` list, so an installed copy has no source tree.)
4578
4688
  - The **Definition panel** in Studio shows, for any selected node, where it is defined and what it declares.
4579
4689
 
4580
- ## Two things that will waste your time
4690
+ ## Verification and shared development
4581
4691
 
4582
- **Never run \`npm run build\` in the Cascade repo.** It empties \`dist/\`, and the Studio servers serve from there.
4692
+ Cascade's build refreshes \`dist/\`, which running Studio servers may serve. Coordinate a core rebuild with anyone using that checkout; routine sketch edits do not require rebuilding Cascade.
4583
4693
 
4584
- **Check your work by running the graph**, not by reading it: \`npm run check:graph\` for a static check, \`npm run run\` for a headless cook. A silently-wrong document is the failure mode here, so a green read proves nothing.
4694
+ Use \`npm run check:graph\` for static checks and \`npm run run\` for a compatible headless graph. A browser-only node may pass static checks with an environment warning and still be refused by the CLI; verify it in Studio instead. Compare actual outputs after edits and distinguish recorded measurements from tests run in the current session.
4585
4695
  `);
4586
4696
  return directory;
4587
4697
  }
@@ -4655,7 +4765,7 @@ function parseStudioArgs(args) {
4655
4765
  if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
4656
4766
  if (name === "--host") host = value;
4657
4767
  else if (name === "--trusted-host") {
4658
- authority(value, 1);
4768
+ trustedAuthority(value, 1);
4659
4769
  trustedHosts.push(value);
4660
4770
  } else {
4661
4771
  port = Number(value);
@@ -4668,8 +4778,8 @@ function parseStudioArgs(args) {
4668
4778
  if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
4669
4779
  positional.push(argument);
4670
4780
  }
4671
- if (trustedHosts.length > 0 && (!host || isLoopbackHost(host))) {
4672
- throw new Error("--trusted-host requires an explicit non-loopback --host bind address");
4781
+ if (trustedHosts.length > 0 && !host) {
4782
+ throw new Error("--trusted-host requires an explicit --host bind address, loopback or otherwise");
4673
4783
  }
4674
4784
  if (host && !isLoopbackHost(host)) {
4675
4785
  if (host === "0.0.0.0" || host === "::" || host === "[::]") {
@@ -4721,7 +4831,7 @@ async function runStudioCli(args) {
4721
4831
  const address = server.address();
4722
4832
  const port2 = typeof address === "object" && address ? address.port : 3030;
4723
4833
  const browserHost = trustedHosts[0] ?? (host && !isLoopbackHost(host) ? host : "localhost");
4724
- const url = `http://${authority(browserHost, port2)}`;
4834
+ const url = `http://${trustedAuthority(browserHost, port2)}`;
4725
4835
  if (!noOpen) {
4726
4836
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
4727
4837
  execFile5(opener, [url], (error) => {
@@ -5625,6 +5735,34 @@ function split(path23) {
5625
5735
  return String(path23 ?? "").split("/").filter((segment) => segment.length > 0 && segment !== ".");
5626
5736
  }
5627
5737
 
5738
+ // src/utils/thrownError.ts
5739
+ function asError(thrown) {
5740
+ if (thrown instanceof Error) return thrown;
5741
+ const described = describeThrown(thrown);
5742
+ const error = new Error(described);
5743
+ error.cause = thrown;
5744
+ return error;
5745
+ }
5746
+ function describeThrown(thrown) {
5747
+ if (thrown === null) return "null was thrown";
5748
+ if (thrown === void 0) return "undefined was thrown";
5749
+ if (typeof thrown === "string") return thrown || "an empty string was thrown";
5750
+ if (typeof thrown !== "object") return `${String(thrown)} was thrown`;
5751
+ const value = thrown;
5752
+ const kind = typeof value.name === "string" && value.name ? value.name : value.constructor?.name || "object";
5753
+ const parts = [];
5754
+ if (typeof value.message === "string" && value.message) parts.push(value.message);
5755
+ if (typeof value.reason === "string" && value.reason) parts.push(`reason: ${value.reason}`);
5756
+ if (!parts.length) {
5757
+ try {
5758
+ const json6 = JSON.stringify(thrown);
5759
+ if (json6 && json6 !== "{}") parts.push(json6);
5760
+ } catch {
5761
+ }
5762
+ }
5763
+ return parts.length ? `${kind}: ${parts.join(", ")}` : `${kind} was thrown with no message`;
5764
+ }
5765
+
5628
5766
  // src/nodes/Node.ts
5629
5767
  function inferParamType(value) {
5630
5768
  if (typeof value === "number") return Number.isInteger(value) ? "int" : "float";
@@ -5731,9 +5869,38 @@ var Node = class _Node {
5731
5869
  // Execution state
5732
5870
  this.hasExecuted = false;
5733
5871
  this.lastInputHash = "";
5872
+ /** The inputs an execute threw on, or null if the last cook did not throw.
5873
+ * Read only by `isDirty` — see the comment there for why it exists. */
5874
+ this.failedInputHash = null;
5734
5875
  this.cookState = "stale";
5735
5876
  this.cookGeneration = 0;
5736
- this.stagedOutputs = null;
5877
+ /**
5878
+ * Output values staged by the cook currently running, one map per cook.
5879
+ *
5880
+ * A stack rather than a field, because it was a field and that was a race.
5881
+ * `stagedOutputs` was instance state created per cook, iterated under a
5882
+ * `generation === this.cookGeneration` guard, and nulled in `finally` — but
5883
+ * `cookGeneration` only advances in `invalidateCook()`, and `cook()` has no
5884
+ * re-entrancy guard. So two overlapping cooks of the same node with no
5885
+ * invalidation between them both ran at the same generation: the second's
5886
+ * `finally` nulled the field, the first resumed, passed the generation check
5887
+ * because nothing had invalidated it, and iterated `null` —
5888
+ * `stagedOutputs is not iterable`.
5889
+ *
5890
+ * Worse than the crash, and the reason this is a correctness fix rather than
5891
+ * a robustness one: the second cook's `new Map()` **replaced the first's
5892
+ * mid-flight**, so the first cook's staged writes were lost whenever the
5893
+ * timing did not happen to produce the throw.
5894
+ *
5895
+ * The underlying fault is that per-cook state lived on the instance, and the
5896
+ * thing protecting it (the generation) was not the thing destroying it (the
5897
+ * `finally`) — two mechanisms guarding one field, neither aware of the other.
5898
+ * Each cook now owns its own map, holds it locally, and removes it **by
5899
+ * identity**, so an interleaving cannot make one cook drop another's.
5900
+ *
5901
+ * Diagnosed by MW-OBSERVATORY-ART, 2026-09-08.
5902
+ */
5903
+ this.stagingStack = [];
5737
5904
  this.executionTimeout = 3e4;
5738
5905
  // Cook info - performance tracking
5739
5906
  this.cookInfo = {
@@ -5750,6 +5917,17 @@ var Node = class _Node {
5750
5917
  this.portsUsedDuringSetup = /* @__PURE__ */ new Set();
5751
5918
  // Port index map for O(1) lookups (portId -> port)
5752
5919
  this._portMap = /* @__PURE__ */ new Map();
5920
+ // ============ Props System ============
5921
+ /**
5922
+ * Add a parameter to this node
5923
+ * Short alias for defineProp - use in setup()
5924
+ */
5925
+ /** Props whose declared default expression has already been applied — see
5926
+ * `bindParameterProp`, which must not reinstate one the author deleted. */
5927
+ this.defaultExpressionsApplied = /* @__PURE__ */ new Set();
5928
+ /** Props whose plain stored value explicitly overrides a declared default
5929
+ * expression. The marker survives even when that value equals the default. */
5930
+ this.plainPropOverrides = /* @__PURE__ */ new Set();
5753
5931
  this.id = id;
5754
5932
  this.type = type;
5755
5933
  this.graph = graph;
@@ -5868,8 +6046,9 @@ var Node = class _Node {
5868
6046
  return port && "defaultValue" in port ? port : null;
5869
6047
  }
5870
6048
  setOutputValue(port, value) {
5871
- if (this.stagedOutputs) {
5872
- this.stagedOutputs.set(port, value);
6049
+ const staging = this.stagingStack[this.stagingStack.length - 1];
6050
+ if (staging) {
6051
+ staging.set(port, value);
5873
6052
  return;
5874
6053
  }
5875
6054
  this.commitOutputValue(port, value);
@@ -5884,11 +6063,6 @@ var Node = class _Node {
5884
6063
  targetPort.onChange?.(value);
5885
6064
  }
5886
6065
  }
5887
- // ============ Props System ============
5888
- /**
5889
- * Add a parameter to this node
5890
- * Short alias for defineProp - use in setup()
5891
- */
5892
6066
  addParm(name, config) {
5893
6067
  this.props[name] = config;
5894
6068
  this.propsDeclaredByCode.add(name);
@@ -5989,6 +6163,9 @@ var Node = class _Node {
5989
6163
  deleteExpression: () => {
5990
6164
  const { expression: _, expressionError: __, ...rest } = self.props[name];
5991
6165
  self.props[name] = rest;
6166
+ if (self.defaultExpressionsApplied.has(name)) {
6167
+ self.plainPropOverrides.add(name);
6168
+ }
5992
6169
  self.updateTimeDependent();
5993
6170
  self.markDirty();
5994
6171
  },
@@ -6068,6 +6245,12 @@ var Node = class _Node {
6068
6245
  ...channel ? { channel } : {}
6069
6246
  };
6070
6247
  }
6248
+ preservePlainProp(name) {
6249
+ this.plainPropOverrides.add(name);
6250
+ }
6251
+ preservesPlainProp(name) {
6252
+ return this.plainPropOverrides.has(name) && this.defaultExpressionsApplied.has(name);
6253
+ }
6071
6254
  /** Read a channel back off disk. Bad data yields no channel, never a throw. */
6072
6255
  restorePropChannel(name, data) {
6073
6256
  if (!this.props[name]) return;
@@ -6215,6 +6398,14 @@ var Node = class _Node {
6215
6398
  this.props[name] = prop;
6216
6399
  this.props = { ...this.props };
6217
6400
  }
6401
+ if (options.defaultExpression && !this.defaultExpressionsApplied.has(name)) {
6402
+ this.defaultExpressionsApplied.add(name);
6403
+ if (!this.plainPropOverrides.has(name) && !prop.expression) {
6404
+ prop.expression = options.defaultExpression;
6405
+ this.props = { ...this.props };
6406
+ this.updateTimeDependent();
6407
+ }
6408
+ }
6218
6409
  prop.fromParameter = name;
6219
6410
  prop.hidden = true;
6220
6411
  const control = paramControlType(parameter.dataType);
@@ -6812,6 +7003,35 @@ var Node = class _Node {
6812
7003
  if (!this.hasExecuted || this.cookState !== "clean") return true;
6813
7004
  return this.calculateInputHash() !== this.lastInputHash;
6814
7005
  }
7006
+ /**
7007
+ * The last cook threw, and nothing has changed since it did.
7008
+ *
7009
+ * Separate from `isDirty` on purpose, and this is the second attempt: folding
7010
+ * it into `isDirty` closed the Viewer's cook loop and broke a contract the
7011
+ * scheduler tests already pinned — `requestOutput` retries a failed node,
7012
+ * which is the transient case (a fetch that timed out, a device lost) and is
7013
+ * right. The two callers are not the same thing wearing different names. An
7014
+ * explicit request means *give me this output now* and should retry; the
7015
+ * Viewer's `requestAnimationFrame` pump is a **poll** that was reaching the
7016
+ * same entry point every 100 ms, and a poll asking again on identical inputs
7017
+ * cannot learn anything.
7018
+ *
7019
+ * So the fix belongs at the poll, and the poll needs a question only the node
7020
+ * can answer. Measured 2026-09-08 by MW-OBSERVATORY-ART against a live sketch:
7021
+ * selecting a failing node produced 93 errors in 10 s — 9.3/sec, median gap
7022
+ * 103.7 ms, identical stack every time — each one a full `scheduler.flush()`
7023
+ * re-running the failing execute and re-fetching its inputs over the network,
7024
+ * for as long as the node stayed selected. `markDirty` is not on that path,
7025
+ * which is why auditing its call sites came back clean, and the Viewer's
7026
+ * overlap guard is why it held 61 fps and never looked like a hang.
7027
+ *
7028
+ * `hasExecuted` is deliberately not consulted: a node that throws on its very
7029
+ * first cook never sets it, and that is the reproduction case.
7030
+ */
7031
+ get hasSettledFailure() {
7032
+ if (this.cookState !== "error" || this.failedInputHash === null) return false;
7033
+ return this.calculateInputHash() === this.failedInputHash;
7034
+ }
6815
7035
  /**
6816
7036
  * Mark all downstream nodes as dirty (lazy propagation)
6817
7037
  */
@@ -6855,7 +7075,8 @@ var Node = class _Node {
6855
7075
  const startTime = performance.now();
6856
7076
  const startMemory = performance.memory?.usedJSHeapSize ?? 0;
6857
7077
  const generation = this.cookGeneration;
6858
- this.stagedOutputs = /* @__PURE__ */ new Map();
7078
+ const staging = /* @__PURE__ */ new Map();
7079
+ this.stagingStack.push(staging);
6859
7080
  this.setCookState("cooking");
6860
7081
  let timeoutId;
6861
7082
  try {
@@ -6870,12 +7091,13 @@ var Node = class _Node {
6870
7091
  ]);
6871
7092
  if (generation === this.cookGeneration) {
6872
7093
  await this.callLifecycleHooks(needsInitialization);
6873
- for (const [port, value] of this.stagedOutputs) {
7094
+ for (const [port, value] of staging) {
6874
7095
  this.commitOutputValue(port, value);
6875
7096
  }
6876
7097
  this.error = null;
6877
7098
  this.hasExecuted = true;
6878
7099
  this.lastInputHash = this.calculateInputHash();
7100
+ this.failedInputHash = null;
6879
7101
  const carryReport = this.finishParameterCarryOver();
6880
7102
  if (carryReport) _Node.onParametersRetargeted?.(this.id, carryReport);
6881
7103
  this.setCookState("clean");
@@ -6883,20 +7105,23 @@ var Node = class _Node {
6883
7105
  this.setCookState("stale");
6884
7106
  }
6885
7107
  this.updateCookInfo(startTime, startMemory);
6886
- } catch (err) {
7108
+ } catch (thrown) {
7109
+ const err = asError(thrown);
6887
7110
  if (generation === this.cookGeneration) {
6888
7111
  this.error = err;
7112
+ this.failedInputHash = this.calculateInputHash();
6889
7113
  this.setCookState("error");
6890
7114
  } else {
6891
7115
  this.setCookState("stale");
6892
7116
  }
6893
7117
  this.updateCookInfo(startTime, startMemory);
6894
- if (!err.message?.includes("timeout")) {
6895
- console.error(`Error executing node ${this.id}:`, err);
7118
+ if (!err.message.includes("timeout")) {
7119
+ console.error(`Error executing node ${this.id}: ${err.message}`, err);
6896
7120
  }
6897
7121
  } finally {
6898
7122
  if (timeoutId !== void 0) clearTimeout(timeoutId);
6899
- this.stagedOutputs = null;
7123
+ const index = this.stagingStack.indexOf(staging);
7124
+ if (index >= 0) this.stagingStack.splice(index, 1);
6900
7125
  }
6901
7126
  }
6902
7127
  /**
@@ -8103,15 +8328,28 @@ var silentProgress = { report: () => {
8103
8328
  function parameterOptions(definition) {
8104
8329
  return {
8105
8330
  ...definition.label ? { label: definition.label } : {},
8331
+ ...definition.expression ? { defaultExpression: definition.expression } : {},
8332
+ ...definition.action ? { action: definition.action } : {},
8106
8333
  ...definition.min === void 0 ? {} : { min: definition.min },
8107
8334
  ...definition.max === void 0 ? {} : { max: definition.max },
8108
8335
  ...definition.step === void 0 ? {} : { step: definition.step },
8109
8336
  ...definition.options ? {
8110
- choices: definition.options.map((value) => ({ value, label: String(value) }))
8337
+ // A declared label wins; a bare value is its own label, which is what
8338
+ // every definition written before the labelled form meant. Stringifying
8339
+ // unconditionally is what turned "Magnific — costs money" into
8340
+ // "magnific" and threw away the only notice that it bills per call.
8341
+ choices: definition.options.map((option) => option !== null && typeof option === "object" && "value" in option ? {
8342
+ value: option.value,
8343
+ label: String(option.label ?? option.value),
8344
+ ...option.disabled ? { disabled: true } : {}
8345
+ } : { value: option, label: String(option) })
8111
8346
  } : {},
8112
8347
  promotable: false
8113
8348
  };
8114
8349
  }
8350
+ function asGpuHost(value) {
8351
+ return value && typeof value.ensure === "function" && typeof value.forNode === "function" ? value : null;
8352
+ }
8115
8353
  function attachDefinition(node, registration, capabilities) {
8116
8354
  const definition = registration.definition;
8117
8355
  const pendingTriggers = /* @__PURE__ */ new Map();
@@ -8186,12 +8424,18 @@ function attachDefinition(node, registration, capabilities) {
8186
8424
  if (!port) throw new Error(`Missing output ${registration.moduleId}.${name}`);
8187
8425
  return [name, output.kind === "trigger" ? { trigger: (payload) => port.trigger(payload) } : { set: (value) => port.setValue(value) }];
8188
8426
  }));
8427
+ let nodeCapabilities = capabilities;
8428
+ const gpuHost = (definition.capabilities ?? []).includes("gpu") ? asGpuHost(capabilities.gpu) : null;
8429
+ if (gpuHost) {
8430
+ await gpuHost.ensure();
8431
+ nodeCapabilities = { ...capabilities, gpu: gpuHost.forNode(node.id) };
8432
+ }
8189
8433
  const context = {
8190
8434
  nodeId: node.id,
8191
8435
  inputs: inputValues,
8192
8436
  outputs,
8193
8437
  props: Object.fromEntries(node.parameters.map((parameter) => [parameter.name, parameter.value])),
8194
- capabilities,
8438
+ capabilities: nodeCapabilities,
8195
8439
  signal: new AbortController().signal,
8196
8440
  progress: silentProgress
8197
8441
  };
@@ -8230,8 +8474,362 @@ var browserAssetCapability = {
8230
8474
  }
8231
8475
  };
8232
8476
 
8477
+ // src/nodes/definition/browserShellCapability.ts
8478
+ var capability = null;
8479
+ function forgetShellCapability() {
8480
+ capability = null;
8481
+ }
8482
+ async function token() {
8483
+ if (capability) return capability;
8484
+ const response = await fetch("/api/shell/capability", { cache: "no-store" });
8485
+ if (!response.ok) {
8486
+ throw new Error(
8487
+ response.status === 404 ? "The shell capability is disabled on this server. It is available on a loopback server, or one started with an explicit trusted host." : `Could not obtain the shell capability (${response.status})`
8488
+ );
8489
+ }
8490
+ const { capability: issued } = await response.json();
8491
+ if (typeof issued !== "string" || !issued) {
8492
+ throw new Error("Server issued no shell capability");
8493
+ }
8494
+ capability = issued;
8495
+ return issued;
8496
+ }
8497
+ function domSignal(signal) {
8498
+ if (!signal) return void 0;
8499
+ const controller = new AbortController();
8500
+ if (signal.aborted) controller.abort(signal.reason);
8501
+ else signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
8502
+ return controller.signal;
8503
+ }
8504
+ async function post(body, signal) {
8505
+ const bridged = domSignal(signal);
8506
+ const send = async (issued) => fetch("/api/shell", {
8507
+ method: "POST",
8508
+ headers: {
8509
+ "Content-Type": "application/json",
8510
+ "X-Cascade-Shell-Capability": issued
8511
+ },
8512
+ body: JSON.stringify(body),
8513
+ ...bridged ? { signal: bridged } : {}
8514
+ });
8515
+ let response = await send(await token());
8516
+ if (response.status === 403) {
8517
+ capability = null;
8518
+ response = await send(await token());
8519
+ }
8520
+ return response;
8521
+ }
8522
+ function requestBody(command, args, options) {
8523
+ const { signal: _signal, ...rest } = options ?? {};
8524
+ return {
8525
+ command,
8526
+ ...args ? { args } : {},
8527
+ ...Object.keys(rest).length > 0 ? { options: rest } : {}
8528
+ };
8529
+ }
8530
+ async function fail2(response) {
8531
+ let payload = null;
8532
+ try {
8533
+ payload = await response.json();
8534
+ } catch {
8535
+ }
8536
+ const message2 = typeof payload?.error === "string" ? payload.error : `Shell request failed (${response.status})`;
8537
+ if (payload?.code === "COMMAND_NOT_ALLOWED") {
8538
+ throw new Error(`${message2}. Add it under Allowed Commands in Project Settings.`);
8539
+ }
8540
+ throw new Error(message2);
8541
+ }
8542
+ var browserShellCapability = {
8543
+ async run(command, args, options) {
8544
+ const response = await post(requestBody(command, args, options), options?.signal);
8545
+ if (!response.ok) await fail2(response);
8546
+ const { ok: _ok, ...result } = await response.json();
8547
+ return result;
8548
+ },
8549
+ async runJson(command, args, options) {
8550
+ const result = await this.run(command, args, options);
8551
+ try {
8552
+ return JSON.parse(result.stdout);
8553
+ } catch {
8554
+ throw new Error(`${command} did not print JSON`);
8555
+ }
8556
+ }
8557
+ };
8558
+ async function shellRouteAvailable() {
8559
+ const forget = () => {
8560
+ forgetShellCapability();
8561
+ return false;
8562
+ };
8563
+ if (typeof fetch !== "function") return forget();
8564
+ try {
8565
+ const response = await fetch("/api/shell/capability", { cache: "no-store" });
8566
+ if (!response.ok) return forget();
8567
+ const { capability: issued } = await response.json();
8568
+ if (typeof issued !== "string" || !issued) return forget();
8569
+ capability = issued;
8570
+ return true;
8571
+ } catch {
8572
+ return forget();
8573
+ }
8574
+ }
8575
+
8576
+ // packages/runtime/src/gpu.ts
8577
+ var HOST_SCOPE = "\0host";
8578
+ var MISSING_DEVICE = "The gpu capability was used before its device was ready \u2014 the host must await ensure() before a cook.";
8579
+ var DISPOSED = "The gpu capability has been disposed.";
8580
+ function isGpuHost(value) {
8581
+ return !!value && typeof value.ensure === "function" && typeof value.forNode === "function" && typeof value.dispose === "function";
8582
+ }
8583
+ function createGpuHost(options) {
8584
+ let active = null;
8585
+ let starting = null;
8586
+ let disposal = null;
8587
+ let disposed = false;
8588
+ const caches = /* @__PURE__ */ new Map();
8589
+ const retiring = /* @__PURE__ */ new Set();
8590
+ const retirementErrors = [];
8591
+ function dropCaches() {
8592
+ const errors = [];
8593
+ const scopes = [...caches.values()];
8594
+ caches.clear();
8595
+ for (const entries of scopes) {
8596
+ for (const entry of entries.values()) {
8597
+ try {
8598
+ entry.destroy?.(entry.value);
8599
+ } catch (error) {
8600
+ errors.push(error);
8601
+ }
8602
+ }
8603
+ }
8604
+ return errors;
8605
+ }
8606
+ async function release(acquired, destroyDevice) {
8607
+ if (acquired.released) return [];
8608
+ acquired.released = true;
8609
+ const errors = [];
8610
+ if (destroyDevice) {
8611
+ try {
8612
+ acquired.device.destroy();
8613
+ } catch (error) {
8614
+ errors.push(error);
8615
+ }
8616
+ }
8617
+ try {
8618
+ await acquired.release?.();
8619
+ } catch (error) {
8620
+ errors.push(error);
8621
+ }
8622
+ return errors;
8623
+ }
8624
+ function throwCleanupErrors(errors) {
8625
+ if (errors.length === 1) throw errors[0];
8626
+ if (errors.length > 1) {
8627
+ const combined = new Error("GPU host cleanup failed");
8628
+ Object.assign(combined, { errors });
8629
+ throw combined;
8630
+ }
8631
+ }
8632
+ function watchLoss(acquired) {
8633
+ void acquired.device.lost.then(() => {
8634
+ if (disposed || active !== acquired) return;
8635
+ active = null;
8636
+ retirementErrors.push(...dropCaches());
8637
+ const cleanup = release(acquired, false).then((errors) => {
8638
+ retirementErrors.push(...errors);
8639
+ });
8640
+ retiring.add(cleanup);
8641
+ void cleanup.finally(() => retiring.delete(cleanup));
8642
+ }).catch(() => {
8643
+ });
8644
+ }
8645
+ async function start() {
8646
+ const result = await options.acquire();
8647
+ const acquired = {
8648
+ device: result.device,
8649
+ info: {
8650
+ vendor: result.info?.vendor ?? "",
8651
+ architecture: result.info?.architecture ?? "",
8652
+ device: result.info?.device ?? "",
8653
+ description: result.info?.description ?? ""
8654
+ },
8655
+ limits: readGpuLimits(result.limits),
8656
+ release: result.release,
8657
+ released: false
8658
+ };
8659
+ if (disposed) {
8660
+ const errors = await release(acquired, true);
8661
+ throwCleanupErrors(errors);
8662
+ throw new Error(DISPOSED);
8663
+ }
8664
+ active = acquired;
8665
+ watchLoss(acquired);
8666
+ }
8667
+ const host = {
8668
+ get device() {
8669
+ if (!active) throw new Error(MISSING_DEVICE);
8670
+ return active.device;
8671
+ },
8672
+ get adapterInfo() {
8673
+ if (!active) throw new Error(MISSING_DEVICE);
8674
+ return active.info;
8675
+ },
8676
+ get limits() {
8677
+ return active?.limits ?? {};
8678
+ },
8679
+ cache(key2, create, destroy) {
8680
+ return host.forNode(HOST_SCOPE).cache(key2, create, destroy);
8681
+ },
8682
+ async ensure() {
8683
+ if (disposed) throw new Error(DISPOSED);
8684
+ if (active) return;
8685
+ if (!starting) {
8686
+ const attempt = start();
8687
+ const tracked = attempt.finally(() => {
8688
+ if (starting === tracked) starting = null;
8689
+ });
8690
+ starting = tracked;
8691
+ }
8692
+ await starting;
8693
+ },
8694
+ forNode(nodeId) {
8695
+ return {
8696
+ get device() {
8697
+ if (!active) throw new Error(MISSING_DEVICE);
8698
+ return active.device;
8699
+ },
8700
+ get adapterInfo() {
8701
+ if (!active) throw new Error(MISSING_DEVICE);
8702
+ return active.info;
8703
+ },
8704
+ get limits() {
8705
+ return active?.limits ?? {};
8706
+ },
8707
+ cache(key2, create, destroy) {
8708
+ if (!active) throw new Error(MISSING_DEVICE);
8709
+ let entries = caches.get(nodeId);
8710
+ if (!entries) {
8711
+ entries = /* @__PURE__ */ new Map();
8712
+ caches.set(nodeId, entries);
8713
+ }
8714
+ const existing = entries.get(key2);
8715
+ if (existing) return existing.value;
8716
+ const value = create(active.device);
8717
+ entries.set(key2, {
8718
+ value,
8719
+ destroy
8720
+ });
8721
+ return value;
8722
+ }
8723
+ };
8724
+ },
8725
+ dispose() {
8726
+ disposal ??= performDispose();
8727
+ return disposal;
8728
+ }
8729
+ };
8730
+ async function performDispose() {
8731
+ disposed = true;
8732
+ const errors = dropCaches();
8733
+ const pending = starting;
8734
+ if (pending) {
8735
+ try {
8736
+ await pending;
8737
+ } catch (error) {
8738
+ if (!(error instanceof Error && error.message === DISPOSED))
8739
+ errors.push(error);
8740
+ }
8741
+ }
8742
+ const acquired = active;
8743
+ active = null;
8744
+ if (acquired) errors.push(...await release(acquired, true));
8745
+ await Promise.all([...retiring]);
8746
+ errors.push(...retirementErrors);
8747
+ retirementErrors.length = 0;
8748
+ throwCleanupErrors(errors);
8749
+ }
8750
+ return host;
8751
+ }
8752
+ function readGpuLimits(source) {
8753
+ if (!source || typeof source !== "object") return {};
8754
+ const names = new Set(Object.keys(source));
8755
+ for (let cursor = Object.getPrototypeOf(source); cursor && cursor !== Object.prototype; cursor = Object.getPrototypeOf(cursor)) {
8756
+ for (const key2 of Object.getOwnPropertyNames(cursor)) {
8757
+ if (key2 !== "constructor") names.add(key2);
8758
+ }
8759
+ }
8760
+ const limits = {};
8761
+ for (const key2 of names) {
8762
+ const value = source[key2];
8763
+ if (typeof value === "number") limits[key2] = value;
8764
+ }
8765
+ return limits;
8766
+ }
8767
+
8768
+ // src/nodes/definition/gpuCapability.ts
8769
+ function createStudioGpuCapability() {
8770
+ const gpu = globalThis.navigator?.gpu;
8771
+ if (!gpu) return void 0;
8772
+ return createGpuHost({
8773
+ async acquire() {
8774
+ const adapter = await gpu.requestAdapter();
8775
+ if (!adapter) {
8776
+ throw new Error(
8777
+ "No WebGPU adapter is available \u2014 the browser reports WebGPU but the system offers no GPU to render on."
8778
+ );
8779
+ }
8780
+ const device = await adapter.requestDevice();
8781
+ const info = adapter.info;
8782
+ return {
8783
+ device,
8784
+ info: {
8785
+ vendor: info?.vendor ?? "",
8786
+ architecture: info?.architecture ?? "",
8787
+ device: info?.device ?? "",
8788
+ description: info?.description ?? ""
8789
+ },
8790
+ limits: device.limits ?? adapter.limits
8791
+ };
8792
+ }
8793
+ });
8794
+ }
8795
+
8233
8796
  // src/nodes/definition/projectDefinition.ts
8234
- var studioCapabilities = { assets: browserAssetCapability };
8797
+ var studioCapabilities = {
8798
+ assets: browserAssetCapability,
8799
+ // Installed only where the browser has WebGPU, and absent rather than
8800
+ // stubbed otherwise. That is what makes the failure a sentence: the preflight
8801
+ // reports `runtime/missing-capability` and the adapter throws "requires the
8802
+ // Studio gpu capability", both naming what is missing. A stub that threw on
8803
+ // first use would name nothing.
8804
+ ...gpuCapabilityIfAvailable(),
8805
+ /**
8806
+ * Shell is present up front and withdrawn if the route turns out not to be
8807
+ * there, which is the opposite way round from gpu and worth the sentence.
8808
+ *
8809
+ * `navigator.gpu` is a synchronous check, so gpu can be absent from the map
8810
+ * before anything reads it. Whether the shell route exists takes a fetch, and
8811
+ * this object is a module-level const read by three default parameters — so
8812
+ * the choice is between delaying Studio's boot on a request, racing the
8813
+ * probe, or installing and withdrawing. Installing is the one where the
8814
+ * common case (a loopback server, where the route is always there) has no
8815
+ * race at all and no cost.
8816
+ *
8817
+ * What it costs: on a server that does NOT offer the route, a cook in the
8818
+ * first few milliseconds after boot sees the capability present and fails at
8819
+ * the fetch instead of at the gate. That failure still names itself — *"the
8820
+ * shell capability is disabled on this server"* — so it is a worse-shaped
8821
+ * error rather than a silent one, and once the probe lands the preflight is
8822
+ * correct from then on.
8823
+ */
8824
+ shell: browserShellCapability
8825
+ };
8826
+ void shellRouteAvailable().then((available) => {
8827
+ if (!available) delete studioCapabilities.shell;
8828
+ });
8829
+ function gpuCapabilityIfAvailable() {
8830
+ const gpu = createStudioGpuCapability();
8831
+ return gpu ? { gpu } : {};
8832
+ }
8235
8833
  function declaresDefinitionV1(value) {
8236
8834
  return typeof value === "object" && value !== null && value.apiVersion === 1;
8237
8835
  }
@@ -9429,7 +10027,9 @@ var Graph = class _Graph {
9429
10027
  result.parent = node.parent.id;
9430
10028
  }
9431
10029
  const rawParameterValue = (p) => node.rawParameterValue(p.name);
9432
- const changedParameters = (node.parameters ?? []).filter((p) => p.promoted || JSON.stringify(rawParameterValue(p)) !== JSON.stringify(p.defaultValue));
10030
+ const changedParameters = (node.parameters ?? []).filter(
10031
+ (p) => p.promoted || node.preservesPlainProp(p.name) || JSON.stringify(rawParameterValue(p)) !== JSON.stringify(p.defaultValue)
10032
+ );
9433
10033
  const parameters = changedParameters.filter((p) => p.documentField !== "props").map((p) => p.promoted ? { name: p.name, value: rawParameterValue(p), promoted: true } : { name: p.name, value: rawParameterValue(p) });
9434
10034
  if (parameters.length > 0) {
9435
10035
  result.params = parameters;
@@ -9628,7 +10228,8 @@ var Graph = class _Graph {
9628
10228
  value = propData;
9629
10229
  }
9630
10230
  let normalizedValue = value;
9631
- if (isColorValue(value)) {
10231
+ const declaredColor = node.props[key2]?.type === "color";
10232
+ if (isColorValue(value) && (declaredColor || !Array.isArray(value))) {
9632
10233
  const normalized = normalizeColor(value);
9633
10234
  normalizedValue = normalized.a !== void 0 && normalized.a !== 1 ? { r: normalized.r, g: normalized.g, b: normalized.b, a: normalized.a } : { r: normalized.r, g: normalized.g, b: normalized.b };
9634
10235
  }
@@ -9644,6 +10245,12 @@ var Graph = class _Graph {
9644
10245
  }
9645
10246
  if (expression && node.props[key2]) {
9646
10247
  node.props[key2].expression = expression;
10248
+ } else {
10249
+ node.preservePlainProp(key2);
10250
+ }
10251
+ if (!expression && node.props[key2]?.expression) {
10252
+ delete node.props[key2].expression;
10253
+ node.props[key2] = { ...node.props[key2] };
9647
10254
  }
9648
10255
  if (propData?.channel && node.props[key2]) {
9649
10256
  node.restorePropChannel(key2, propData.channel);
@@ -9894,6 +10501,81 @@ var CascadeRuntimeError = class extends Error {
9894
10501
  }
9895
10502
  };
9896
10503
 
10504
+ // packages/runtime/src/camera/index.ts
10505
+ var DEG = Math.PI / 180;
10506
+ function cross(a, b) {
10507
+ return [
10508
+ a[1] * b[2] - a[2] * b[1],
10509
+ a[2] * b[0] - a[0] * b[2],
10510
+ a[0] * b[1] - a[1] * b[0]
10511
+ ];
10512
+ }
10513
+ function normalize(v) {
10514
+ const length = Math.hypot(v[0], v[1], v[2]);
10515
+ if (length === 0) return [0, 0, 0];
10516
+ return [v[0] / length, v[1] / length, v[2] / length];
10517
+ }
10518
+ function subtract(a, b) {
10519
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
10520
+ }
10521
+ function dot(a, b) {
10522
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
10523
+ }
10524
+ function lookAtRotation(from, to, up = [0, 1, 0]) {
10525
+ const forward = normalize(subtract(to, from));
10526
+ if (forward[0] === 0 && forward[1] === 0 && forward[2] === 0) return [0, 0, 0];
10527
+ const horizontal = Math.hypot(forward[0], forward[2]);
10528
+ const pitch = Math.atan2(forward[1], horizontal);
10529
+ const yaw = Math.atan2(-forward[0], -forward[2]);
10530
+ return [pitch / DEG, yaw / DEG, rollTowards(forward, pitch, yaw, up) / DEG];
10531
+ }
10532
+ function rollTowards(forward, pitch, yaw, up) {
10533
+ const desired = normalize([
10534
+ up[0] - forward[0] * dot(up, forward),
10535
+ up[1] - forward[1] * dot(up, forward),
10536
+ up[2] - forward[2] * dot(up, forward)
10537
+ ]);
10538
+ if (desired[0] === 0 && desired[1] === 0 && desired[2] === 0) return 0;
10539
+ const locked = cameraBasis({
10540
+ ...CAMERA_ROTATION_ONLY,
10541
+ rotate: [pitch / DEG, yaw / DEG, 0]
10542
+ }).up;
10543
+ return Math.atan2(dot(cross(desired, locked), forward), dot(locked, desired));
10544
+ }
10545
+ var CAMERA_ROTATION_ONLY = {
10546
+ translate: [0, 0, 0],
10547
+ focal: 50,
10548
+ aperture: 41.4214,
10549
+ near: 1e-3,
10550
+ far: 1e4,
10551
+ resolution: [1, 1],
10552
+ aspect: 1,
10553
+ projection: "perspective",
10554
+ orthowidth: 2
10555
+ };
10556
+ function cameraBasis(camera) {
10557
+ const [rx, ry, rz] = camera.rotate;
10558
+ const sx = Math.sin(rx * DEG);
10559
+ const cx = Math.cos(rx * DEG);
10560
+ const sy = Math.sin(ry * DEG);
10561
+ const cy = Math.cos(ry * DEG);
10562
+ const sz = Math.sin(rz * DEG);
10563
+ const cz = Math.cos(rz * DEG);
10564
+ const m00 = cy * cz + sy * sx * sz;
10565
+ const m01 = -cy * sz + sy * sx * cz;
10566
+ const m02 = sy * cx;
10567
+ const m10 = cx * sz;
10568
+ const m11 = cx * cz;
10569
+ const m12 = -sx;
10570
+ const m20 = -sy * cz + cy * sx * sz;
10571
+ const m21 = sy * sz + cy * sx * cz;
10572
+ const m22 = cy * cx;
10573
+ const right = [m00, m10, m20];
10574
+ const up = [m01, m11, m21];
10575
+ const forward = [-m02, -m12, -m22];
10576
+ return { eye: camera.translate, forward, right, up };
10577
+ }
10578
+
9897
10579
  // packages/runtime/src/geometry/attributes.ts
9898
10580
  init_src();
9899
10581
  function geometryError(code, message2) {
@@ -10780,6 +11462,134 @@ function utf8Bytes(value) {
10780
11462
  // packages/runtime/src/runtime.ts
10781
11463
  init_src();
10782
11464
 
11465
+ // packages/runtime/src/builtins/core/camera.ts
11466
+ init_src();
11467
+ var cameraDefinition = {
11468
+ apiVersion: 1,
11469
+ label: "Camera",
11470
+ description: "A camera, following Houdini's /obj/cam.",
11471
+ icon: "Video",
11472
+ runsOn: "portable",
11473
+ inputs: {
11474
+ translate: {
11475
+ kind: "data",
11476
+ type: "vec3",
11477
+ default: [0, 0, 5],
11478
+ description: "World position."
11479
+ },
11480
+ rotate: {
11481
+ kind: "data",
11482
+ type: "vec3",
11483
+ default: [0, 0, 0],
11484
+ description: "Degrees, XYZ order. The camera looks down its own -Z."
11485
+ },
11486
+ focal: {
11487
+ kind: "data",
11488
+ type: "float",
11489
+ default: 50,
11490
+ min: 1,
11491
+ max: 300,
11492
+ description: "Focal length in millimetres."
11493
+ },
11494
+ lookat: {
11495
+ kind: "data",
11496
+ type: "vec3",
11497
+ default: [0, 0, 0],
11498
+ description: "Point to aim at. Used only when Look At is on, and it replaces Rotate."
11499
+ },
11500
+ up: {
11501
+ kind: "data",
11502
+ type: "vec3",
11503
+ default: [0, 1, 0],
11504
+ description: "Up vector for Look At."
11505
+ },
11506
+ spin: {
11507
+ kind: "data",
11508
+ type: "float",
11509
+ default: 0,
11510
+ description: "Degrees added to the Y rotation, after Look At. For turntables."
11511
+ }
11512
+ },
11513
+ outputs: {
11514
+ camera: { kind: "data", type: "camera" }
11515
+ },
11516
+ props: {
11517
+ lookAt: {
11518
+ type: "bool",
11519
+ default: false,
11520
+ label: "Look At",
11521
+ description: "Aim at the Look At point instead of using Rotate."
11522
+ },
11523
+ aperture: {
11524
+ type: "float",
11525
+ default: 41.4214,
11526
+ min: 1,
11527
+ max: 200,
11528
+ label: "Aperture",
11529
+ description: "Horizontal aperture in millimetres. With the default focal length this is a 45 degree view."
11530
+ },
11531
+ resolution: {
11532
+ type: "vec2i",
11533
+ default: [1280, 720],
11534
+ label: "Resolution",
11535
+ description: "Frame size in pixels. The vertical field of view is derived from this."
11536
+ },
11537
+ aspect: {
11538
+ type: "float",
11539
+ default: 1,
11540
+ min: 0.1,
11541
+ max: 10,
11542
+ label: "Pixel Aspect",
11543
+ description: "The aspect of one pixel, not of the frame."
11544
+ },
11545
+ projection: {
11546
+ type: "string",
11547
+ default: "perspective",
11548
+ label: "Projection",
11549
+ control: "select",
11550
+ options: [
11551
+ { value: "perspective", label: "Perspective" },
11552
+ { value: "orthographic", label: "Orthographic" }
11553
+ ]
11554
+ },
11555
+ orthowidth: {
11556
+ type: "float",
11557
+ default: 2,
11558
+ min: 0.01,
11559
+ label: "Ortho Width",
11560
+ description: "Width of the orthographic frame in world units."
11561
+ },
11562
+ near: { type: "float", default: 1e-3, min: 0, label: "Near Clip" },
11563
+ far: { type: "float", default: 1e4, min: 0, label: "Far Clip" }
11564
+ }
11565
+ };
11566
+ function executeCamera(context) {
11567
+ const { translate, rotate, focal, lookat, up, spin } = context.inputs;
11568
+ const props = context.props;
11569
+ const aimed = props.lookAt ? lookAtRotation(translate, lookat, up) : rotate;
11570
+ const oriented = spin === 0 ? aimed : [aimed[0], aimed[1] + spin, aimed[2]];
11571
+ const camera = {
11572
+ ...CAMERA_DEFAULTS,
11573
+ translate,
11574
+ rotate: oriented,
11575
+ focal,
11576
+ aperture: props.aperture,
11577
+ resolution: props.resolution,
11578
+ aspect: props.aspect,
11579
+ projection: props.projection === "orthographic" ? "orthographic" : "perspective",
11580
+ orthowidth: props.orthowidth,
11581
+ near: props.near,
11582
+ far: props.far
11583
+ };
11584
+ context.outputs.camera.set(camera);
11585
+ }
11586
+ var cameraRegistration = {
11587
+ kind: "definition-v1",
11588
+ moduleId: "cascade.core.Camera",
11589
+ definition: cameraDefinition,
11590
+ loadExecute: async () => executeCamera
11591
+ };
11592
+
10783
11593
  // packages/runtime/src/builtins/core/random.ts
10784
11594
  var randomDefinition = {
10785
11595
  apiVersion: 1,
@@ -11059,7 +11869,8 @@ var coreNodeRegistrations = Object.freeze([
11059
11869
  nullRegistration,
11060
11870
  selectRegistration,
11061
11871
  randomRegistration,
11062
- remapRegistration
11872
+ remapRegistration,
11873
+ cameraRegistration
11063
11874
  ]);
11064
11875
  var registrationsById = new Map(
11065
11876
  coreNodeRegistrations.map((registration) => [registration.moduleId, registration])
@@ -11409,7 +12220,12 @@ function builtinNodeRegistration(moduleId2) {
11409
12220
  // packages/runtime/src/runtime.ts
11410
12221
  var PREFLIGHT_WARNING_CODES = /* @__PURE__ */ new Set([
11411
12222
  "runtime/preflight-environment",
11412
- "runtime/stray-params"
12223
+ "runtime/stray-params",
12224
+ "runtime/unknown-props"
12225
+ ]);
12226
+ var RUN_TOLERATED_CODES = /* @__PURE__ */ new Set([
12227
+ "runtime/stray-params",
12228
+ "runtime/unknown-props"
11413
12229
  ]);
11414
12230
  var MutableAbortSignal = class {
11415
12231
  constructor(report) {
@@ -11559,6 +12375,46 @@ var Graph2 = class {
11559
12375
  this.frame = MIN_FRAME;
11560
12376
  this.fps = DEFAULT_FPS;
11561
12377
  this.byId = new Map(nodes.map((node) => [node.id, node]));
12378
+ const byInput = /* @__PURE__ */ new Map();
12379
+ const byNode = /* @__PURE__ */ new Map();
12380
+ const connectedInputs = /* @__PURE__ */ new Set();
12381
+ const connectedOutputs = /* @__PURE__ */ new Set();
12382
+ for (const connection of connections) {
12383
+ connectedInputs.add(
12384
+ `${connection.target.nodeId}\0${connection.target.inputName}`
12385
+ );
12386
+ connectedOutputs.add(
12387
+ `${connection.source.nodeId}\0${connection.source.outputName}`
12388
+ );
12389
+ if (connection.kind !== "data") continue;
12390
+ const inputKey = `${connection.target.nodeId}\0${connection.target.inputName}`;
12391
+ const inputConnections = byInput.get(inputKey) ?? [];
12392
+ inputConnections.push(connection);
12393
+ byInput.set(inputKey, inputConnections);
12394
+ const nodeConnections = byNode.get(connection.target.nodeId) ?? [];
12395
+ nodeConnections.push(connection);
12396
+ byNode.set(connection.target.nodeId, nodeConnections);
12397
+ }
12398
+ this.dataConnectionsByInput = new Map(
12399
+ [...byInput].map(([key2, inputConnections]) => [
12400
+ key2,
12401
+ Object.freeze(
12402
+ inputConnections.sort(
12403
+ (first, second) => (first.variadicIndex ?? first.order) - (second.variadicIndex ?? second.order)
12404
+ )
12405
+ )
12406
+ ])
12407
+ );
12408
+ this.dataConnectionsByNode = new Map(
12409
+ [...byNode].map(([key2, nodeConnections]) => [
12410
+ key2,
12411
+ Object.freeze(
12412
+ nodeConnections.sort((first, second) => first.order - second.order)
12413
+ )
12414
+ ])
12415
+ );
12416
+ this.connectedInputs = connectedInputs;
12417
+ this.connectedOutputs = connectedOutputs;
11562
12418
  this.graphInputs = graphBoundaries(nodes, "cascade.core.Input", "inputName");
11563
12419
  this.graphOutputs = graphBoundaries(nodes, "cascade.core.Output", "outputName");
11564
12420
  this.authoredAnnotations = Object.freeze(
@@ -11568,9 +12424,7 @@ var Graph2 = class {
11568
12424
  nodes,
11569
12425
  node: (id) => this.byId.get(id),
11570
12426
  inputNode: (node, index) => {
11571
- const feeding = this.connections.filter(
11572
- (connection) => connection.kind === "data" && connection.target.nodeId === node.id
11573
- ).sort((a, b) => a.order - b.order);
12427
+ const feeding = this.dataConnectionsByNode.get(node.id) ?? [];
11574
12428
  const source = feeding[index]?.source.nodeId;
11575
12429
  return (source ? this.byId.get(source) : void 0) ?? null;
11576
12430
  },
@@ -11614,9 +12468,7 @@ var Graph2 = class {
11614
12468
  {
11615
12469
  kind: definition.kind,
11616
12470
  type: definition.kind === "trigger" ? "trigger" : definition.type,
11617
- connected: this.connections.some(
11618
- (connection) => connection.target.nodeId === node.id && connection.target.inputName === name
11619
- ),
12471
+ connected: this.connectedInputs.has(`${node.id}\0${name}`),
11620
12472
  ...definition.kind === "data" ? { value: snapshot(node.inputs[name]) } : {}
11621
12473
  }
11622
12474
  ]
@@ -11629,9 +12481,7 @@ var Graph2 = class {
11629
12481
  {
11630
12482
  kind: definition.kind,
11631
12483
  type: definition.kind === "trigger" ? "trigger" : definition.type,
11632
- connected: this.connections.some(
11633
- (connection) => connection.source.nodeId === node.id && connection.source.outputName === name
11634
- ),
12484
+ connected: this.connectedOutputs.has(`${node.id}\0${name}`),
11635
12485
  ...definition.kind === "data" && node.outputs.has(name) ? { value: snapshot(node.outputs.get(name)) } : {}
11636
12486
  }
11637
12487
  ]
@@ -11981,11 +12831,7 @@ var Graph2 = class {
11981
12831
  const index = integerProp(node.props.inputIndex);
11982
12832
  return [name, this.dataInputValue(parent, `input_${index}`)];
11983
12833
  }
11984
- const connections = this.connections.filter(
11985
- (item) => item.kind === "data" && item.target.nodeId === node.id && item.target.inputName === name
11986
- ).sort(
11987
- (first, second) => (first.variadicIndex ?? first.order) - (second.variadicIndex ?? second.order)
11988
- );
12834
+ const connections = this.dataConnectionsByInput.get(`${node.id}\0${name}`) ?? [];
11989
12835
  if (definition.variadic) {
11990
12836
  const values = connections.map(
11991
12837
  (connection2) => this.requireNode(connection2.source.nodeId).outputs.get(
@@ -12086,14 +12932,20 @@ var Graph2 = class {
12086
12932
  ]
12087
12933
  )
12088
12934
  );
12089
- const capabilities = Object.freeze(
12090
- Object.fromEntries(
12091
- (node.definition.capabilities ?? []).map((name) => [
12935
+ const capabilityEntries = [];
12936
+ for (const name of node.definition.capabilities ?? []) {
12937
+ const installed = this.host.capabilities[name];
12938
+ if (name === "gpu" && isGpuHost(installed)) {
12939
+ await installed.ensure();
12940
+ capabilityEntries.push([name, installed.forNode(node.id)]);
12941
+ } else {
12942
+ capabilityEntries.push([
12092
12943
  name,
12093
- name === "shell" ? bindShellToRun(this.host.capabilities.shell, signal) : this.host.capabilities[name]
12094
- ])
12095
- )
12096
- );
12944
+ name === "shell" ? bindShellToRun(this.host.capabilities.shell, signal) : installed
12945
+ ]);
12946
+ }
12947
+ }
12948
+ const capabilities = Object.freeze(Object.fromEntries(capabilityEntries));
12097
12949
  const props = Object.freeze({ ...node.props });
12098
12950
  const progress = {
12099
12951
  report: (event) => this.emit({ type: "node:progress", runId, nodeId: node.id, ...event })
@@ -12156,6 +13008,12 @@ var Graph2 = class {
12156
13008
  const diagnostics = [];
12157
13009
  const push = (code, nodeId, message2) => diagnostics.push({ phase: "preflight", code, path: nodeId, message: message2 });
12158
13010
  for (const node of nodes) {
13011
+ if (node.unknownProps.length)
13012
+ push(
13013
+ "runtime/unknown-props",
13014
+ node.id,
13015
+ `${node.moduleId} stores ${node.unknownProps.join(", ")} under "props", and its definition declares no such prop \u2014 ${node.unknownProps.length === 1 ? "that value was" : "those values were"} dropped on load. A prop renamed or removed leaves its value behind; a name that never existed is a typo`
13016
+ );
12159
13017
  if (node.strayParams.length)
12160
13018
  push(
12161
13019
  "runtime/stray-params",
@@ -12194,12 +13052,12 @@ var Graph2 = class {
12194
13052
  `${node.moduleId} cannot send textures over the server bridge`
12195
13053
  );
12196
13054
  } else
12197
- for (const capability of node.definition.capabilities ?? [])
12198
- if (!this.host.capabilities[capability])
13055
+ for (const capability2 of node.definition.capabilities ?? [])
13056
+ if (!this.host.capabilities[capability2])
12199
13057
  push(
12200
13058
  "runtime/missing-capability",
12201
13059
  node.id,
12202
- `Missing capability ${capability} for ${node.moduleId} (node ${node.id}) \u2014 the ${this.host.environment} host ${this.hostProvides()}`
13060
+ `Missing capability ${capability2} for ${node.moduleId} (node ${node.id}) \u2014 the ${this.host.environment} host ${this.hostProvides()}`
12203
13061
  );
12204
13062
  }
12205
13063
  return diagnostics;
@@ -12215,7 +13073,7 @@ var Graph2 = class {
12215
13073
  }
12216
13074
  assertPreflight(nodes) {
12217
13075
  const [first] = this.preflightDiagnostics(nodes).filter(
12218
- (diagnostic2) => !PREFLIGHT_WARNING_CODES.has(diagnostic2.code)
13076
+ (diagnostic2) => !RUN_TOLERATED_CODES.has(diagnostic2.code)
12219
13077
  );
12220
13078
  if (first) throw new CascadeRuntimeError(first.code, [first]);
12221
13079
  }
@@ -12229,8 +13087,9 @@ var Graph2 = class {
12229
13087
  }
12230
13088
  runSelection(target) {
12231
13089
  if (target.kind === "all") {
13090
+ const gatedDataDescendants = this.gatedDataDescendants();
12232
13091
  const initial = this.nodes.filter(
12233
- (node) => !isGated(node) && !this.hasGatedDataAncestor(node.id)
13092
+ (node) => !isGated(node) && !gatedDataDescendants.has(node.id)
12234
13093
  );
12235
13094
  const ids = new Set(initial.map((node) => node.id));
12236
13095
  let changed = true;
@@ -12278,8 +13137,24 @@ var Graph2 = class {
12278
13137
  }
12279
13138
  return this.nodes.filter((node) => ids.has(node.id));
12280
13139
  }
12281
- hasGatedDataAncestor(nodeId) {
12282
- return this.dataClosure(nodeId).some(isGated);
13140
+ gatedDataDescendants() {
13141
+ const outgoing = /* @__PURE__ */ new Map();
13142
+ for (const dependency of dataDependencies(this.nodes, this.connections)) {
13143
+ const targets = outgoing.get(dependency.sourceId) ?? [];
13144
+ targets.push(dependency.targetId);
13145
+ outgoing.set(dependency.sourceId, targets);
13146
+ }
13147
+ const queue = this.nodes.filter(isGated).map((node) => node.id);
13148
+ const descendants = new Set(queue);
13149
+ let queueIndex = 0;
13150
+ while (queueIndex < queue.length) {
13151
+ for (const targetId of outgoing.get(queue[queueIndex++]) ?? []) {
13152
+ if (descendants.has(targetId)) continue;
13153
+ descendants.add(targetId);
13154
+ queue.push(targetId);
13155
+ }
13156
+ }
13157
+ return descendants;
12283
13158
  }
12284
13159
  triggerDataAncestors(nodeId) {
12285
13160
  const dependencies = this.dataClosure(nodeId).filter(
@@ -12454,8 +13329,23 @@ function materialize(authored, registration) {
12454
13329
  false,
12455
13330
  `${authored.id}.${name}`
12456
13331
  );
13332
+ if (Array.isArray(authored.props))
13333
+ misuse(
13334
+ "runtime/props-not-a-map",
13335
+ `Node ${authored.id} stores props as a list. "props" is a map of name to value; "params" was the list. Every value in it is being dropped, silently, and the graph would run on its defaults`
13336
+ );
12457
13337
  const bindings = {};
12458
- for (const [name, saved] of Object.entries(authored.props ?? {})) {
13338
+ const storedProps = authored.props ?? {};
13339
+ for (const [name, definition] of Object.entries(
13340
+ registration.definition.props ?? {}
13341
+ )) {
13342
+ if (!definition.expression || name in storedProps) continue;
13343
+ bindings[name] = {
13344
+ value: props[name],
13345
+ expression: definition.expression
13346
+ };
13347
+ }
13348
+ for (const [name, saved] of Object.entries(storedProps)) {
12459
13349
  const definition = registration.definition.props?.[name];
12460
13350
  if (!definition) continue;
12461
13351
  const object = saved && typeof saved === "object" && "value" in saved ? saved : null;
@@ -12466,7 +13356,10 @@ function materialize(authored, registration) {
12466
13356
  `${authored.id}.${name}`
12467
13357
  );
12468
13358
  props[name] = value;
12469
- if (!object) continue;
13359
+ if (!object) {
13360
+ delete bindings[name];
13361
+ continue;
13362
+ }
12470
13363
  const channel = deserializeChannel(object.channel);
12471
13364
  const expression = typeof object.expression === "string" && object.expression ? object.expression : void 0;
12472
13365
  const binding = {
@@ -12486,9 +13379,16 @@ function materialize(authored, registration) {
12486
13379
  props,
12487
13380
  bindings,
12488
13381
  strayParams: strayParams(authored, registration),
13382
+ unknownProps: unknownProps(authored, registration),
12489
13383
  outputs: /* @__PURE__ */ new Map()
12490
13384
  };
12491
13385
  }
13386
+ function unknownProps(authored, registration) {
13387
+ const props = authored.props;
13388
+ if (!props || Array.isArray(props)) return [];
13389
+ const declared = registration.definition.props ?? {};
13390
+ return Object.keys(props).filter((name) => !(name in declared));
13391
+ }
12492
13392
  function strayParams(authored, registration) {
12493
13393
  const definition = registration.definition;
12494
13394
  const declared = /* @__PURE__ */ new Set([
@@ -12727,6 +13627,7 @@ function validateTopology(nodes, connections) {
12727
13627
  topological(nodes, connections);
12728
13628
  }
12729
13629
  function topological(nodes, connections) {
13630
+ const byId = new Map(nodes.map((node) => [node.id, node]));
12730
13631
  const indegree = new Map(nodes.map((node) => [node.id, 0]));
12731
13632
  const outgoing = new Map(nodes.map((node) => [node.id, []]));
12732
13633
  for (const dependency of graphDependencies(nodes, connections)) {
@@ -12738,13 +13639,14 @@ function topological(nodes, connections) {
12738
13639
  }
12739
13640
  const queue = nodes.filter((node) => indegree.get(node.id) === 0);
12740
13641
  const result = [];
12741
- while (queue.length) {
12742
- const node = queue.shift();
13642
+ let queueIndex = 0;
13643
+ while (queueIndex < queue.length) {
13644
+ const node = queue[queueIndex++];
12743
13645
  result.push(node);
12744
13646
  for (const id of outgoing.get(node.id)) {
12745
13647
  indegree.set(id, indegree.get(id) - 1);
12746
13648
  if (indegree.get(id) === 0)
12747
- queue.push(nodes.find((candidate) => candidate.id === id));
13649
+ queue.push(byId.get(id));
12748
13650
  }
12749
13651
  }
12750
13652
  if (result.length !== nodes.length)
@@ -12932,12 +13834,16 @@ function validateNodeModuleArchitecture(source, fileName, ts3) {
12932
13834
  ts3.ScriptKind.TS
12933
13835
  );
12934
13836
  const diagnostics = [];
13837
+ const exportsDefinition = file.statements.some(
13838
+ (statement) => ts3.isVariableStatement(statement) && isDefinition(statement)
13839
+ );
12935
13840
  for (const statement of file.statements) {
12936
13841
  if (ts3.isVariableStatement(statement) && !isDefinition(statement)) {
13842
+ const superseded = supersededByDefinition(statement);
12937
13843
  add(
12938
13844
  statement,
12939
13845
  "module-state",
12940
- "Node modules may not declare module-level values; use a pure function or pass state through ports, props, and declared capabilities"
13846
+ superseded ? `\`${superseded}\` belongs inside the definition, not beside it \u2014 a definition-v1 node declares it as \`${superseded}: ...\` in \`export const definition\`, and a module-level copy is the module state this rule forbids. A dynamic node without a definition still declares it here.` : "Node modules may not declare module-level values; use a pure function or pass state through ports, props, and declared capabilities"
12941
13847
  );
12942
13848
  } else if (ts3.isClassDeclaration(statement) || ts3.isEnumDeclaration(statement) || ts3.isModuleDeclaration(statement)) {
12943
13849
  add(
@@ -12989,6 +13895,13 @@ function validateNodeModuleArchitecture(source, fileName, ts3) {
12989
13895
  const parent = node.parent;
12990
13896
  return !(ts3.isPropertyAccessExpression(parent) && parent.name === node || ts3.isPropertyAssignment(parent) && parent.name === node || ts3.isMethodDeclaration(parent) && parent.name === node || ts3.isPropertyDeclaration(parent) && parent.name === node);
12991
13897
  }
13898
+ function supersededByDefinition(statement) {
13899
+ if (!exportsDefinition) return null;
13900
+ const declaration = statement.declarationList.declarations[0];
13901
+ if (statement.declarationList.declarations.length !== 1) return null;
13902
+ if (!declaration || !ts3.isIdentifier(declaration.name)) return null;
13903
+ return declaration.name.text === "icon" || declaration.name.text === "runsOn" ? declaration.name.text : null;
13904
+ }
12992
13905
  function isDefinition(statement) {
12993
13906
  return Boolean(statement.declarationList.flags & ts3.NodeFlags.Const) && Boolean(statement.modifiers?.some((item) => item.kind === ts3.SyntaxKind.ExportKeyword)) && statement.declarationList.declarations.length === 1 && ts3.isIdentifier(statement.declarationList.declarations[0].name) && statement.declarationList.declarations[0].name.text === "definition";
12994
13907
  }
@@ -13006,15 +13919,15 @@ function validateNodeModuleArchitecture(source, fileName, ts3) {
13006
13919
  }
13007
13920
  function importsSiblingNode(fileName, specifier) {
13008
13921
  if (!specifier.startsWith(".")) return false;
13009
- const file = normalize(fileName).split("/");
13922
+ const file = normalize2(fileName).split("/");
13010
13923
  const nodesIndex = file.lastIndexOf("nodes");
13011
13924
  if (nodesIndex < 0 || !file[nodesIndex + 1]) return false;
13012
13925
  const sourceNode = file[nodesIndex + 1];
13013
- const resolved = normalize([...file.slice(0, -1), ...specifier.split("/")].join("/")).split("/");
13926
+ const resolved = normalize2([...file.slice(0, -1), ...specifier.split("/")].join("/")).split("/");
13014
13927
  const resolvedNodesIndex = resolved.lastIndexOf("nodes");
13015
13928
  return resolvedNodesIndex >= 0 && Boolean(resolved[resolvedNodesIndex + 1]) && resolved[resolvedNodesIndex + 1] !== sourceNode;
13016
13929
  }
13017
- function normalize(value) {
13930
+ function normalize2(value) {
13018
13931
  const output = [];
13019
13932
  for (const part of value.split("\\").join("/").split("/")) {
13020
13933
  if (!part || part === ".") continue;
@@ -13129,6 +14042,41 @@ function imagePath(value) {
13129
14042
  return null;
13130
14043
  }
13131
14044
 
14045
+ // src/cli/headlessGpu.ts
14046
+ var MISSING_GPU_MESSAGE = 'Cascade cannot load the optional Dawn WebGPU renderer. Install "webgpu@0.6.0" on a supported platform and run again.';
14047
+ function createDawnGpuHost() {
14048
+ return createGpuHost({
14049
+ async acquire() {
14050
+ let dawn;
14051
+ try {
14052
+ dawn = await import("webgpu");
14053
+ } catch (cause) {
14054
+ throw Object.assign(new Error(MISSING_GPU_MESSAGE), { cause });
14055
+ }
14056
+ let gpu = dawn.create([]);
14057
+ let device;
14058
+ try {
14059
+ const adapter = await gpu.requestAdapter();
14060
+ if (!adapter) throw new Error("Dawn found no WebGPU adapter. Check GPU drivers and backend availability; headless rendering still needs a hardware or explicitly configured software adapter.");
14061
+ device = await adapter.requestDevice();
14062
+ const info = adapter.info;
14063
+ return { device, info: {
14064
+ vendor: info.vendor,
14065
+ architecture: info.architecture,
14066
+ device: info.device,
14067
+ description: info.description
14068
+ }, limits: device.limits, release: () => {
14069
+ gpu = void 0;
14070
+ } };
14071
+ } catch (error) {
14072
+ device?.destroy();
14073
+ gpu = void 0;
14074
+ throw error;
14075
+ }
14076
+ }
14077
+ });
14078
+ }
14079
+
13132
14080
  // src/cli/projectRuntime.ts
13133
14081
  function createProjectHost(file, registrations) {
13134
14082
  const project = new ProjectRoot(path9.dirname(path9.resolve(file)));
@@ -13139,7 +14087,8 @@ function createProjectHost(file, registrations) {
13139
14087
  // `cascade.geo.SvgExport` declares it — so an export graph passed both
13140
14088
  // `validate` and `check` and then refused to run. See ./nodeAssets.ts
13141
14089
  // for where the file lands and why.
13142
- assets: createNodeAssetCapability(project)
14090
+ assets: createNodeAssetCapability(project),
14091
+ gpu: createDawnGpuHost()
13143
14092
  });
13144
14093
  }
13145
14094
  async function preflightAgainstRunHost(file, registrations, document2) {
@@ -13157,14 +14106,23 @@ async function preflightAgainstRunHost(file, registrations, document2) {
13157
14106
  try {
13158
14107
  const graph = await runtime.load(document2);
13159
14108
  if (checkable) diagnostics = graph.preflight();
13160
- await graph.dispose();
13161
14109
  } finally {
13162
14110
  await runtime.dispose();
14111
+ await disposeProjectGpu(host);
13163
14112
  }
13164
14113
  if (!diagnostics.length) return;
13165
14114
  const { errors, warnings } = classifyPreflight(diagnostics);
14115
+ reportPreflightWarnings(host, warnings);
14116
+ if (!errors.length) return;
14117
+ throw new Error([
14118
+ `This graph cannot run on the ${host.environment} host that "cascade run" uses:`,
14119
+ ...errors.map(line),
14120
+ ...capabilityAdvice(host, errors)
14121
+ ].join("\n"));
14122
+ }
14123
+ function reportPreflightWarnings(host, warnings) {
13166
14124
  const elsewhere = warnings.filter((item) => item.code === "runtime/preflight-environment");
13167
- const lossy = warnings.filter((item) => item.code === "runtime/stray-params");
14125
+ const lossy = warnings.filter((item) => item.code === "runtime/stray-params" || item.code === "runtime/unknown-props");
13168
14126
  if (elsewhere.length) {
13169
14127
  console.warn([
13170
14128
  `Not checkable here \u2014 ${elsewhere.length === 1 ? "one node" : `${elsewhere.length} nodes`} in this graph target${elsewhere.length === 1 ? "s" : ""} another host:`,
@@ -13174,17 +14132,11 @@ async function preflightAgainstRunHost(file, registrations, document2) {
13174
14132
  }
13175
14133
  if (lossy.length) {
13176
14134
  console.warn([
13177
- `Stored values were dropped on load \u2014 ${lossy.length === 1 ? "one node" : `${lossy.length} nodes`} still keep${lossy.length === 1 ? "s" : ""} values under "params":`,
14135
+ `Stored values were dropped on load \u2014 ${lossy.length === 1 ? "one node" : `${lossy.length} nodes`}:`,
13178
14136
  ...lossy.map(line),
13179
14137
  ` This graph will run, on the defaults, and look like it worked.`
13180
14138
  ].join("\n"));
13181
14139
  }
13182
- if (!errors.length) return;
13183
- throw new Error([
13184
- `This graph cannot run on the ${host.environment} host that "cascade run" uses:`,
13185
- ...errors.map(line),
13186
- ...capabilityAdvice(host, errors)
13187
- ].join("\n"));
13188
14140
  }
13189
14141
  function line(item) {
13190
14142
  return ` ${item.code}${item.path ? ` [${item.path}]` : ""}: ${item.message}`;
@@ -13201,8 +14153,8 @@ function capabilityAdvice(host, diagnostics) {
13201
14153
  const hostCapabilities = CAPABILITIES_BY_ENVIRONMENT[host.environment] ?? [];
13202
14154
  return missing.map((name) => hostCapabilities.includes(name) ? ` ${name}: declared in @cascade/contracts but not installed by the host \u2014 drop it from the definition, or install it where the host is built (createProjectHost, src/cli/projectRuntime.ts).` : ` ${name}: a ${environmentOwning(name)} capability. The CLI runs the ${host.environment} host, so no CLI run can supply it.`);
13203
14155
  }
13204
- function environmentOwning(capability) {
13205
- const owners = Object.entries(CAPABILITIES_BY_ENVIRONMENT).filter(([, names]) => names.includes(capability)).map(([environment]) => environment);
14156
+ function environmentOwning(capability2) {
14157
+ const owners = Object.entries(CAPABILITIES_BY_ENVIRONMENT).filter(([, names]) => names.includes(capability2)).map(([environment]) => environment);
13206
14158
  return owners.length ? owners.join("/") : "unknown";
13207
14159
  }
13208
14160
  function reportUnresolved(prepared) {
@@ -13242,21 +14194,34 @@ async function runDeterministicProjectGraph(file, document2, entryNode) {
13242
14194
  if (deterministicCount !== nodes.length) {
13243
14195
  throw new Error("Mixed deterministic and dynamic graphs are not executable until the bounded legacy adapter is implemented");
13244
14196
  }
14197
+ const host = createProjectHost(file, prepared.registrations);
13245
14198
  const runtime = createRuntime({
13246
- host: createProjectHost(file, prepared.registrations),
14199
+ host,
13247
14200
  nodes: prepared.registrations
13248
14201
  });
13249
- const graph = await runtime.load(document2);
13250
- const result = await graph.run(entryNode ? { target: { kind: "node", nodeId: entryNode } } : {});
13251
- await graph.dispose();
13252
- await runtime.dispose();
13253
- if (result.status !== "completed") {
13254
- throw new Error(result.diagnostics.map((item) => item.message).join("\n") || `Graph ${result.status}`);
14202
+ let graph;
14203
+ try {
14204
+ graph = await runtime.load(document2);
14205
+ reportPreflightWarnings(
14206
+ host,
14207
+ classifyPreflight(graph.preflight()).warnings.filter((item) => item.code === "runtime/stray-params" || item.code === "runtime/unknown-props")
14208
+ );
14209
+ const result = await graph.run(entryNode ? { target: { kind: "node", nodeId: entryNode } } : {});
14210
+ if (result.status !== "completed") {
14211
+ throw new Error(result.diagnostics.map((item) => item.message).join("\n") || `Graph ${result.status}`);
14212
+ }
14213
+ return true;
14214
+ } finally {
14215
+ try {
14216
+ await runtime.dispose();
14217
+ } finally {
14218
+ await disposeProjectGpu(host);
14219
+ }
13255
14220
  }
13256
- return true;
13257
14221
  }
13258
14222
  async function renderDeterministicProjectFrames(file, document2, options) {
13259
14223
  const prepared = await prepare(file, document2, true);
14224
+ reportUnresolved(prepared);
13260
14225
  const nodes = Array.isArray(document2?.nodes) ? document2.nodes : [];
13261
14226
  const deterministicCount = nodes.filter((node) => prepared.deterministic.has(moduleId(node))).length;
13262
14227
  if (!nodes.length || deterministicCount === 0) return null;
@@ -13264,18 +14229,23 @@ async function renderDeterministicProjectFrames(file, document2, options) {
13264
14229
  throw new Error("Mixed deterministic and dynamic graphs are not executable until the bounded legacy adapter is implemented");
13265
14230
  }
13266
14231
  const project = new ProjectRoot(path9.dirname(path9.resolve(file)));
14232
+ const host = createProjectHost(file, prepared.registrations);
13267
14233
  const runtime = createRuntime({
13268
- host: createProjectHost(file, prepared.registrations),
14234
+ host,
13269
14235
  nodes: prepared.registrations
13270
14236
  });
13271
- const graph = await runtime.load(document2);
13272
- const outDirectory = project.resolve(options.out);
13273
- await fs7.mkdir(outDirectory, { recursive: true });
14237
+ let graph;
13274
14238
  const width = sequenceWidth(options.end);
13275
14239
  const frames = [];
13276
14240
  const files = [];
13277
14241
  let aborted = false;
14242
+ let gpu;
14243
+ const started = performance.now();
13278
14244
  try {
14245
+ graph = await runtime.load(document2);
14246
+ reportPreflightWarnings(host, classifyPreflight(graph.preflight()).warnings);
14247
+ const outDirectory = project.resolve(options.out);
14248
+ await fs7.mkdir(outDirectory, { recursive: true });
13279
14249
  for (const frame of frameRange(options.start, options.end, options.step ?? 1)) {
13280
14250
  if (options.signal?.aborted) {
13281
14251
  aborted = true;
@@ -13307,11 +14277,21 @@ async function renderDeterministicProjectFrames(file, document2, options) {
13307
14277
  frames.push(frame);
13308
14278
  if (options.verbose) console.log(` frame ${frame}`);
13309
14279
  }
14280
+ try {
14281
+ gpu = { renderer: "dawn", adapter: host.capabilities.gpu.adapterInfo };
14282
+ } catch {
14283
+ }
13310
14284
  } finally {
13311
- await graph.dispose();
13312
- await runtime.dispose();
14285
+ try {
14286
+ await runtime.dispose();
14287
+ } finally {
14288
+ await disposeProjectGpu(host);
14289
+ }
13313
14290
  }
13314
- return { frames, files, aborted };
14291
+ return { frames, files, aborted, fps: graph.getFps(), durationMs: performance.now() - started, ...gpu ? { gpu } : {} };
14292
+ }
14293
+ async function disposeProjectGpu(host) {
14294
+ await host.capabilities.gpu?.dispose();
13315
14295
  }
13316
14296
  function imageOutputs(graph, entryNode) {
13317
14297
  const found = [];
@@ -13328,6 +14308,7 @@ function imageOutputs(graph, entryNode) {
13328
14308
  }
13329
14309
  async function inspectProjectGraph(file, document2) {
13330
14310
  const prepared = await prepare(file, document2, false);
14311
+ reportUnresolved(prepared);
13331
14312
  const registrations = new Map(
13332
14313
  [...builtinNodeRegistrations, ...prepared.registrations].map((item) => [item.moduleId, item])
13333
14314
  );
@@ -13376,6 +14357,8 @@ async function prepare(file, document2, compileExecutors) {
13376
14357
  const mentionsDefinition = /\bexport\s+const\s+definition\b/.test(source);
13377
14358
  const extracted = extractNodeDefinition(source, moduleFile, ts2);
13378
14359
  if (!extracted.ok) {
14360
+ const reExport = reExportedDefinition(source);
14361
+ if (reExport) throw new Error(formatDiagnostic(reExport(moduleFile)));
13379
14362
  if (!mentionsDefinition && extracted.diagnostics.every((item) => item.code === "definition/missing-export")) {
13380
14363
  registrations.push(savedRegistration(id, node));
13381
14364
  continue;
@@ -13415,6 +14398,18 @@ async function projectModuleFile(root, node, id) {
13415
14398
  tried.push(candidate);
13416
14399
  return { file: await exists(candidate) ? candidate : null, tried };
13417
14400
  }
14401
+ function reExportedDefinition(source) {
14402
+ const named = /\bexport\s*\{[^}]*\bdefinition\b[^}]*\}\s*from\s*['"]/.test(source);
14403
+ const star = /\bexport\s*\*\s*from\s*['"]/.test(source);
14404
+ if (!named && !star) return null;
14405
+ const what = named ? "this module re-exports `definition` from another file" : "this module has a star re-export and no `definition` of its own";
14406
+ return (file) => ({
14407
+ phase: "prepare",
14408
+ code: "definition/re-exported",
14409
+ file,
14410
+ message: `${what}, and a definition cannot arrive that way \u2014 it is read statically from this file. Write the literal here, or keep the stub for \`execute\` only and leave this module dynamic. Without this the node loads as dynamic and every command reports success.`
14411
+ });
14412
+ }
13418
14413
  function unresolvedModule(id, node, tried, root) {
13419
14414
  const relative = tried.map((candidate) => path9.relative(root, candidate) || candidate);
13420
14415
  const where = relative.length ? `looked for ${relative.join(" and ")}` : `no project file is possible for this module id \u2014 a project module is named "project.<dir>" or carries a source file`;
@@ -13422,7 +14417,7 @@ function unresolvedModule(id, node, tried, root) {
13422
14417
  phase: "prepare",
13423
14418
  code: "project/module-not-found",
13424
14419
  path: node.id,
13425
- message: `${id} has no module file \u2014 ${where}. Loading the ports the document saved instead, which cannot run: a typo and an unconverted legacy node look identical from here`
14420
+ message: `${id} has no module file \u2014 ${where}. Only known legacy modules can use saved-port compatibility, for inspection only; unknown and explicit project modules are rejected`
13426
14421
  };
13427
14422
  }
13428
14423
  async function compileExecute(projectRoot, moduleFile) {
@@ -17099,28 +18094,50 @@ async function runGraph(options) {
17099
18094
  if (verbose) console.warn(canvasError.message);
17100
18095
  }
17101
18096
  const frameSpec = frames ? parseFrameSpec(frames) : null;
18097
+ let hostReleased = false;
17102
18098
  const releaseHost = () => {
17103
- disposeStageBridge?.();
17104
- disposeIoBridge?.();
17105
- canvasHost?.dispose();
18099
+ if (hostReleased) return;
18100
+ hostReleased = true;
18101
+ const errors = [];
18102
+ for (const dispose of [
18103
+ disposeStageBridge,
18104
+ disposeIoBridge,
18105
+ canvasHost ? () => canvasHost.dispose() : void 0,
18106
+ () => setProjectModuleCompiler(null),
18107
+ () => setEmbeddedCompiler(null)
18108
+ ]) {
18109
+ try {
18110
+ dispose?.();
18111
+ } catch (error) {
18112
+ errors.push(error);
18113
+ }
18114
+ }
18115
+ if (errors.length) throw errors[0];
17106
18116
  };
17107
- if (frameSpec) {
17108
- const rendered = await renderDeterministicProjectFrames(file, graphData, {
17109
- ...frameSpec,
17110
- ...fps === void 0 ? {} : { fps },
17111
- out: out ?? "renders",
17112
- ...entryNode ? { entryNode } : {},
17113
- ...verbose ? { verbose } : {}
17114
- });
17115
- if (rendered) {
18117
+ try {
18118
+ if (frameSpec) {
18119
+ const rendered = await renderDeterministicProjectFrames(file, graphData, {
18120
+ ...frameSpec,
18121
+ ...fps === void 0 ? {} : { fps },
18122
+ out: out ?? "renders",
18123
+ ...entryNode ? { entryNode } : {},
18124
+ ...verbose ? { verbose } : {}
18125
+ });
18126
+ if (rendered) {
18127
+ releaseHost();
18128
+ if (options.json) process.stdout.write(JSON.stringify({ status: "completed", graph: path13.resolve(file), ...rendered }) + "\n");
18129
+ else console.log(`Rendered ${rendered.frames.length} frames, ${rendered.files.length} files -> ${out ?? "renders"}/`);
18130
+ return;
18131
+ }
18132
+ if (options.json) throw new Error("--json rendering requires a fully definition-v1 graph");
18133
+ } else if (await runDeterministicProjectGraph(file, graphData, entryNode)) {
18134
+ if (verbose) console.log("Graph execution completed");
17116
18135
  releaseHost();
17117
- console.log(`Rendered ${rendered.frames.length} frames, ${rendered.files.length} files -> ${out ?? "renders"}/`);
17118
18136
  return;
17119
18137
  }
17120
- } else if (await runDeterministicProjectGraph(file, graphData, entryNode)) {
17121
- if (verbose) console.log("Graph execution completed");
18138
+ } catch (error) {
17122
18139
  releaseHost();
17123
- return;
18140
+ throw error;
17124
18141
  }
17125
18142
  let activeGraph;
17126
18143
  try {
@@ -17230,14 +18247,60 @@ async function runGraph(options) {
17230
18247
  } finally {
17231
18248
  activeGraph?.scheduler?.dispose?.();
17232
18249
  releaseHost();
17233
- setProjectModuleCompiler(null);
17234
- setEmbeddedCompiler(null);
17235
18250
  }
17236
18251
  }
17237
18252
 
17238
18253
  // src/cli/index.ts
17239
18254
  import * as fs22 from "fs/promises";
17240
18255
  import { readFileSync } from "node:fs";
18256
+
18257
+ // src/cli/supervise.ts
18258
+ import { spawn as spawn2 } from "node:child_process";
18259
+ function superviseProcess(command, args, timeoutMs) {
18260
+ return new Promise((resolve2, reject) => {
18261
+ const child = spawn2(command, args, { stdio: "inherit", detached: process.platform !== "win32" });
18262
+ let timedOut = false;
18263
+ let cancelled = false;
18264
+ let grace;
18265
+ const kill = (signal) => {
18266
+ if (!child.pid) return;
18267
+ try {
18268
+ if (process.platform === "win32") child.kill(signal);
18269
+ else process.kill(-child.pid, signal);
18270
+ } catch (error) {
18271
+ if (error.code !== "ESRCH") throw error;
18272
+ }
18273
+ };
18274
+ const cancel = () => {
18275
+ cancelled = true;
18276
+ kill("SIGTERM");
18277
+ grace ??= setTimeout(() => kill("SIGKILL"), 1e3);
18278
+ };
18279
+ process.on("SIGINT", cancel);
18280
+ process.on("SIGTERM", cancel);
18281
+ const timeout = setTimeout(() => {
18282
+ timedOut = true;
18283
+ console.error(`Render timed out after ${timeoutMs} ms`);
18284
+ kill("SIGKILL");
18285
+ }, timeoutMs);
18286
+ const cleanup = () => {
18287
+ clearTimeout(timeout);
18288
+ clearTimeout(grace);
18289
+ process.off("SIGINT", cancel);
18290
+ process.off("SIGTERM", cancel);
18291
+ };
18292
+ child.once("error", (error) => {
18293
+ cleanup();
18294
+ reject(error);
18295
+ });
18296
+ child.once("close", (code) => {
18297
+ cleanup();
18298
+ resolve2(timedOut ? 124 : cancelled ? 130 : code ?? 1);
18299
+ });
18300
+ });
18301
+ }
18302
+
18303
+ // src/cli/index.ts
17241
18304
  var VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
17242
18305
  async function main() {
17243
18306
  const args = process.argv.slice(2);
@@ -17282,6 +18345,8 @@ Options:
17282
18345
  --frames <range> Render a frame sequence: 1-100, 1-100x2 (step), or 42
17283
18346
  --fps <number> Frame rate to evaluate the range at
17284
18347
  --out <dir> Sequence directory, project-relative (default: renders)
18348
+ --json Return a JSON render manifest (requires run --frames)
18349
+ --timeout <ms> Bound a run in a supervised child process (exit 124 on timeout)
17285
18350
  --validate-only Validate only (same as 'validate' command)
17286
18351
  --verbose, -v Show verbose output
17287
18352
  --version Show version
@@ -17359,7 +18424,24 @@ image output nothing downstream consumes, or per output of --entry-node.
17359
18424
  }
17360
18425
  const out = valueOf("--out");
17361
18426
  if (out) options.out = out;
18427
+ if (args.includes("--json")) {
18428
+ if (command !== "run" || !options.frames) throw new Error("--json requires run --frames (use --frames 1 for a still)");
18429
+ options.json = true;
18430
+ }
18431
+ const timeout = valueOf("--timeout");
18432
+ if (timeout !== void 0) {
18433
+ const ms = Number(timeout);
18434
+ if (command !== "run" || !Number.isSafeInteger(ms) || ms <= 0 || ms > 2147483647) {
18435
+ throw new Error("--timeout requires run and a positive integer number of milliseconds up to 2147483647");
18436
+ }
18437
+ const index = args.indexOf("--timeout");
18438
+ const childArgs = [...args.slice(0, index), ...args.slice(index + 2)];
18439
+ if (childArgs.includes("--timeout")) throw new Error("--timeout may only be specified once");
18440
+ process.exitCode = await superviseProcess(process.execPath, [...process.execArgv, process.argv[1], ...childArgs], ms);
18441
+ return;
18442
+ }
17362
18443
  try {
18444
+ if (options.json) console.log = console.error;
17363
18445
  await runGraph(options);
17364
18446
  } catch (error) {
17365
18447
  console.error(`Error: ${error.message}`);