@field/cascade 0.3.1 → 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 (74) hide show
  1. package/AGENTS.md +14 -9
  2. package/ARCHITECTURE.md +22 -6
  3. package/CHANGELOG.md +51 -1
  4. package/CONTRIBUTING.md +71 -0
  5. package/DESIGN.md +19 -13
  6. package/README.md +42 -11
  7. package/dist/assets/{CodeEditor-D22fVi-4.js → CodeEditor-BNOJIDzL.js} +4 -4
  8. package/dist/assets/{CodePanel-DPNEYfcR.js → CodePanel-2wp2G0Fj.js} +2 -2
  9. package/dist/assets/{CustomNodeDialog-DeHCAvgb.js → CustomNodeDialog-DNqaVBqA.js} +1 -1
  10. package/dist/assets/{ProjectSettings-DWpVtWEx.js → ProjectSettings-DcdYF2E1.js} +1 -1
  11. package/dist/assets/{SettingsDialog-za00IS3L.js → SettingsDialog-D2xVvyiR.js} +1 -1
  12. package/dist/assets/{VersionHistoryDialog-DTMGhI0_.js → VersionHistoryDialog-DW288ykF.js} +1 -1
  13. package/dist/assets/{core-JsyKMO1k.js → core-B1y38i95.js} +1 -1
  14. package/dist/assets/{cssMode-emdRheFK.js → cssMode-BLucnaln.js} +1 -1
  15. package/dist/assets/{editor.api-xHX-fMSo.js → editor.api-B3Et3Pph.js} +1 -1
  16. package/dist/assets/{freemarker2-C3M8_Gs3.js → freemarker2-DVlPoano.js} +1 -1
  17. package/dist/assets/geo-DhyFdBXX.js +1 -0
  18. package/dist/assets/{handlebars-CBMurtFk.js → handlebars-B5FZBAMR.js} +1 -1
  19. package/dist/assets/{html-xbLQwknw.js → html-FodD8KbR.js} +1 -1
  20. package/dist/assets/{htmlMode-2wKdDTon.js → htmlMode-CsG57VCD.js} +1 -1
  21. package/dist/assets/{image-i1j6nWKe.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-DJaOo_2I.js → javascript-B3gonF_H.js} +1 -1
  25. package/dist/assets/{jsonMode-Bheas8_0.js → jsonMode-Y3w1ahZg.js} +1 -1
  26. package/dist/assets/{liquid-BCj_BGbM.js → liquid-CTpWXIYO.js} +1 -1
  27. package/dist/assets/{lspLanguageFeatures-BfIsx_xz.js → lspLanguageFeatures-CODBdKEZ.js} +1 -1
  28. package/dist/assets/{mdx-DwiQDiVO.js → mdx-CDAQJZIz.js} +1 -1
  29. package/dist/assets/{python-BjrB5nqj.js → python-LOIlxJU-.js} +1 -1
  30. package/dist/assets/{razor-CfoBmGDs.js → razor-CllPQSHo.js} +1 -1
  31. package/dist/assets/{tsMode-BcEoMSxp.js → tsMode-CO28ut8t.js} +1 -1
  32. package/dist/assets/{typescript-DIg7TEmQ.js → typescript-ruU9wKQ_.js} +1 -1
  33. package/dist/assets/{workers-vXYuwtPw.js → workers-Bc1Ywp4-.js} +1 -1
  34. package/dist/assets/{xml-DexLQIQy.js → xml-DoXP2Dh1.js} +1 -1
  35. package/dist/assets/{yaml-DUnD_1dE.js → yaml-D9YPyGiJ.js} +1 -1
  36. package/dist/cli/index.js +999 -185
  37. package/dist/cli/runtime/gpu.ts +71 -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 +6 -6
  41. package/dist/contracts/definition.d.ts +41 -2
  42. package/dist/contracts/index.d.ts +2 -0
  43. package/dist/contracts/index.js +2 -0
  44. package/dist/contracts/schema.js +25 -2
  45. package/dist/contracts/series.d.ts +75 -0
  46. package/dist/contracts/series.js +2 -0
  47. package/dist/contracts/validate.js +19 -3
  48. package/dist/contracts/values.d.ts +3 -2
  49. package/dist/contracts/values.js +1 -0
  50. package/dist/gpu.d.ts +17 -0
  51. package/dist/gpu.js +61 -1
  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.js +63 -6
  65. package/dist/runtime/series/index.d.ts +41 -0
  66. package/dist/runtime/series/index.js +167 -0
  67. package/dist/runtime/types.d.ts +1 -1
  68. package/doc/HEADLESS_GPU.md +160 -0
  69. package/doc/NODE_REFERENCE.md +36 -0
  70. package/doc/PROJECT_AUTHORING.md +106 -20
  71. package/package.json +10 -2
  72. package/dist/assets/geo-BAR6lQp5.js +0 -1
  73. package/dist/assets/index-BDV5xGBZ.css +0 -1
  74. package/dist/assets/index-CMcH8AgP.js +0 -212
@@ -4,6 +4,7 @@ import { builtinNodeRegistrations } from "./builtins/index.js";
4
4
  import { deserializeChannel, isEmptyChannel } from "./animation/channel.js";
5
5
  import { PropAnimator, isBoundProp } from "./params/index.js";
6
6
  import { DEFAULT_FPS, MIN_FRAME } from "./expressions/time.js";
7
+ import { isGpuHost } from "./gpu.js";
7
8
  /**
8
9
  * Preflight codes that describe something other than "this graph cannot run
9
10
  * here", so `run` proceeds and a checker reports them as warnings.
@@ -48,6 +49,7 @@ import { DEFAULT_FPS, MIN_FRAME } from "./expressions/time.js";
48
49
  export const PREFLIGHT_WARNING_CODES = new Set([
49
50
  "runtime/preflight-environment",
50
51
  "runtime/stray-params",
52
+ "runtime/unknown-props",
51
53
  ]);
52
54
  /**
53
55
  * The only preflight code that does not stop a run.
@@ -60,6 +62,7 @@ export const PREFLIGHT_WARNING_CODES = new Set([
60
62
  */
61
63
  const RUN_TOLERATED_CODES = new Set([
62
64
  "runtime/stray-params",
65
+ "runtime/unknown-props",
63
66
  ]);
64
67
  class MutableAbortSignal {
65
68
  report;
@@ -683,12 +686,23 @@ class Graph {
683
686
  set: (value) => pendingData.set(name, runtimeValue(value, definition.type, false, `${node.id}.${name}`)),
684
687
  },
685
688
  ]));
686
- const capabilities = Object.freeze(Object.fromEntries((node.definition.capabilities ?? []).map((name) => [
687
- name,
688
- name === "shell"
689
- ? bindShellToRun(this.host.capabilities.shell, signal)
690
- : this.host.capabilities[name],
691
- ])));
689
+ const capabilityEntries = [];
690
+ for (const name of node.definition.capabilities ?? []) {
691
+ const installed = this.host.capabilities[name];
692
+ if (name === "gpu" && isGpuHost(installed)) {
693
+ await installed.ensure();
694
+ capabilityEntries.push([name, installed.forNode(node.id)]);
695
+ }
696
+ else {
697
+ capabilityEntries.push([
698
+ name,
699
+ name === "shell"
700
+ ? bindShellToRun(this.host.capabilities.shell, signal)
701
+ : installed,
702
+ ]);
703
+ }
704
+ }
705
+ const capabilities = Object.freeze(Object.fromEntries(capabilityEntries));
692
706
  const props = Object.freeze({ ...node.props });
693
707
  const progress = {
694
708
  report: (event) => this.emit({ type: "node:progress", runId, nodeId: node.id, ...event }),
@@ -751,6 +765,8 @@ class Graph {
751
765
  const diagnostics = [];
752
766
  const push = (code, nodeId, message) => diagnostics.push({ phase: "preflight", code, path: nodeId, message });
753
767
  for (const node of nodes) {
768
+ if (node.unknownProps.length)
769
+ push("runtime/unknown-props", node.id, `${node.moduleId} stores ${node.unknownProps.join(", ")} under "props", and its definition declares no such prop — ${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`);
754
770
  if (node.strayParams.length)
755
771
  push("runtime/stray-params", node.id, `${node.moduleId} stores ${node.strayParams.join(", ")} under "params", which this runtime does not read — move ${node.strayParams.length === 1 ? "it" : "them"} to "props" or "inputs", whichever the definition declares, or the ${node.strayParams.length === 1 ? "value is" : "values are"} silently the default`);
756
772
  const remote = this.isRemote(node);
@@ -1022,6 +1038,25 @@ function materialize(authored, registration) {
1022
1038
  }
1023
1039
  for (const [name, definition] of Object.entries(registration.definition.props ?? {}))
1024
1040
  props[name] = runtimeValue(definition.default, definition.type, false, `${authored.id}.${name}`);
1041
+ /**
1042
+ * `props` is a map and `params` was a list, and a migration that changes the
1043
+ * key without changing the shape leaves an array here.
1044
+ *
1045
+ * Fatal, and the reason is what it costs: `Object.entries` of an array gives
1046
+ * keys `"0"`, `"1"`, which match no declared prop, so **every authored value
1047
+ * is dropped and every signal stays green.** Measured on 2026-09-08 —
1048
+ * preflight empty, the prop back at its default, `cascade run` reporting
1049
+ * `completed`. `cloud-plots` rendered a whole picture against no moment and
1050
+ * was reported as working on the strength of an exit code.
1051
+ *
1052
+ * An error rather than a warning, unlike a leftover under `params`: that is a
1053
+ * real state an unconverted sketch is legitimately in and must keep
1054
+ * validating, whereas `props` as an array is never valid — the schema
1055
+ * declares a record — and nothing passes through it on the way to being
1056
+ * right. Same reasoning as `definition/re-exported`.
1057
+ */
1058
+ if (Array.isArray(authored.props))
1059
+ misuse("runtime/props-not-a-map", `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`);
1025
1060
  const bindings = {};
1026
1061
  // A declared default expression, applied only where the document stored
1027
1062
  // nothing at all for the prop. The loop below then runs over what *was*
@@ -1079,6 +1114,7 @@ function materialize(authored, registration) {
1079
1114
  props,
1080
1115
  bindings,
1081
1116
  strayParams: strayParams(authored, registration),
1117
+ unknownProps: unknownProps(authored, registration),
1082
1118
  outputs: new Map(),
1083
1119
  };
1084
1120
  }
@@ -1103,6 +1139,27 @@ function materialize(authored, registration) {
1103
1139
  * naming nothing is a different fault, and reporting it would make this fire on
1104
1140
  * documents where no value was lost.
1105
1141
  */
1142
+ /**
1143
+ * Names stored under `props` that the definition does not declare.
1144
+ *
1145
+ * The mirror of `strayParams`, and the gap that one had: it asks *is anything
1146
+ * still under the old key*, which is blind to *did what arrived under the new
1147
+ * key arrive in the right form*. One is a leftover, the other is a botched
1148
+ * move, and the second is worse because it looks finished.
1149
+ *
1150
+ * A warning rather than an error, because unlike an array container this is a
1151
+ * state a real document reaches honestly: a prop renamed or removed from a
1152
+ * definition leaves its value behind, and the graph runs on the defaults. Same
1153
+ * class as a leftover under `params`, and the same severity for the same
1154
+ * reason — a sketch mid-conversion must keep validating.
1155
+ */
1156
+ function unknownProps(authored, registration) {
1157
+ const props = authored.props;
1158
+ if (!props || Array.isArray(props))
1159
+ return [];
1160
+ const declared = registration.definition.props ?? {};
1161
+ return Object.keys(props).filter((name) => !(name in declared));
1162
+ }
1106
1163
  function strayParams(authored, registration) {
1107
1164
  const definition = registration.definition;
1108
1165
  const declared = new Set([
@@ -0,0 +1,41 @@
1
+ import type { CascadeAbortSignal, CascadePreset, CascadeSeries, SeriesInstanceResult } from "@field/cascade/contracts";
2
+ import type { LoadedCascadeGraph } from "../types.js";
3
+ /**
4
+ * The graph's current values for those keys, as a preset.
5
+ *
6
+ * Read through `inspect()` rather than from the nodes directly, so the values
7
+ * are the same snapshots every other reader gets. A key the graph does not
8
+ * have is left out rather than defaulted: `applyPreset` is the thing that
9
+ * decides an unknown key is a misuse, and it should keep deciding that.
10
+ */
11
+ export declare function seriesBaseline(graph: LoadedCascadeGraph, series: CascadeSeries): CascadePreset;
12
+ export interface RunSeriesOptions {
13
+ readonly signal?: CascadeAbortSignal;
14
+ readonly frame?: number;
15
+ readonly fps?: number;
16
+ /**
17
+ * The instance to cook first, by id.
18
+ *
19
+ * *"Making changes to the underlying network re-renders the current selected
20
+ * item first then the entire series."* The order of the results is always the
21
+ * series' own order whatever this says — only the order of the **work**
22
+ * changes, because the caller is watching one instance and waiting for a
23
+ * list would defeat the point.
24
+ */
25
+ readonly first?: string;
26
+ /** Called as each instance finishes, in cook order rather than list order,
27
+ * so a gallery can fill in as results arrive instead of after all of them. */
28
+ readonly onInstance?: (result: SeriesInstanceResult) => void;
29
+ }
30
+ export interface SeriesRunResult {
31
+ /** In the series' own order, whatever order the work ran in. */
32
+ readonly instances: readonly SeriesInstanceResult[];
33
+ /** By record id, which is the address everything downstream uses. */
34
+ readonly byId: ReadonlyMap<string, SeriesInstanceResult>;
35
+ readonly status: "completed" | "cancelled" | "failed";
36
+ }
37
+ /** Ids must be unique, or an instance has no address at all — and every
38
+ * judgement, override and cache key in the plan hangs off that address. */
39
+ export declare function validateSeries(series: CascadeSeries): void;
40
+ export declare function runSeries(graph: LoadedCascadeGraph, series: CascadeSeries, options?: RunSeriesOptions): Promise<SeriesRunResult>;
41
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Cooking a graph once per parameter set.
3
+ *
4
+ * Step 1 of `PLAN series.md`, and headless on purpose: it is the foundation the
5
+ * gallery and the scheduler stand on, and it is verifiable without a picture.
6
+ *
7
+ * **The whole difficulty is that a parameter set is sparse.** `applyPreset`
8
+ * mutates the runtime, and instance 3 overriding `seed` while instance 4 does
9
+ * not means instance 4 inherits instance 3's seed unless something puts it
10
+ * back. That is accidental feedback — in the one mechanism specified as having
11
+ * none — and it would show up as a series where the pictures are subtly wrong
12
+ * in an order-dependent way, which is close to the worst failure this system
13
+ * could have: re-run it and the same instance renders differently, so it reads
14
+ * as a flaky renderer rather than as a bug.
15
+ *
16
+ * So every instance is cooked from the same baseline. The baseline is itself a
17
+ * `CascadePreset`, holding the graph's current value for **every key any
18
+ * instance touches** — the union, computed once — which means restoring is the
19
+ * same validated code path as applying, rather than a second one that can
20
+ * disagree with it.
21
+ */
22
+ /** Every `node.input` and `node.prop` any instance in the series overrides. */
23
+ function overriddenKeys(series) {
24
+ const keys = new Map();
25
+ for (const instance of series.instances) {
26
+ for (const [nodeId, values] of Object.entries(instance.parameters.nodes)) {
27
+ let entry = keys.get(nodeId);
28
+ if (!entry) {
29
+ entry = { inputs: new Set(), props: new Set() };
30
+ keys.set(nodeId, entry);
31
+ }
32
+ for (const name of Object.keys(values.inputs ?? {}))
33
+ entry.inputs.add(name);
34
+ for (const name of Object.keys(values.props ?? {}))
35
+ entry.props.add(name);
36
+ }
37
+ }
38
+ return keys;
39
+ }
40
+ /**
41
+ * The graph's current values for those keys, as a preset.
42
+ *
43
+ * Read through `inspect()` rather than from the nodes directly, so the values
44
+ * are the same snapshots every other reader gets. A key the graph does not
45
+ * have is left out rather than defaulted: `applyPreset` is the thing that
46
+ * decides an unknown key is a misuse, and it should keep deciding that.
47
+ */
48
+ export function seriesBaseline(graph, series) {
49
+ const wanted = overriddenKeys(series);
50
+ const inspection = graph.inspect();
51
+ const nodes = {};
52
+ for (const node of inspection.nodes) {
53
+ const entry = wanted.get(node.id);
54
+ if (!entry)
55
+ continue;
56
+ const inputs = {};
57
+ const props = {};
58
+ for (const name of entry.inputs) {
59
+ if (node.inputs[name] !== undefined)
60
+ inputs[name] = node.inputs[name].value;
61
+ }
62
+ for (const name of entry.props) {
63
+ if (node.props[name] !== undefined)
64
+ props[name] = node.props[name].value;
65
+ }
66
+ if (Object.keys(inputs).length > 0 || Object.keys(props).length > 0) {
67
+ nodes[node.id] = {
68
+ ...(Object.keys(inputs).length > 0 ? { inputs } : {}),
69
+ ...(Object.keys(props).length > 0 ? { props } : {}),
70
+ };
71
+ }
72
+ }
73
+ return { version: 1, nodes };
74
+ }
75
+ /** Ids must be unique, or an instance has no address at all — and every
76
+ * judgement, override and cache key in the plan hangs off that address. */
77
+ export function validateSeries(series) {
78
+ if (series.version !== 1) {
79
+ throw new Error("Series version must be 1");
80
+ }
81
+ const seen = new Set();
82
+ for (const instance of series.instances) {
83
+ if (!instance.id)
84
+ throw new Error("Every series instance needs an id");
85
+ if (seen.has(instance.id)) {
86
+ throw new Error(`Duplicate series instance id ${instance.id}`);
87
+ }
88
+ seen.add(instance.id);
89
+ }
90
+ }
91
+ function collect(graph, collection) {
92
+ return "graphOutput" in collection
93
+ ? graph.getGraphOutput(collection.graphOutput)
94
+ : graph.getOutput(collection.nodeId, collection.output);
95
+ }
96
+ /** The cook order: the selected instance, then the rest in the series' order. */
97
+ function cookOrder(series, first) {
98
+ const order = series.instances.map((_, index) => index);
99
+ if (first === undefined)
100
+ return order;
101
+ const selected = series.instances.findIndex(instance => instance.id === first);
102
+ if (selected < 0)
103
+ return order;
104
+ return [selected, ...order.filter(index => index !== selected)];
105
+ }
106
+ export async function runSeries(graph, series, options = {}) {
107
+ validateSeries(series);
108
+ // Before the first `applyPreset` and not after, or the baseline is already
109
+ // instance one's.
110
+ const baseline = seriesBaseline(graph, series);
111
+ const results = new Array(series.instances.length);
112
+ let cancelled = false;
113
+ try {
114
+ for (const index of cookOrder(series, options.first)) {
115
+ if (options.signal?.aborted) {
116
+ cancelled = true;
117
+ break;
118
+ }
119
+ const instance = series.instances[index];
120
+ // Baseline first, every time. A sparse set does not clear what the
121
+ // previous instance changed, and inheriting it is accidental feedback.
122
+ await graph.applyPreset(baseline);
123
+ await graph.applyPreset(instance.parameters);
124
+ const run = await graph.run({
125
+ ...(options.signal ? { signal: options.signal } : {}),
126
+ ...(options.frame === undefined ? {} : { frame: options.frame }),
127
+ ...(options.fps === undefined ? {} : { fps: options.fps }),
128
+ });
129
+ const result = {
130
+ id: instance.id,
131
+ index,
132
+ status: run.status,
133
+ // A run that did not complete has no value worth collecting, and
134
+ // reading the port anyway would hand back the previous instance's
135
+ // picture under this instance's id.
136
+ value: run.status === "completed" ? collect(graph, series.collect) : undefined,
137
+ diagnostics: run.diagnostics,
138
+ durationMs: run.timings.durationMs,
139
+ };
140
+ results[index] = result;
141
+ options.onInstance?.(result);
142
+ if (run.status === "cancelled") {
143
+ cancelled = true;
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ finally {
149
+ // Leave the graph as it was found. A series is a way of looking at a graph,
150
+ // not an edit to it, and a Studio session that ran one and was left on the
151
+ // last instance's parameters would be indistinguishable from one the author
152
+ // had changed by hand.
153
+ await graph.applyPreset(baseline);
154
+ }
155
+ const completed = results.filter((result) => Boolean(result));
156
+ const status = cancelled
157
+ ? "cancelled"
158
+ : completed.some(result => result.status === "failed")
159
+ ? "failed"
160
+ : "completed";
161
+ return {
162
+ instances: completed,
163
+ byId: new Map(completed.map(result => [result.id, result])),
164
+ status,
165
+ };
166
+ }
167
+ //# sourceMappingURL=index.js.map
@@ -246,7 +246,7 @@ export interface LoadedCascadeGraph {
246
246
  getOutputs(nodeId: string): ReadonlyMap<string, unknown>;
247
247
  dispose(): Promise<void>;
248
248
  }
249
- export interface NodeRuntimeHostOptions extends Omit<RuntimeCapabilities, "gpu"> {
249
+ export interface NodeRuntimeHostOptions extends RuntimeCapabilities {
250
250
  readonly modules: RuntimeHost["modules"];
251
251
  readonly legacyShell?: LegacyShellBindingFactory & Readonly<{
252
252
  kind: "server-direct";
@@ -0,0 +1,160 @@
1
+ # Headless WebGPU with Dawn
2
+
3
+ The CLI can run definition-v1 nodes declaring `runsOn: 'portable'` or
4
+ `'server'` and `capabilities: ['gpu']` through optional `webgpu@0.6.0` (Dawn).
5
+ Studio uses browser WebGPU through the same device/cache lifecycle mechanism.
6
+ Browser-only declarations remain browser-only; installing Dawn does not supply
7
+ DOM, WebGL, canvas presentation, image decoding, or video APIs.
8
+
9
+ ## Decision and release status
10
+
11
+ Implemented on 2026-09-08 and checked in as source version `0.3.2`; see the
12
+ [changelog](../CHANGELOG.md). Checking in the source does not publish the npm
13
+ package. Use a built checkout or a tarball known to include the feature until
14
+ the corresponding package release is published.
15
+
16
+ The decision is to use optional native Dawn for browser-independent GPU
17
+ execution and agent image feedback. Browser automation remains useful for
18
+ Studio interaction/presentation tests; Skia remains the Canvas2D renderer.
19
+ Neither is replaced by Dawn. This first slice shares WGSL/rendering logic and
20
+ device/cache ownership without introducing a new graph texture pipeline or
21
+ requiring a browser process for production CLI rendering.
22
+
23
+ ## Render for a test or agent
24
+
25
+ From a compatible project, using a Cascade build containing this feature:
26
+
27
+ ```sh
28
+ npx --no-install cascade run index.cascade --frames 1 --json --timeout 60000
29
+ npx --no-install cascade run index.cascade --frames 1-24 --fps 24 --out renders --json --timeout 120000
30
+ ```
31
+
32
+ `--json` requires a fully definition-v1 frame render; use `--frames 1` for a
33
+ still. Stdout contains one success manifest with absolute graph path,
34
+ project-relative image `files`, `frames`, actual `fps`, elapsed `durationMs`,
35
+ and Dawn adapter metadata when a GPU was used. Logs and failure diagnostics go
36
+ to stderr; failures exit nonzero and do not emit a success manifest. This is a
37
+ local command contract, not a chat-provider integration or an untrusted-code sandbox.
38
+
39
+ Illustrative manifest for the `cloud-volumes/dawn.cascade` fixture:
40
+
41
+ ```json
42
+ {
43
+ "status": "completed",
44
+ "graph": "/path/to/cloud-volumes/dawn.cascade",
45
+ "frames": [1, 2],
46
+ "files": ["renders/dawn/volume.0001.png", "renders/dawn/volume.0002.png"],
47
+ "aborted": false,
48
+ "fps": 30,
49
+ "durationMs": 115.1,
50
+ "gpu": {
51
+ "renderer": "dawn",
52
+ "adapter": {
53
+ "vendor": "apple",
54
+ "architecture": "metal-3",
55
+ "device": "apple-m3-max",
56
+ "description": "Metal driver on macOS"
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ Resolve `files` relative to the directory containing `graph`. `gpu` is omitted
63
+ if no GPU node executed. `durationMs` measures the frame-rendering section and
64
+ cleanup, excluding CLI startup and module preparation; it is not GPU-pass time.
65
+
66
+ For agent callers: set a finite timeout, check the exit code, parse stdout only
67
+ on success, then attach the listed images to the conversation. Keep stderr for
68
+ diagnosis. A manifest records outputs, not a complete reproducibility bundle:
69
+ save the graph, code/assets and authored settings separately. The CLI does not
70
+ upload images or contact a model provider.
71
+
72
+ `--timeout` supervises a separate render process and returns exit 124 when its
73
+ deadline expires. On POSIX, termination covers that process group; on Windows,
74
+ the current supervisor terminates the direct child only. Interrupts are forwarded
75
+ with a short grace period before forced termination. A forced stop can leave
76
+ partial output files; consumers must check the exit code and manifest. Ordinary
77
+ successful runs destroy cached resources and release the native GPU reference.
78
+
79
+ Dawn loads lazily, only when a GPU node executes. Static `check`/`validate`
80
+ inspect the declared host contract without initializing hardware; they do not
81
+ prove that a native binary, driver, or adapter is usable. Missing native support
82
+ produces an installation diagnostic at execution. Dawn itself may be omitted
83
+ for CPU-only environments; retain the compiler's own platform dependencies.
84
+ Skia remains the independent Canvas2D path.
85
+
86
+ ## Authoring contract
87
+
88
+ Use `context.capabilities.gpu.device` and `.cache(key, create, destroy)`.
89
+ Cache keys are scoped per node; destroy callbacks release textures and buffers.
90
+ The application owns the host's disposal, not an individual graph sharing it.
91
+ Use `createRenderPipelineAsync` to surface shader/pipeline validation failures.
92
+ `readTexture` captures validation failures within its own operation. Arbitrary
93
+ GPU commands issued before it need their own error scopes or async validation;
94
+ run-aware routing of all uncaptured device errors is not implemented yet.
95
+
96
+ Render into a single-sample `rgba8unorm` 2D texture with
97
+ `TextureUsage.RENDER_ATTACHMENT | TextureUsage.COPY_SRC`. Then:
98
+
99
+ ```ts
100
+ import { readTexture } from 'cascade/gpu';
101
+
102
+ const pixels = await readTexture(gpu.device, target, { signal: context.signal });
103
+ // pixels: { data: Uint8Array, width, height }, tightly packed top-left RGBA8
104
+ ```
105
+
106
+ Readback strips the WebGPU buffer row padding and copies bytes before unmapping.
107
+ It releases its staging buffer on success, mapping failure, or cancellation.
108
+ It does not encode PNGs or perform colour conversion. Encode through the existing
109
+ image/IO host and publish an `image` value. Graph-wide texture transport, HDR
110
+ readback, and browser video uploads are outside this first implementation.
111
+
112
+ ## Verification and limits
113
+
114
+ ```sh
115
+ CASCADE_TEST_DAWN=1 npx vitest run tests/dawn-native.test.ts
116
+ CASCADE_TEST_DAWN=1 npm run test:package
117
+ ```
118
+
119
+ These opt-in hardware gates fail if Dawn cannot initialize. The first checks
120
+ repeated creation, rendering, padded readback and natural process exit. The
121
+ second installs a packed tarball into an empty project, renders animated PNGs
122
+ through the CLI and checks decoded pixels and the JSON manifest.
123
+ It also removes Dawn from that temporary installation to verify that a CPU
124
+ graph still runs and a GPU graph fails with an actionable missing-renderer
125
+ diagnostic. No silent CPU or browser fallback is performed.
126
+
127
+ Recorded implementation verification (2026-09-08):
128
+
129
+ - Main test suite: 1,322 passed; the opt-in hardware test was skipped there and
130
+ passed separately with `CASCADE_TEST_DAWN=1`.
131
+ - Typechecking: zero errors, eight existing Svelte warnings.
132
+ - Contracts/runtime package checks and tests, server/Studio/CLI builds, and
133
+ packed-package imports/types and rendering checks passed.
134
+ - The real `cloud-volumes/dawn.cascade` fixture passed static checks and wrote
135
+ two distinct animated PNGs with an adapter-bearing JSON manifest.
136
+
137
+ These are recorded results, not a claim that every future checkout or platform
138
+ passes. Rerun the gates after changes; Windows/Linux hardware has not yet been
139
+ verified. Windows process-tree cancellation remains follow-up work.
140
+
141
+ Do not require identical pixels across GPUs/drivers. Pin reference environments,
142
+ record adapter information and use measured image tolerances. Agent callers
143
+ should set a timeout, cap dimensions/frame counts, and preserve graph/code,
144
+ assets, seed and frame settings alongside selected outputs.
145
+
146
+ Initial proof (2026-09-08): the `cloud-volumes` shader at 130 × 96, 32 steps,
147
+ with a fixed opaque 17 × 73 column and 144-byte uniform block produced zero
148
+ changed channels across 49,920 channels for both original browser canvas →
149
+ migrated browser texture and migrated browser → native Dawn. All outputs were
150
+ nonblank. Both hosts used Apple Metal (native: M3 Max). This establishes that
151
+ specific migration's parity, not universal GPU bit-exactness. The sketch's
152
+ `dawn.cascade` additionally exercises the real Aoos input, image IO and animation
153
+ through the CLI at 256 × 320.
154
+
155
+ Native packaging and GPU/driver support vary by platform. The package chooses
156
+ its default backend; Cascade does not enable unsafe Dawn toggles or silently
157
+ configure software drivers. Software Vulkan requires separate host setup.
158
+ See [Dawn Node bindings](https://github.com/dawn-gpu/node-webgpu),
159
+ [software backend setup](https://dawn.googlesource.com/dawn/+/refs/heads/main/src/dawn/node/README.md),
160
+ and [WGSL floating-point rules](https://gpuweb.github.io/gpuweb/wgsl/#floating-point-evaluation).
@@ -17,6 +17,42 @@ node and none is needed.
17
17
  These are definition-v1: their declarations are literal, so what follows is
18
18
  exact rather than a description.
19
19
 
20
+ ### `cascade.core.Camera`
21
+
22
+ Camera — A camera, following Houdini's /obj/cam.
23
+
24
+ Runs on `portable`.
25
+
26
+ **Inputs**
27
+
28
+ | name | type | default | range |
29
+ | --- | --- | --- | --- |
30
+ | `translate` | `vec3` | `[0,0,5]` | |
31
+ | `rotate` | `vec3` | `[0,0,0]` | |
32
+ | `focal` | `float` | `50` | 1…300 |
33
+ | `lookat` | `vec3` | `[0,0,0]` | |
34
+ | `up` | `vec3` | `[0,1,0]` | |
35
+ | `spin` | `float` | `0` | |
36
+
37
+ **Props**
38
+
39
+ | name | type | default | range |
40
+ | --- | --- | --- | --- |
41
+ | `lookAt` | `bool` | `false` | |
42
+ | `aperture` | `float` | `41.4214` | 1…200 |
43
+ | `resolution` | `vec2i` | `[1280,720]` | |
44
+ | `aspect` | `float` | `1` | 0.1…10 |
45
+ | `projection` | `string` | `"perspective"` | |
46
+ | `orthowidth` | `float` | `2` | |
47
+ | `near` | `float` | `0.001` | |
48
+ | `far` | `float` | `10000` | |
49
+
50
+ **Outputs**
51
+
52
+ | name | type | default | range |
53
+ | --- | --- | --- | --- |
54
+ | `camera` | `camera` | | |
55
+
20
56
  ### `cascade.core.Input`
21
57
 
22
58
  Input — Defines a public graph or subnet input