@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.
- package/AGENTS.md +14 -9
- package/ARCHITECTURE.md +22 -6
- package/CHANGELOG.md +69 -1
- package/CONTRIBUTING.md +71 -0
- package/DESIGN.md +19 -13
- package/README.md +42 -11
- package/dist/assets/{CodeEditor-BADFbTJS.js → CodeEditor-BNOJIDzL.js} +4 -4
- package/dist/assets/{CodePanel-faK6PRfz.js → CodePanel-2wp2G0Fj.js} +2 -2
- package/dist/assets/{CustomNodeDialog-DhlKuviy.js → CustomNodeDialog-DNqaVBqA.js} +1 -1
- package/dist/assets/{ProjectSettings-D6a6kjqR.js → ProjectSettings-DcdYF2E1.js} +1 -1
- package/dist/assets/{SettingsDialog-DJZdIvLC.js → SettingsDialog-D2xVvyiR.js} +1 -1
- package/dist/assets/{VersionHistoryDialog-C1X3HOhc.js → VersionHistoryDialog-DW288ykF.js} +1 -1
- package/dist/assets/{core-CiuiuWZi.js → core-B1y38i95.js} +1 -1
- package/dist/assets/{cssMode-D-NwZSMJ.js → cssMode-BLucnaln.js} +1 -1
- package/dist/assets/{editor.api-DT3WEvBd.js → editor.api-B3Et3Pph.js} +1 -1
- package/dist/assets/{freemarker2-eKNYQaiz.js → freemarker2-DVlPoano.js} +1 -1
- package/dist/assets/geo-DhyFdBXX.js +1 -0
- package/dist/assets/{handlebars-C2w1ggK2.js → handlebars-B5FZBAMR.js} +1 -1
- package/dist/assets/{html-DQb41jyo.js → html-FodD8KbR.js} +1 -1
- package/dist/assets/{htmlMode-IhNlh6Lv.js → htmlMode-CsG57VCD.js} +1 -1
- package/dist/assets/{image-DMCXBY-_.js → image-B3F-nq25.js} +1 -1
- package/dist/assets/index-CC6YQw2n.js +212 -0
- package/dist/assets/index-Y2ock1cw.css +1 -0
- package/dist/assets/{javascript-BIT6jMLC.js → javascript-B3gonF_H.js} +1 -1
- package/dist/assets/{jsonMode-CNfyB1ro.js → jsonMode-Y3w1ahZg.js} +1 -1
- package/dist/assets/{liquid-CmSmJmOG.js → liquid-CTpWXIYO.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-BqMm3uMP.js → lspLanguageFeatures-CODBdKEZ.js} +1 -1
- package/dist/assets/{mdx-6r6YgOFw.js → mdx-CDAQJZIz.js} +1 -1
- package/dist/assets/{python-x1H0TQuI.js → python-LOIlxJU-.js} +1 -1
- package/dist/assets/{razor-Dzl1vZFm.js → razor-CllPQSHo.js} +1 -1
- package/dist/assets/{tsMode-n6DMMe3a.js → tsMode-CO28ut8t.js} +1 -1
- package/dist/assets/{typescript-ziEz65xN.js → typescript-ruU9wKQ_.js} +1 -1
- package/dist/assets/{workers-D-qUdp-r.js → workers-Bc1Ywp4-.js} +1 -1
- package/dist/assets/{xml-DMZjt5lE.js → xml-DoXP2Dh1.js} +1 -1
- package/dist/assets/{yaml-DFe5qZSO.js → yaml-D9YPyGiJ.js} +1 -1
- package/dist/cli/index.js +1240 -158
- package/dist/cli/runtime/gpu.ts +147 -0
- package/dist/contracts/camera.d.ts +70 -0
- package/dist/contracts/camera.js +27 -0
- package/dist/contracts/capabilities.d.ts +69 -6
- package/dist/contracts/definition.d.ts +61 -2
- package/dist/contracts/index.d.ts +2 -0
- package/dist/contracts/index.js +2 -0
- package/dist/contracts/schema.js +33 -7
- package/dist/contracts/series.d.ts +75 -0
- package/dist/contracts/series.js +2 -0
- package/dist/contracts/validate.js +29 -4
- package/dist/contracts/values.d.ts +3 -2
- package/dist/contracts/values.js +1 -0
- package/dist/gpu.d.ts +88 -0
- package/dist/gpu.js +113 -0
- package/dist/index.html +2 -2
- package/dist/runtime/builtins/core/camera.d.ts +252 -0
- package/dist/runtime/builtins/core/camera.js +149 -0
- package/dist/runtime/builtins/core/index.d.ts +2 -1
- package/dist/runtime/builtins/core/index.js +3 -1
- package/dist/runtime/camera/index.d.ts +60 -0
- package/dist/runtime/camera/index.js +234 -0
- package/dist/runtime/definition/architecture.js +36 -1
- package/dist/runtime/gpu.d.ts +33 -0
- package/dist/runtime/gpu.js +218 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +3 -0
- package/dist/runtime/runtime.d.ts +23 -3
- package/dist/runtime/runtime.js +181 -30
- package/dist/runtime/series/index.d.ts +41 -0
- package/dist/runtime/series/index.js +167 -0
- package/dist/runtime/types.d.ts +3 -3
- package/doc/HEADLESS_GPU.md +160 -0
- package/doc/NODE_REFERENCE.md +36 -0
- package/doc/PROJECT_AUTHORING.md +106 -20
- package/package.json +14 -2
- package/dist/assets/geo-DUvVbLnx.js +0 -1
- package/dist/assets/index-BDV5xGBZ.css +0 -1
- package/dist/assets/index-HTCN44kj.js +0 -212
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cascade/gpu` — WebGPU's flag namespaces as plain values.
|
|
3
|
+
*
|
|
4
|
+
* Small, and it was load-bearing. TypeScript's DOM library declares the WebGPU
|
|
5
|
+
* *interfaces* but not the constant objects, so the only way to reach
|
|
6
|
+
* `GPUTextureUsage.RENDER_ATTACHMENT` was off `globalThis` — which
|
|
7
|
+
* `architecture/ambient-state` correctly forbids inside a node module, because
|
|
8
|
+
* an ambient read makes a node's inputs invisible to the static reader that
|
|
9
|
+
* lets `cascade check` say anything without cooking.
|
|
10
|
+
*
|
|
11
|
+
* So a sketch had to choose between a rule and a working shader, and it chose
|
|
12
|
+
* a project library file to hide the read in. Both stay honest now: the rule
|
|
13
|
+
* keeps its teeth and a node imports these instead. That is the whole reason
|
|
14
|
+
* this file exists, and it is why it had to land with the capability rather
|
|
15
|
+
* than after it — a rule whose only workaround is removed at the same moment
|
|
16
|
+
* is a rule; one enforced a release earlier is a nuisance.
|
|
17
|
+
*
|
|
18
|
+
* The values are fixed by the WebGPU specification, not by an implementation,
|
|
19
|
+
* which is what makes writing them down safe rather than a guess. They are
|
|
20
|
+
* checked against a real browser's own globals in `tests/gpu-usage-flags.test.ts`
|
|
21
|
+
* — the numbers below were verified against Chrome rather than recalled.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** `GPUBufferUsage`. */
|
|
25
|
+
export const BufferUsage = {
|
|
26
|
+
MAP_READ: 0x0001,
|
|
27
|
+
MAP_WRITE: 0x0002,
|
|
28
|
+
COPY_SRC: 0x0004,
|
|
29
|
+
COPY_DST: 0x0008,
|
|
30
|
+
INDEX: 0x0010,
|
|
31
|
+
VERTEX: 0x0020,
|
|
32
|
+
UNIFORM: 0x0040,
|
|
33
|
+
STORAGE: 0x0080,
|
|
34
|
+
INDIRECT: 0x0100,
|
|
35
|
+
QUERY_RESOLVE: 0x0200,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
/** `GPUTextureUsage`. */
|
|
39
|
+
export const TextureUsage = {
|
|
40
|
+
COPY_SRC: 0x01,
|
|
41
|
+
COPY_DST: 0x02,
|
|
42
|
+
TEXTURE_BINDING: 0x04,
|
|
43
|
+
STORAGE_BINDING: 0x08,
|
|
44
|
+
RENDER_ATTACHMENT: 0x10,
|
|
45
|
+
/**
|
|
46
|
+
* Chrome 147 has this and it is newer than the rest — verified present
|
|
47
|
+
* 2026-09-08 alongside the others, but do not assume every implementation
|
|
48
|
+
* carries it. Included rather than omitted deliberately: leaving it out
|
|
49
|
+
* would send a node that wants it back to `globalThis`, which is the exact
|
|
50
|
+
* ambient read this file exists to remove, so an honest constant with a
|
|
51
|
+
* caveat beats an omission that forces a rule violation.
|
|
52
|
+
*/
|
|
53
|
+
TRANSIENT_ATTACHMENT: 0x20,
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
/** `GPUShaderStage`, for a bind group layout entry's `visibility`. */
|
|
57
|
+
export const ShaderStage = {
|
|
58
|
+
VERTEX: 0x1,
|
|
59
|
+
FRAGMENT: 0x2,
|
|
60
|
+
COMPUTE: 0x4,
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
/** `GPUMapMode`, for `buffer.mapAsync`. */
|
|
64
|
+
export const MapMode = {
|
|
65
|
+
READ: 0x1,
|
|
66
|
+
WRITE: 0x2,
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
/** `GPUColorWrite`, for a colour target's `writeMask`. */
|
|
70
|
+
export const ColorWrite = {
|
|
71
|
+
RED: 0x1,
|
|
72
|
+
GREEN: 0x2,
|
|
73
|
+
BLUE: 0x4,
|
|
74
|
+
ALPHA: 0x8,
|
|
75
|
+
ALL: 0xf,
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
/** Read one single-sample rgba8unorm 2D texture into owned, tightly packed
|
|
79
|
+
* top-left RGBA bytes. No colour conversion, PNG encoding or graph transport.
|
|
80
|
+
* The caller owns the source texture and must give it COPY_SRC usage.
|
|
81
|
+
*/
|
|
82
|
+
export async function readTexture(
|
|
83
|
+
device: GPUDevice,
|
|
84
|
+
texture: GPUTexture,
|
|
85
|
+
options: { signal?: {
|
|
86
|
+
readonly aborted: boolean;
|
|
87
|
+
addEventListener(type: 'abort', listener: () => void, options?: { once?: boolean }): void;
|
|
88
|
+
removeEventListener(type: 'abort', listener: () => void): void;
|
|
89
|
+
} } = {},
|
|
90
|
+
): Promise<{ data: Uint8Array<ArrayBuffer>; width: number; height: number }> {
|
|
91
|
+
const { signal } = options;
|
|
92
|
+
if (signal?.aborted) throw new Error('GPU readback aborted');
|
|
93
|
+
if (texture.format !== 'rgba8unorm' || texture.dimension !== '2d'
|
|
94
|
+
|| texture.depthOrArrayLayers !== 1 || texture.sampleCount !== 1
|
|
95
|
+
|| !(texture.usage & TextureUsage.COPY_SRC)) {
|
|
96
|
+
throw new Error('GPU readback requires a single-sample rgba8unorm 2D texture with COPY_SRC usage');
|
|
97
|
+
}
|
|
98
|
+
const { width, height } = texture;
|
|
99
|
+
const rowBytes = width * 4;
|
|
100
|
+
const bytesPerRow = Math.ceil(rowBytes / 256) * 256;
|
|
101
|
+
let buffer: GPUBuffer | undefined;
|
|
102
|
+
let mapped = false;
|
|
103
|
+
let abort: (() => void) | undefined;
|
|
104
|
+
let errorScopeOpen = false;
|
|
105
|
+
try {
|
|
106
|
+
device.pushErrorScope('validation');
|
|
107
|
+
errorScopeOpen = true;
|
|
108
|
+
buffer = device.createBuffer({
|
|
109
|
+
label: 'Cascade RGBA readback', size: bytesPerRow * height,
|
|
110
|
+
usage: BufferUsage.COPY_DST | BufferUsage.MAP_READ,
|
|
111
|
+
});
|
|
112
|
+
const encoder = device.createCommandEncoder();
|
|
113
|
+
encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow }, [width, height]);
|
|
114
|
+
device.queue.submit([encoder.finish()]);
|
|
115
|
+
const validation = device.popErrorScope();
|
|
116
|
+
errorScopeOpen = false;
|
|
117
|
+
const completion = Promise.allSettled([buffer.mapAsync(MapMode.READ), validation]);
|
|
118
|
+
let completed: Awaited<typeof completion>;
|
|
119
|
+
if (signal) {
|
|
120
|
+
completed = await Promise.race([completion, new Promise<never>((_, reject) => {
|
|
121
|
+
abort = () => reject(new Error('GPU readback aborted'));
|
|
122
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
123
|
+
if (signal.aborted) abort();
|
|
124
|
+
})]);
|
|
125
|
+
} else completed = await completion;
|
|
126
|
+
const mappingResult = completed[0];
|
|
127
|
+
const validationResult = completed[1];
|
|
128
|
+
if (validationResult.status === 'rejected') throw validationResult.reason;
|
|
129
|
+
const validationError = validationResult.value;
|
|
130
|
+
if (validationError) throw new Error(`GPU readback validation failed: ${validationError.message}`);
|
|
131
|
+
if (mappingResult.status === 'rejected') throw mappingResult.reason;
|
|
132
|
+
mapped = true;
|
|
133
|
+
if (signal?.aborted) throw new Error('GPU readback aborted');
|
|
134
|
+
const source = new Uint8Array(buffer.getMappedRange());
|
|
135
|
+
const data = new Uint8Array(rowBytes * height);
|
|
136
|
+
for (let y = 0; y < height; y++) data.set(source.subarray(y * bytesPerRow, y * bytesPerRow + rowBytes), y * rowBytes);
|
|
137
|
+
return { data, width, height };
|
|
138
|
+
} finally {
|
|
139
|
+
if (abort) signal?.removeEventListener('abort', abort);
|
|
140
|
+
if (errorScopeOpen) {
|
|
141
|
+
try { await device.popErrorScope(); }
|
|
142
|
+
catch { /* Preserve the operation's original failure. */ }
|
|
143
|
+
}
|
|
144
|
+
if (mapped) buffer?.unmap();
|
|
145
|
+
buffer?.destroy();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Vec2i, Vec3 } from "./values.js";
|
|
2
|
+
/**
|
|
3
|
+
* A camera, following Houdini's `/obj/cam`.
|
|
4
|
+
*
|
|
5
|
+
* `AGENTS.md` already required following Houdini's parameter names where
|
|
6
|
+
* Houdini has an equivalent node, and Marcus restated it for this type
|
|
7
|
+
* specifically on 2026-09-08. It is worth writing down what that costs, because
|
|
8
|
+
* the obvious design is not the Houdini one and it was proposed first here:
|
|
9
|
+
*
|
|
10
|
+
* **There is no field of view.** Houdini has `focal` (a focal length in
|
|
11
|
+
* millimetres) and `aperture` (the horizontal aperture, also in millimetres),
|
|
12
|
+
* and the angle is derived from the two. The defaults — 50 mm on a 41.4214 mm
|
|
13
|
+
* aperture — are a 45° horizontal field of view, which is where that odd
|
|
14
|
+
* aperture number comes from. Carrying an `fov` instead would be one number
|
|
15
|
+
* rather than two, and it would also make every lens value a photographer knows
|
|
16
|
+
* untypeable.
|
|
17
|
+
*
|
|
18
|
+
* **The orientation is `translate` and `rotate`, not eye-and-target.** A
|
|
19
|
+
* Houdini camera is an object with an ordinary transform, looking down its own
|
|
20
|
+
* **-Z** with **+Y** up, and look-at is a second mechanism layered on top
|
|
21
|
+
* (`lookatpath` and `upvector`) rather than the primitive. So `lookat` and `up`
|
|
22
|
+
* are here as the optional override they are in Houdini, and `rotate` is what
|
|
23
|
+
* the type actually carries.
|
|
24
|
+
*
|
|
25
|
+
* **Rotation is degrees, XYZ order** — Houdini's convention, and already the
|
|
26
|
+
* one `cascade.geo.Transform` uses for its own `rotate`. Note that this is
|
|
27
|
+
* deliberately unlike expressions, where bare trigonometry is radians; that
|
|
28
|
+
* divergence is recorded in `AGENTS.md` and is not new here.
|
|
29
|
+
*
|
|
30
|
+
* The one place this departs from Houdini's names is **`resolution` in place of
|
|
31
|
+
* `resx` and `resy`**, because the vault's more specific rule wins: anything
|
|
32
|
+
* with an x and a y is one `vec2`, and resolutions are named in that rule
|
|
33
|
+
* explicitly. Two floats that are really one vector cost two Inspector rows and
|
|
34
|
+
* two keyframes and let a graph carry a width without its height.
|
|
35
|
+
*
|
|
36
|
+
* Resolution belongs to the camera rather than to the render target for a
|
|
37
|
+
* Houdini reason that is easy to miss: the **vertical** aperture is not stored,
|
|
38
|
+
* it is derived from the horizontal one and the resolution ratio. So the frame
|
|
39
|
+
* shape participates in the projection, and a camera that did not know it could
|
|
40
|
+
* not produce a vertical angle at all.
|
|
41
|
+
*/
|
|
42
|
+
export interface Camera {
|
|
43
|
+
/** World position. Houdini `t`. */
|
|
44
|
+
readonly translate: Vec3;
|
|
45
|
+
/** Degrees, XYZ order. Houdini `r`. The camera looks down its own -Z. */
|
|
46
|
+
readonly rotate: Vec3;
|
|
47
|
+
/** Focal length in millimetres. Houdini `focal`, default 50. */
|
|
48
|
+
readonly focal: number;
|
|
49
|
+
/** Horizontal aperture in millimetres. Houdini `aperture`, default 41.4214. */
|
|
50
|
+
readonly aperture: number;
|
|
51
|
+
/** Near clipping plane. Houdini `near`, default 0.001. */
|
|
52
|
+
readonly near: number;
|
|
53
|
+
/** Far clipping plane. Houdini `far`, default 10000. */
|
|
54
|
+
readonly far: number;
|
|
55
|
+
/** Frame size in pixels. Houdini `resx`/`resy` as one `vec2i`. */
|
|
56
|
+
readonly resolution: Vec2i;
|
|
57
|
+
/** PIXEL aspect ratio, not the frame's. Houdini `aspect`, default 1. */
|
|
58
|
+
readonly aspect: number;
|
|
59
|
+
/** Houdini `projection`. */
|
|
60
|
+
readonly projection: "perspective" | "orthographic";
|
|
61
|
+
/** Width of the orthographic frame in world units. Houdini `orthowidth`. */
|
|
62
|
+
readonly orthowidth: number;
|
|
63
|
+
}
|
|
64
|
+
/** Houdini's own defaults, so a camera with nothing set matches a fresh
|
|
65
|
+
* `/obj/cam` rather than something invented here. */
|
|
66
|
+
export declare const CAMERA_DEFAULTS: Camera;
|
|
67
|
+
/** True for any object carrying the fields this type is identified by. Used at
|
|
68
|
+
* the document boundary, where a value arrives as parsed JSON. */
|
|
69
|
+
export declare function isCamera(value: unknown): value is Camera;
|
|
70
|
+
//# sourceMappingURL=camera.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Houdini's own defaults, so a camera with nothing set matches a fresh
|
|
2
|
+
* `/obj/cam` rather than something invented here. */
|
|
3
|
+
export const CAMERA_DEFAULTS = Object.freeze({
|
|
4
|
+
translate: [0, 0, 0],
|
|
5
|
+
rotate: [0, 0, 0],
|
|
6
|
+
focal: 50,
|
|
7
|
+
aperture: 41.4214,
|
|
8
|
+
near: 0.001,
|
|
9
|
+
far: 10000,
|
|
10
|
+
resolution: [1280, 720],
|
|
11
|
+
aspect: 1,
|
|
12
|
+
projection: "perspective",
|
|
13
|
+
orthowidth: 2,
|
|
14
|
+
});
|
|
15
|
+
/** True for any object carrying the fields this type is identified by. Used at
|
|
16
|
+
* the document boundary, where a value arrives as parsed JSON. */
|
|
17
|
+
export function isCamera(value) {
|
|
18
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const candidate = value;
|
|
22
|
+
return (Array.isArray(candidate.translate) &&
|
|
23
|
+
Array.isArray(candidate.rotate) &&
|
|
24
|
+
typeof candidate.focal === "number" &&
|
|
25
|
+
typeof candidate.aperture === "number");
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=camera.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AssetRef, ImageRef, JsonValue
|
|
1
|
+
import type { AssetRef, ImageRef, JsonValue } from "./values.js";
|
|
2
2
|
export interface CascadeAbortSignal {
|
|
3
3
|
readonly aborted: boolean;
|
|
4
4
|
readonly reason?: unknown;
|
|
@@ -66,10 +66,73 @@ export interface PythonCapability {
|
|
|
66
66
|
progress: ProgressReporter;
|
|
67
67
|
}): Promise<JsonValue | AssetRef | ImageRef>;
|
|
68
68
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
/**
|
|
70
|
+
* A GPU a node can compute on, and the state that has to outlive one cook.
|
|
71
|
+
*
|
|
72
|
+
* This replaces `WebGLCapability`, which nothing implemented, nothing declared
|
|
73
|
+
* and no host installed — its one method was WebGL2-shaped, and keeping both
|
|
74
|
+
* names would have meant two devices in one page. Two devices cannot exchange
|
|
75
|
+
* a texture: an allocation made on one is not bindable, sampleable or copyable
|
|
76
|
+
* on the other, and the failure is a validation error at bind time rather than
|
|
77
|
+
* anything a graph author would recognise. So a shared device is not a
|
|
78
|
+
* preference, it is the precondition for a chain of GPU nodes at all.
|
|
79
|
+
*
|
|
80
|
+
* **The capability carries a device/cache contract, not graph textures.** Ports
|
|
81
|
+
* still pass `image`; what this buys is
|
|
82
|
+
* one device instead of one per node module, an owner for `device.lost`, and
|
|
83
|
+
* somewhere legitimate to keep a pipeline — which is what lets the
|
|
84
|
+
* `architecture/ambient-state` rule stay strict. The texture store, the
|
|
85
|
+
* generation-stamped handles remain future work. Explicit RGBA8 readback is a
|
|
86
|
+
* helper in `cascade/gpu`, shared by browser and native hosts without encoding.
|
|
87
|
+
*
|
|
88
|
+
* The `GPUDevice` type comes from `@webgpu/types`, added to this package's
|
|
89
|
+
* `tsconfig.json` as its only `types` entry. Contracts compiles with
|
|
90
|
+
* `lib: ["ES2022"]` and no DOM on purpose — it is the vocabulary both hosts
|
|
91
|
+
* share, so pulling in the whole browser would let a browser-only type reach a
|
|
92
|
+
* portable definition by accident. A hand-written stub was the alternative and
|
|
93
|
+
* would have been worse than a dependency: it would shadow the genuine
|
|
94
|
+
* `GPUDevice` at the node's own call site, so every real method call would
|
|
95
|
+
* stop typechecking.
|
|
96
|
+
*
|
|
97
|
+
* The device is raw on purpose. A wrapped pipeline API would be a second thing
|
|
98
|
+
* to learn, would lag the spec, and would fight the reason to reach for WebGPU
|
|
99
|
+
* at all, which is that you write WGSL and encode passes. The capability wraps
|
|
100
|
+
* only what carries lifetime or identity, because those are the parts a host
|
|
101
|
+
* has to be able to account for.
|
|
102
|
+
*/
|
|
103
|
+
export interface GpuCapability {
|
|
104
|
+
/**
|
|
105
|
+
* The one device for this host. Shared by every node declaring `gpu`, which
|
|
106
|
+
* is the whole point — see the note above on why two would be useless.
|
|
107
|
+
*/
|
|
108
|
+
readonly device: GPUDevice;
|
|
109
|
+
/**
|
|
110
|
+
* Who is actually rendering, recorded so a difference between two runs of
|
|
111
|
+
* the same graph is attributable rather than a mystery. WebGPU output can
|
|
112
|
+
* differ across adapters, and today there is no way to answer "which one".
|
|
113
|
+
*/
|
|
114
|
+
readonly adapterInfo: Readonly<{
|
|
115
|
+
vendor: string;
|
|
116
|
+
architecture: string;
|
|
117
|
+
device: string;
|
|
118
|
+
description: string;
|
|
119
|
+
}>;
|
|
120
|
+
/** What this adapter will actually allow — `maxTextureDimension3D` and the
|
|
121
|
+
* rest. Read at runtime rather than assumed; the 3D default is far below
|
|
122
|
+
* the 2D one and a hard-coded guess would be wrong on some machines. */
|
|
123
|
+
readonly limits: Readonly<Record<string, number>>;
|
|
124
|
+
/**
|
|
125
|
+
* A resource that outlives one cook — a compiled pipeline, a sampler, a
|
|
126
|
+
* bind group layout. Keyed per node by the host, so two instances of one
|
|
127
|
+
* module do not share a cache entry and a module-level `Map` is never the
|
|
128
|
+
* right answer again.
|
|
129
|
+
*
|
|
130
|
+
* `create` runs once per key. `destroy` is called when the entry is dropped:
|
|
131
|
+
* when the device is lost, or when the owning host disposes. This is the member
|
|
132
|
+
* that makes `architecture/module-state` keepable — it was previously
|
|
133
|
+
* forbidding the only available way to hold a pipeline.
|
|
134
|
+
*/
|
|
135
|
+
cache<T>(key: string, create: (device: GPUDevice) => T, destroy?: (value: T) => void): T;
|
|
73
136
|
}
|
|
74
137
|
export interface ShellRunResult {
|
|
75
138
|
readonly stdout: string;
|
|
@@ -102,7 +165,7 @@ export interface CapabilityMap {
|
|
|
102
165
|
readonly assets: AssetCapability;
|
|
103
166
|
readonly media: MediaCapability;
|
|
104
167
|
readonly python: PythonCapability;
|
|
105
|
-
readonly
|
|
168
|
+
readonly gpu: GpuCapability;
|
|
106
169
|
readonly shell: ShellCapability;
|
|
107
170
|
}
|
|
108
171
|
export type NodeCapabilityName = keyof CapabilityMap;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CapabilityMap, CascadeAbortSignal, NodeCapabilityName, ProgressReporter } from "./capabilities.js";
|
|
2
2
|
import type { CascadeType, CoreType, JsonValue, NamespacedType, SerializableValueForType, ValueForType } from "./values.js";
|
|
3
3
|
export type RuntimeEnvironment = "portable" | "browser" | "server";
|
|
4
|
-
export type CapabilityForEnvironment<E extends RuntimeEnvironment> = E extends "portable" ? "assets" | "media" : E extends "browser" ? "assets" | "media" | "
|
|
4
|
+
export type CapabilityForEnvironment<E extends RuntimeEnvironment> = E extends "portable" ? "assets" | "media" | "gpu" : E extends "browser" ? "assets" | "media" | "gpu" : "files" | "assets" | "media" | "python" | "shell" | "gpu";
|
|
5
5
|
export type DataTypeForEnvironment<E extends RuntimeEnvironment> = E extends "browser" ? CascadeType : Exclude<CascadeType, "texture">;
|
|
6
6
|
type ControlFor<T extends CascadeType> = T extends "float" ? "number" | "slider" | "range" : T extends "int" ? "int" | "slider" | "range" : T extends "bool" ? "boolean" : T extends "string" ? "text" | "textarea" | "select" : T extends "vec2" | "vec3" | "vec4" | "vec2i" | "vec3i" | "vec4i" ? "vector" : T extends "mat2" | "mat3" | "mat4" ? "matrix" : T extends "color" ? "color" : T extends "image" ? "image" : T extends "asset" ? "asset" | "select" : T extends "texture" ? never : "select";
|
|
7
7
|
/**
|
|
@@ -41,12 +41,34 @@ type AcceptMetadata<T extends CascadeType> = T extends "image" | "asset" ? {
|
|
|
41
41
|
} : {
|
|
42
42
|
readonly accept?: never;
|
|
43
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* One choice on a select control: the bare value, or the value with the words
|
|
46
|
+
* a person reads.
|
|
47
|
+
*
|
|
48
|
+
* The labelled form exists because losing it cost something real. Converting
|
|
49
|
+
* `image-superres` turned *"Off (pass through) / Local — Real-ESRGAN /
|
|
50
|
+
* Magnific — costs money"* into `off` / `local` / `magnific`, and that third
|
|
51
|
+
* label carried **the only warning that one of the three engines spends money
|
|
52
|
+
* per call**. A lost display name is a papercut; a lost cost warning is a
|
|
53
|
+
* bill. Six props across the sketches had lost their labels by the time this
|
|
54
|
+
* landed, and that one is why it was not deferred any longer.
|
|
55
|
+
*
|
|
56
|
+
* A bare value stays legal, and reads as "the value is the label" — which is
|
|
57
|
+
* true of most enums and is what every existing definition means.
|
|
58
|
+
*/
|
|
59
|
+
export type SelectOption<T extends CascadeType> = SerializableValueForType<T> | Readonly<{
|
|
60
|
+
value: SerializableValueForType<T>;
|
|
61
|
+
label: string;
|
|
62
|
+
/** Greyed out but visible: an option that exists and cannot be picked
|
|
63
|
+
* here, which is more informative than one that is simply absent. */
|
|
64
|
+
disabled?: boolean;
|
|
65
|
+
}>;
|
|
44
66
|
type SelectMetadata<T extends CascadeType> = {
|
|
45
67
|
readonly control?: Exclude<ControlFor<T>, "select">;
|
|
46
68
|
readonly options?: never;
|
|
47
69
|
} | {
|
|
48
70
|
readonly control: Extract<ControlFor<T>, "select">;
|
|
49
|
-
readonly options?: readonly
|
|
71
|
+
readonly options?: readonly SelectOption<T>[];
|
|
50
72
|
};
|
|
51
73
|
export interface TriggerInputDefinition {
|
|
52
74
|
readonly kind: "trigger";
|
|
@@ -80,6 +102,43 @@ export type PropDefinition<T extends PropDataType> = {
|
|
|
80
102
|
readonly default: SerializableValueForType<T>;
|
|
81
103
|
readonly label?: string;
|
|
82
104
|
readonly description?: string;
|
|
105
|
+
/**
|
|
106
|
+
* An expression the prop starts with, so a node can come out of the box
|
|
107
|
+
* already moving — `sine-oscillator` and `ripple` baked in `$T` as dynamic
|
|
108
|
+
* nodes and shipped a static 0 once converted, which is what this restores.
|
|
109
|
+
* Marcus's decision, 2026-09-08, asked as a taste question about his own
|
|
110
|
+
* tool: a node like that should self-animate when dropped.
|
|
111
|
+
*
|
|
112
|
+
* **It is a default, so it loses to a stored value like any other.** A
|
|
113
|
+
* document that saved a plain number for this prop keeps that number and
|
|
114
|
+
* does not silently start animating on load. That is the mirror of the
|
|
115
|
+
* dropped-`params` fault: whatever the author set has to win, and the test
|
|
116
|
+
* for this asserts the losing case rather than the winning one.
|
|
117
|
+
*
|
|
118
|
+
* Applied only when the document stored nothing at all for the prop. It is a
|
|
119
|
+
* real expression once applied, not a hidden fallback — visible in the
|
|
120
|
+
* Inspector, editable, and deletable, which is how a stored expression
|
|
121
|
+
* already reads and the only version that does not lie about where the
|
|
122
|
+
* number came from.
|
|
123
|
+
*/
|
|
124
|
+
readonly expression?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Render this prop as a button that fires a named action, rather than as an
|
|
127
|
+
* editable field.
|
|
128
|
+
*
|
|
129
|
+
* It exists because converting a node removed a button. `observatory-moment`
|
|
130
|
+
* declared `action: 'panel:observatory-moments'` as a dynamic node, which the
|
|
131
|
+
* Inspector renders from `parameter.options.action`; the v1 adapter built its
|
|
132
|
+
* options from `label`, `min`, `max`, `step` and `options` only, so the
|
|
133
|
+
* Moment picker quietly became a two-step job through the panel menu instead
|
|
134
|
+
* of one click on the node. Nothing broke — the panel falls back to the
|
|
135
|
+
* selected node and reads the same prop — which is exactly why nobody would
|
|
136
|
+
* have noticed from a test.
|
|
137
|
+
*
|
|
138
|
+
* The value is the action name the host dispatches. Cascade does not
|
|
139
|
+
* interpret it; a `panel:` prefix is a Studio convention, not a contract.
|
|
140
|
+
*/
|
|
141
|
+
readonly action?: string;
|
|
83
142
|
} & NumericMetadata<T> & AcceptMetadata<T> & SelectMetadata<T>;
|
|
84
143
|
export type AnyPropDefinition = {
|
|
85
144
|
[T in PropDataType]: PropDefinition<T>;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export * from "./animation.js";
|
|
2
|
+
export * from "./camera.js";
|
|
2
3
|
export * from "./capabilities.js";
|
|
3
4
|
export * from "./color.js";
|
|
4
5
|
export * from "./definition.js";
|
|
5
6
|
export * from "./document.js";
|
|
6
7
|
export * from "./geometry.js";
|
|
8
|
+
export * from "./series.js";
|
|
7
9
|
export * from "./validate.js";
|
|
8
10
|
export * from "./values.js";
|
|
9
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/contracts/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export * from "./animation.js";
|
|
2
|
+
export * from "./camera.js";
|
|
2
3
|
export * from "./capabilities.js";
|
|
3
4
|
export * from "./color.js";
|
|
4
5
|
export * from "./definition.js";
|
|
5
6
|
export * from "./document.js";
|
|
6
7
|
export * from "./geometry.js";
|
|
8
|
+
export * from "./series.js";
|
|
7
9
|
export * from "./validate.js";
|
|
8
10
|
export * from "./values.js";
|
|
9
11
|
//# sourceMappingURL=index.js.map
|
package/dist/contracts/schema.js
CHANGED
|
@@ -36,15 +36,40 @@ const dataProperties = {
|
|
|
36
36
|
step: { type: "number", exclusiveMinimum: 0 },
|
|
37
37
|
accept: { type: "array", items: { type: "string", minLength: 1 } },
|
|
38
38
|
control,
|
|
39
|
-
|
|
39
|
+
// A bare value, or `{ value, label, disabled? }`. Both forms, because a
|
|
40
|
+
// bare value means "the value is its own label" and is what every
|
|
41
|
+
// definition written before the labelled form meant.
|
|
42
|
+
options: {
|
|
43
|
+
type: "array",
|
|
44
|
+
items: {
|
|
45
|
+
anyOf: [
|
|
46
|
+
{ not: { type: "object" } },
|
|
47
|
+
{
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
value: {},
|
|
51
|
+
label: { type: "string", minLength: 1 },
|
|
52
|
+
disabled: { type: "boolean" },
|
|
53
|
+
},
|
|
54
|
+
required: ["value", "label"],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
40
60
|
};
|
|
41
61
|
const data = object(dataProperties, ["kind", "type"]);
|
|
42
62
|
const output = object({ kind: dataProperties.kind, type: dataProperties.type, description }, ["kind", "type"]);
|
|
43
63
|
const { kind: _kind, variadic: _variadic, ...propProperties } = dataProperties;
|
|
44
|
-
const prop = object({
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
|
|
64
|
+
const prop = object({
|
|
65
|
+
...propProperties,
|
|
66
|
+
label: { type: "string" },
|
|
67
|
+
// Props only, not inputs: an input's value comes from whatever is wired to
|
|
68
|
+
// it, so a default expression there would be describing the wrong thing.
|
|
69
|
+
expression: { type: "string", minLength: 1 },
|
|
70
|
+
// A button that fires a named host action instead of an editable field.
|
|
71
|
+
action: { type: "string", minLength: 1 },
|
|
72
|
+
}, ["type", "default"]);
|
|
48
73
|
function environmentCapabilities(runsOn, capabilities) {
|
|
49
74
|
return {
|
|
50
75
|
if: { properties: { runsOn: { const: runsOn } } },
|
|
@@ -71,14 +96,15 @@ export const nodeDefinitionSchema = {
|
|
|
71
96
|
props: record(prop),
|
|
72
97
|
}, ["apiVersion", "runsOn"]),
|
|
73
98
|
allOf: [
|
|
74
|
-
environmentCapabilities("portable", ["assets", "media"]),
|
|
75
|
-
environmentCapabilities("browser", ["assets", "media", "
|
|
99
|
+
environmentCapabilities("portable", ["assets", "media", "gpu"]),
|
|
100
|
+
environmentCapabilities("browser", ["assets", "media", "gpu"]),
|
|
76
101
|
environmentCapabilities("server", [
|
|
77
102
|
"files",
|
|
78
103
|
"assets",
|
|
79
104
|
"media",
|
|
80
105
|
"python",
|
|
81
106
|
"shell",
|
|
107
|
+
"gpu",
|
|
82
108
|
]),
|
|
83
109
|
],
|
|
84
110
|
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { CascadePreset } from "./document.js";
|
|
2
|
+
/**
|
|
3
|
+
* A series: one system, many instances.
|
|
4
|
+
*
|
|
5
|
+
* **A series is a graph plus an ordered list of parameter sets, and nothing
|
|
6
|
+
* else.** Plan: `Work/Projects/Cascade/plans/PLAN series.md`. The definition is
|
|
7
|
+
* deliberately this thin, because the alternative is the trap the plan names —
|
|
8
|
+
* *the series must not become a second document format.* If it grows its own
|
|
9
|
+
* copy of node state it forks from the graph and the two disagree.
|
|
10
|
+
*
|
|
11
|
+
* So there is no boundary in it. Houdini marks a region because the varying
|
|
12
|
+
* thing flows through the region as geometry; here the varying thing is a
|
|
13
|
+
* sparse map of parameter overrides keyed by node id, so the affected subgraph
|
|
14
|
+
* is **derived** from those overrides by the dependency graph that already
|
|
15
|
+
* exists. Nothing is marked, so nothing can be marked wrongly. That is also
|
|
16
|
+
* why `Block Begin`/`Block End` are not copied: they exist to serve feedback,
|
|
17
|
+
* a series has none, and the form without the feedback is all cost.
|
|
18
|
+
*/
|
|
19
|
+
export interface CascadeSeries {
|
|
20
|
+
readonly version: 1;
|
|
21
|
+
/**
|
|
22
|
+
* Ordered, and ordered on purpose.
|
|
23
|
+
*
|
|
24
|
+
* Independent instances do not need an order, which makes a bag tempting.
|
|
25
|
+
* But feedback — instance N reading N-1 — needs a sequence, and retrofitting
|
|
26
|
+
* order onto something built unordered is the expensive kind of change.
|
|
27
|
+
* Ordered costs nothing today. Marcus, 2026-09-08: *"feedback will at some
|
|
28
|
+
* point be useful but maybe not this series."*
|
|
29
|
+
*/
|
|
30
|
+
readonly instances: readonly CascadeSeriesInstance[];
|
|
31
|
+
/** Which output the series collects, one value per instance. */
|
|
32
|
+
readonly collect: SeriesCollection;
|
|
33
|
+
}
|
|
34
|
+
export interface CascadeSeriesInstance {
|
|
35
|
+
/**
|
|
36
|
+
* **The record's own id, never its index in the array.**
|
|
37
|
+
*
|
|
38
|
+
* The decision the plan says to make before anything is built. A set reorders
|
|
39
|
+
* the moment a filter changes or data arrives, so a judgement pinned to a
|
|
40
|
+
* position — a favourite, an override, a note — silently becomes a different
|
|
41
|
+
* picture. For the Observatory this is the moment's id; for a wedge, the
|
|
42
|
+
* parameter tuple; for a hand-written list, whatever the author typed.
|
|
43
|
+
*/
|
|
44
|
+
readonly id: string;
|
|
45
|
+
/** This instance's overrides. `CascadePreset` already is a parameter set. */
|
|
46
|
+
readonly parameters: CascadePreset;
|
|
47
|
+
/** For display. Never an address — see `id`. */
|
|
48
|
+
readonly label?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Either a graph output by name, or any node's output port.
|
|
52
|
+
*
|
|
53
|
+
* Both, for the same reason subnets stay an ordinary tool rather than the
|
|
54
|
+
* mechanism: requiring a `cascade.core.Output` node would mean restructuring an
|
|
55
|
+
* existing graph before it could be a series, and `cloud-volumes` is exactly
|
|
56
|
+
* that case.
|
|
57
|
+
*/
|
|
58
|
+
export type SeriesCollection = {
|
|
59
|
+
readonly graphOutput: string;
|
|
60
|
+
} | {
|
|
61
|
+
readonly nodeId: string;
|
|
62
|
+
readonly output: string;
|
|
63
|
+
};
|
|
64
|
+
/** One instance's result. `status` is the run's, so a failed instance is
|
|
65
|
+
* reported rather than dropped — a series that silently renders 9,998 of
|
|
66
|
+
* 10,000 is worse than one that says which two failed. */
|
|
67
|
+
export interface SeriesInstanceResult {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly index: number;
|
|
70
|
+
readonly status: "completed" | "cancelled" | "failed";
|
|
71
|
+
readonly value: unknown;
|
|
72
|
+
readonly diagnostics: readonly unknown[];
|
|
73
|
+
readonly durationMs: number;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=series.d.ts.map
|
|
@@ -13,16 +13,16 @@ const CONTROLS = new Set(["number", "slider", "range", "int", "boolean", "text",
|
|
|
13
13
|
* capability belongs to.
|
|
14
14
|
*/
|
|
15
15
|
export const CAPABILITIES_BY_ENVIRONMENT = {
|
|
16
|
-
portable: ["assets", "media"],
|
|
17
|
-
browser: ["assets", "media", "
|
|
18
|
-
server: ["files", "assets", "media", "python", "shell"],
|
|
16
|
+
portable: ["assets", "media", "gpu"],
|
|
17
|
+
browser: ["assets", "media", "gpu"],
|
|
18
|
+
server: ["files", "assets", "media", "python", "shell", "gpu"],
|
|
19
19
|
};
|
|
20
20
|
const ALLOWED = CAPABILITIES_BY_ENVIRONMENT;
|
|
21
21
|
const fields = (names) => new Set(names.split(" "));
|
|
22
22
|
const TOP_FIELDS = fields("apiVersion runsOn container capabilities label description icon inputs outputs props");
|
|
23
23
|
const DATA_FIELDS = fields("kind type default description variadic min max step accept control options");
|
|
24
24
|
const OUTPUT_FIELDS = fields("kind type description");
|
|
25
|
-
const PROP_FIELDS = fields("type default label description min max step accept control options");
|
|
25
|
+
const PROP_FIELDS = fields("type default label description min max step accept control options expression action");
|
|
26
26
|
function diagnostic(code, message, path) {
|
|
27
27
|
return { phase: "definition", code: `definition/${code}`, message, path };
|
|
28
28
|
}
|
|
@@ -170,6 +170,22 @@ export function validateNodeDefinition(value) {
|
|
|
170
170
|
add("invalid-control", `${String(raw.control)} is not a Cascade control`, `${path}.control`);
|
|
171
171
|
if ("options" in raw && raw.control !== "select")
|
|
172
172
|
add("options-without-select", "options require the select control", `${path}.options`);
|
|
173
|
+
// A labelled option is `{ value, label }`. Checked because the failure
|
|
174
|
+
// is cosmetic and silent: a malformed entry reaches the control and
|
|
175
|
+
// renders as "[object Object]" rather than erroring, and the labelled
|
|
176
|
+
// form exists precisely because a lost label lost a cost warning.
|
|
177
|
+
if (Array.isArray(raw.options))
|
|
178
|
+
for (const [index, option] of raw.options.entries()) {
|
|
179
|
+
if (option === null || typeof option !== "object")
|
|
180
|
+
continue;
|
|
181
|
+
const entry = option;
|
|
182
|
+
if (!("value" in entry))
|
|
183
|
+
add("invalid-option", "a labelled option needs a value", `${path}.options[${index}]`);
|
|
184
|
+
if (typeof entry.label !== "string" || !entry.label)
|
|
185
|
+
add("invalid-option", "a labelled option needs a non-empty label", `${path}.options[${index}].label`);
|
|
186
|
+
if ("disabled" in entry && typeof entry.disabled !== "boolean")
|
|
187
|
+
add("invalid-option", "disabled must be a boolean", `${path}.options[${index}].disabled`);
|
|
188
|
+
}
|
|
173
189
|
const type = raw.type;
|
|
174
190
|
if (!validType(type)) {
|
|
175
191
|
add("invalid-type", `${String(type)} is not a Cascade type`, `${path}.type`);
|
|
@@ -194,6 +210,15 @@ export function validateNodeDefinition(value) {
|
|
|
194
210
|
!Number.isFinite(raw.step) ||
|
|
195
211
|
raw.step <= 0))
|
|
196
212
|
add("invalid-step", "step must be greater than zero", `${path}.step`);
|
|
213
|
+
if (section === "props" &&
|
|
214
|
+
"expression" in raw &&
|
|
215
|
+
(typeof raw.expression !== "string" || raw.expression.length === 0))
|
|
216
|
+
add("invalid-expression", "expression must be a non-empty string", `${path}.expression`);
|
|
217
|
+
for (const field of ["min", "max"]) {
|
|
218
|
+
if (field in raw &&
|
|
219
|
+
(typeof raw[field] !== "number" || !Number.isFinite(raw[field])))
|
|
220
|
+
add(`invalid-${field}`, `${field} must be a finite number`, `${path}.${field}`);
|
|
221
|
+
}
|
|
197
222
|
if (typeof raw.min === "number" &&
|
|
198
223
|
typeof raw.max === "number" &&
|
|
199
224
|
raw.min > raw.max)
|