@dopaminefx/core 0.1.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 (136) hide show
  1. package/dist/engine/color.d.ts +71 -0
  2. package/dist/engine/color.d.ts.map +1 -0
  3. package/dist/engine/color.js +107 -0
  4. package/dist/engine/color.js.map +1 -0
  5. package/dist/engine/context.d.ts +54 -0
  6. package/dist/engine/context.d.ts.map +1 -0
  7. package/dist/engine/context.js +0 -0
  8. package/dist/engine/context.js.map +1 -0
  9. package/dist/engine/gl.d.ts +9 -0
  10. package/dist/engine/gl.d.ts.map +1 -0
  11. package/dist/engine/gl.js +39 -0
  12. package/dist/engine/gl.js.map +1 -0
  13. package/dist/engine/look/glsl.d.ts +95 -0
  14. package/dist/engine/look/glsl.d.ts.map +1 -0
  15. package/dist/engine/look/glsl.js +171 -0
  16. package/dist/engine/look/glsl.js.map +1 -0
  17. package/dist/engine/look/particles.glsl.d.ts +21 -0
  18. package/dist/engine/look/particles.glsl.d.ts.map +1 -0
  19. package/dist/engine/look/particles.glsl.js +44 -0
  20. package/dist/engine/look/particles.glsl.js.map +1 -0
  21. package/dist/engine/sdf.d.ts +77 -0
  22. package/dist/engine/sdf.d.ts.map +1 -0
  23. package/dist/engine/sdf.js +255 -0
  24. package/dist/engine/sdf.js.map +1 -0
  25. package/dist/engine/seed.d.ts +10 -0
  26. package/dist/engine/seed.d.ts.map +1 -0
  27. package/dist/engine/seed.js +20 -0
  28. package/dist/engine/seed.js.map +1 -0
  29. package/dist/engine/shadow.d.ts +41 -0
  30. package/dist/engine/shadow.d.ts.map +1 -0
  31. package/dist/engine/shadow.js +39 -0
  32. package/dist/engine/shadow.js.map +1 -0
  33. package/dist/engine/tempo.d.ts +33 -0
  34. package/dist/engine/tempo.d.ts.map +1 -0
  35. package/dist/engine/tempo.js +51 -0
  36. package/dist/engine/tempo.js.map +1 -0
  37. package/dist/framework/conductor.d.ts +100 -0
  38. package/dist/framework/conductor.d.ts.map +1 -0
  39. package/dist/framework/conductor.js +493 -0
  40. package/dist/framework/conductor.js.map +1 -0
  41. package/dist/framework/content.d.ts +67 -0
  42. package/dist/framework/content.d.ts.map +1 -0
  43. package/dist/framework/content.js +72 -0
  44. package/dist/framework/content.js.map +1 -0
  45. package/dist/framework/dope-pass.d.ts +131 -0
  46. package/dist/framework/dope-pass.d.ts.map +1 -0
  47. package/dist/framework/dope-pass.js +346 -0
  48. package/dist/framework/dope-pass.js.map +1 -0
  49. package/dist/framework/dope-zip.d.ts +22 -0
  50. package/dist/framework/dope-zip.d.ts.map +1 -0
  51. package/dist/framework/dope-zip.js +116 -0
  52. package/dist/framework/dope-zip.js.map +1 -0
  53. package/dist/framework/effect.d.ts +128 -0
  54. package/dist/framework/effect.d.ts.map +1 -0
  55. package/dist/framework/effect.js +19 -0
  56. package/dist/framework/effect.js.map +1 -0
  57. package/dist/framework/frame-expr.d.ts +124 -0
  58. package/dist/framework/frame-expr.d.ts.map +1 -0
  59. package/dist/framework/frame-expr.js +135 -0
  60. package/dist/framework/frame-expr.js.map +1 -0
  61. package/dist/framework/load-effect.d.ts +77 -0
  62. package/dist/framework/load-effect.d.ts.map +1 -0
  63. package/dist/framework/load-effect.js +135 -0
  64. package/dist/framework/load-effect.js.map +1 -0
  65. package/dist/framework/loader.d.ts +309 -0
  66. package/dist/framework/loader.d.ts.map +1 -0
  67. package/dist/framework/loader.js +266 -0
  68. package/dist/framework/loader.js.map +1 -0
  69. package/dist/framework/mood-registry.d.ts +58 -0
  70. package/dist/framework/mood-registry.d.ts.map +1 -0
  71. package/dist/framework/mood-registry.js +58 -0
  72. package/dist/framework/mood-registry.js.map +1 -0
  73. package/dist/framework/panel-runner.d.ts +96 -0
  74. package/dist/framework/panel-runner.d.ts.map +1 -0
  75. package/dist/framework/panel-runner.js +137 -0
  76. package/dist/framework/panel-runner.js.map +1 -0
  77. package/dist/framework/pass-common.d.ts +97 -0
  78. package/dist/framework/pass-common.d.ts.map +1 -0
  79. package/dist/framework/pass-common.js +178 -0
  80. package/dist/framework/pass-common.js.map +1 -0
  81. package/dist/framework/pass-runner.d.ts +183 -0
  82. package/dist/framework/pass-runner.d.ts.map +1 -0
  83. package/dist/framework/pass-runner.js +212 -0
  84. package/dist/framework/pass-runner.js.map +1 -0
  85. package/dist/framework/programs.d.ts +54 -0
  86. package/dist/framework/programs.d.ts.map +1 -0
  87. package/dist/framework/programs.js +33 -0
  88. package/dist/framework/programs.js.map +1 -0
  89. package/dist/framework/registry.d.ts +29 -0
  90. package/dist/framework/registry.d.ts.map +1 -0
  91. package/dist/framework/registry.js +38 -0
  92. package/dist/framework/registry.js.map +1 -0
  93. package/dist/framework/runtime.d.ts +19 -0
  94. package/dist/framework/runtime.d.ts.map +1 -0
  95. package/dist/framework/runtime.js +37 -0
  96. package/dist/framework/runtime.js.map +1 -0
  97. package/dist/index.d.ts +63 -0
  98. package/dist/index.d.ts.map +1 -0
  99. package/dist/index.js +126 -0
  100. package/dist/index.js.map +1 -0
  101. package/dist/overlay.d.ts +46 -0
  102. package/dist/overlay.d.ts.map +1 -0
  103. package/dist/overlay.js +79 -0
  104. package/dist/overlay.js.map +1 -0
  105. package/dist/types.d.ts +68 -0
  106. package/dist/types.d.ts.map +1 -0
  107. package/dist/types.js +10 -0
  108. package/dist/types.js.map +1 -0
  109. package/package.json +37 -0
  110. package/src/engine/color.ts +154 -0
  111. package/src/engine/context.ts +0 -0
  112. package/src/engine/gl.ts +46 -0
  113. package/src/engine/look/glsl.ts +183 -0
  114. package/src/engine/look/particles.glsl.ts +44 -0
  115. package/src/engine/sdf.ts +298 -0
  116. package/src/engine/seed.ts +23 -0
  117. package/src/engine/shadow.ts +66 -0
  118. package/src/engine/tempo.ts +54 -0
  119. package/src/framework/conductor.ts +604 -0
  120. package/src/framework/content.ts +113 -0
  121. package/src/framework/dope-pass.ts +432 -0
  122. package/src/framework/dope-zip.ts +125 -0
  123. package/src/framework/effect.ts +127 -0
  124. package/src/framework/frame-expr.ts +217 -0
  125. package/src/framework/load-effect.ts +204 -0
  126. package/src/framework/loader.ts +502 -0
  127. package/src/framework/mood-registry.ts +87 -0
  128. package/src/framework/panel-runner.ts +233 -0
  129. package/src/framework/pass-common.ts +222 -0
  130. package/src/framework/pass-runner.ts +391 -0
  131. package/src/framework/programs.ts +62 -0
  132. package/src/framework/registry.ts +44 -0
  133. package/src/framework/runtime.ts +38 -0
  134. package/src/index.ts +227 -0
  135. package/src/overlay.ts +109 -0
  136. package/src/types.ts +63 -0
@@ -0,0 +1,502 @@
1
+ /**
2
+ * `.dope` effect loader.
3
+ *
4
+ * Parses a `.dope` JSON document (per docs/effect-format.md) and evaluates its
5
+ * `controls → render.params` mapping grammar, the OKLCH golden-angle palette,
6
+ * and the per-mood baseline table into the SAME flat render-param bag the engine
7
+ * consumes. Shader bodies stay referenced GLSL (the format references them); the
8
+ * loader is NOT a GLSL transpiler.
9
+ *
10
+ * The single load-bearing invariant (the correctness anchor): the PRNG is
11
+ * consumed in the SAME order as the legacy `resolve*Params` — `buildPalette`
12
+ * draws the base hue first (one `rng()` inside it), then the per-fire scatter
13
+ * (`rng() * 1000`). So a pinned seed reproduces the legacy output byte-for-byte;
14
+ * a vitest asserts this across a mood × intensity × whimsy × seed grid.
15
+ *
16
+ * The grammar is intentionally tiny + non-Turing-complete (no loops, no user
17
+ * functions) so it is safe to evaluate from an untrusted file and trivial to
18
+ * port to Swift for the Metal backend.
19
+ */
20
+
21
+ import { buildPalette, oklchToLinearSrgb, type OKLCH, type RGB } from "../engine/color.js";
22
+ import { mulberry32, type Rng } from "../engine/seed.js";
23
+ import { NPR_TIME_STEP_MS } from "../engine/tempo.js";
24
+ import type { BakedSdf } from "../engine/sdf.js";
25
+ import type { FrameExprNode } from "./frame-expr.js";
26
+
27
+ const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x);
28
+ const lerp = (a: number, b: number, t: number): number => a + (b - a) * clamp01(t);
29
+
30
+ /**
31
+ * The per-frame logic spec (`tempo.frame`): the datafied form of an effect's
32
+ * hand-written `frame()` hook. `amp` feeds shadowGeometry; `extras` are keyed by
33
+ * the CANONICAL extra name (matching `binding.extras[].name`) and map to each
34
+ * platform's uniform via the binding contract.
35
+ */
36
+ export interface DopeFrameSpec {
37
+ amp: FrameExprNode;
38
+ extras?: Record<string, FrameExprNode>;
39
+ }
40
+
41
+ /**
42
+ * The continuous-loop contract (`tempo.loop`): the effect repeats seamlessly
43
+ * with period `periodMs`. `parseDope` validates the two seam invariants that
44
+ * used to be per-effect convention: the period tiles the "animate on twos"
45
+ * grid (unless `snapAligned` is false) and every baseline `durationMs` is a
46
+ * whole number of periods — so the frame at `t == durationMs` matches `t == 0`
47
+ * at every whimsy. Runners surface the standard periodic clock uniforms
48
+ * `uLoopS` / `uPhase` (and the `loopS` / `phase` frame-expr inputs) from it,
49
+ * and conductors re-arm at `durationMs` instead of tearing down.
50
+ */
51
+ export interface DopeLoopSpec {
52
+ /** The seamless loop period, ms. */
53
+ periodMs: number;
54
+ /**
55
+ * Whether `periodMs` must be a whole number of `NPR_TIME_STEP_MS` steps so
56
+ * the "animate on twos" snapped clock is also periodic. Default true.
57
+ */
58
+ snapAligned?: boolean;
59
+ }
60
+
61
+ /**
62
+ * The cross-platform uniform-binding contract. SHIPS in the portable doc: the
63
+ * runtime derives which resolved params bind to which shader uniforms from it
64
+ * (and the toolchain consumes it for the Metal struct codegen).
65
+ */
66
+ export interface DopeBinding {
67
+ note?: string;
68
+ /** `render.params` (or resolved-bag) names that are NOT shader uniforms. */
69
+ excludeParams?: string[];
70
+ /** The per-fire seed-keyed scatter field (auroraSeed / inkSeed / …). */
71
+ scatterKey?: string | null;
72
+ /** The web uniform the scatter binds to (absent = not a shader uniform). */
73
+ scatterWeb?: string;
74
+ /** Per-frame/host extras (filled by `tempo.frame.extras` or host hooks). */
75
+ extras?: Array<{ name: string; type?: string; web?: string; note?: string }>;
76
+ /** Texture samplers the shader reads (see {@link DopeSampler}). */
77
+ samplers?: Array<string | DopeSampler>;
78
+ /** CPU-precomputed per-frame ARRAYS the shader reads (see {@link DopeFrameArray}). */
79
+ arrays?: DopeFrameArray[];
80
+ }
81
+
82
+ /**
83
+ * One per-frame ARRAY in the binding contract — the cross-platform transport
84
+ * for CPU-precomputed frame geometry (lightning's bolt polyline). The web and
85
+ * GL runtimes bind it by NAME as a `uniform vecN <web>[…]` array filled by the
86
+ * `frameArrays` seam; Metal binds it as a `constant floatN *` FRAGMENT BUFFER
87
+ * at index `buffer` (the toolchain's GLSL→MSL transpiler + the generated
88
+ * factory shells consume this entry).
89
+ */
90
+ export interface DopeFrameArray {
91
+ /** The canonical name — the field of the logic module's returned bundle. */
92
+ name: string;
93
+ /** The web/GL uniform array name (e.g. `uVerts`). */
94
+ web: string;
95
+ /** Vector component count (2 | 3 | 4). */
96
+ size: number;
97
+ /** The Metal fragment buffer index (>= 1; 0 is the uniform struct). */
98
+ buffer?: number;
99
+ note?: string;
100
+ }
101
+
102
+ /**
103
+ * One texture sampler in the binding contract. Beyond the binding names, a
104
+ * sampler may declare a DECLARATIVE SDF source: `outline` names a
105
+ * `geometry.outlines` entry whose baked SDF the runtime decodes + binds at
106
+ * texture unit `texture` (web; the native runtimes keep their analytic
107
+ * fallback), and `on` names the `binding.extras` flag the runner flips to 1
108
+ * when the texture is bound (left at 0 — the analytic path — when the SDF is
109
+ * absent or undecodable).
110
+ */
111
+ export interface DopeSampler {
112
+ /** The web sampler uniform name (e.g. `uSdfTex`). */
113
+ web: string;
114
+ /** The canonical/MSL argument name (e.g. `sdfTex`). */
115
+ name?: string;
116
+ /** The texture unit it binds at (texture(0) is the panel slot). */
117
+ texture?: number;
118
+ /** A `geometry.outlines` key whose baked SDF backs this sampler. */
119
+ outline?: string;
120
+ /** The canonical `binding.extras` name of the sampler's "on" flag. */
121
+ on?: string;
122
+ note?: string;
123
+ }
124
+
125
+ /** A `.dope` document (the parts the loader consumes — others are ignored). */
126
+ export interface DopeDoc {
127
+ fmt: string;
128
+ v: string;
129
+ id: string;
130
+ meta?: { name?: string; description?: string; tags?: string[] };
131
+ palette: DopePalette;
132
+ tempo: {
133
+ durationMs?: DopeParamSpec;
134
+ /** Continuous-loop contract (seamless period); see {@link DopeLoopSpec}. */
135
+ loop?: DopeLoopSpec;
136
+ /** Per-frame logic (amp + extras) as frame-expression trees. */
137
+ frame?: DopeFrameSpec;
138
+ /** Reduced-motion peak/hold the factories used to hardcode. */
139
+ reducedMotion?: { peakMs?: number; holdMs?: number };
140
+ };
141
+ render: {
142
+ params: Record<string, DopeParamSpec>;
143
+ /** Shadow occluder height: a params-only frame expression (or a bare number). */
144
+ shadowHeightFrac?: FrameExprNode;
145
+ /**
146
+ * PER-PASS scalar uniforms, keyed by the CANONICAL extra name (matching
147
+ * `binding.extras[].name`): expressions over the resolved params + the
148
+ * pass-geometry inputs (`targetMinDimPx` / `sdfRange` / `sdfViewBoxW`),
149
+ * evaluated once per pass by `evalPassExpr` (frame clocks throw).
150
+ */
151
+ pass?: Record<string, FrameExprNode>;
152
+ /** Loop-cap consts the param mapping's clampMax/clampMin reference. */
153
+ consts?: Record<string, number>;
154
+ /**
155
+ * The dynamic PANEL the host draws each frame and the shader samples
156
+ * (`sampler` must appear in `binding.samplers`; `texture` defaults to 0 —
157
+ * the cross-platform panel slot). The draw itself stays code (the
158
+ * per-platform panel-draw seam); only the wiring is data.
159
+ */
160
+ panel?: { sampler: string; texture?: number; note?: string };
161
+ /**
162
+ * Runner config: whether the shader reads `uOrigin`, and the clock
163
+ * stepping — `"none"` declares the effect's clock NEVER snaps "on twos"
164
+ * (the Canvas2D-panel runner semantics; panel geometry is hand-drawn, so
165
+ * snapping the clock would stutter it). Default: style-driven snapping.
166
+ */
167
+ config?: { usesOrigin?: boolean; stepping?: "none" };
168
+ backends?: unknown;
169
+ fallbackOrder?: string[];
170
+ };
171
+ /** The uniform-binding contract (see {@link DopeBinding}). */
172
+ binding?: DopeBinding;
173
+ /** Per-mood baseline table (color + non-color baselines), keyed by mood name. */
174
+ baselines: Record<string, Record<string, number>>;
175
+ /** Outline geometry — icon paths + (after the pack/bake step) baked SDFs. */
176
+ geometry?: DopeGeometry;
177
+ /** Free-form per-effect content (word sets, tokens) consumed by renderers. */
178
+ content?: Record<string, unknown>;
179
+ /** Typography tables (mood→face + whimsy/intensity curves) for letter effects. */
180
+ typography?: Record<string, unknown>;
181
+ }
182
+
183
+ /** An outline entry: an authored `svgPath` and/or its baked SDF + a role tag. */
184
+ export interface DopeOutline {
185
+ role?: string;
186
+ source?: string;
187
+ svgPath?: string;
188
+ /** Inline baked signed-distance field (a `data:` URI blob); see engine/sdf.ts. */
189
+ sdf?: BakedSdf;
190
+ note?: string;
191
+ }
192
+
193
+ export interface DopeGeometry {
194
+ kind?: string;
195
+ viewBox?: [number, number, number, number];
196
+ outlines?: Record<string, DopeOutline>;
197
+ }
198
+
199
+ /** Read a named outline from a doc's geometry, or undefined. */
200
+ export function getOutline(doc: DopeDoc, name: string): DopeOutline | undefined {
201
+ return doc.geometry?.outlines?.[name];
202
+ }
203
+
204
+ /**
205
+ * The mood a doc degrades to when asked for one it doesn't declare.
206
+ *
207
+ * Each effect declares its OWN moods (the success trio declares
208
+ * serene/celebratory/electric; the fail effect declares try-again/error/denied),
209
+ * so there is no single hardcoded fallback mood that exists for every effect.
210
+ * An effect's own default is, in order of preference:
211
+ * 1. its `controls.mood.default`, if that mood actually has a baseline, else
212
+ * 2. the first mood key in its `baselines` table.
213
+ * This keeps EVERY declared mood resolvable and makes an unknown mood degrade to
214
+ * the effect's own sensible default instead of throwing on a missing baseline.
215
+ */
216
+ export function defaultMoodKey(doc: DopeDoc): string {
217
+ const controls = (doc as { controls?: { mood?: { default?: unknown } } }).controls;
218
+ const declared = controls?.mood?.default;
219
+ if (typeof declared === "string" && doc.baselines[declared]) return declared;
220
+ const first = Object.keys(doc.baselines)[0];
221
+ if (!first) throw new Error("dope: document has no baselines to resolve a mood against");
222
+ return first;
223
+ }
224
+
225
+ /**
226
+ * Resolve a requested mood name against a doc to a key the doc actually declares:
227
+ * the requested mood if it has a baseline, otherwise the doc's own default mood
228
+ * ({@link defaultMoodKey}). Returned key is used for BOTH the baseline table and
229
+ * the palette `perMood` register, so they always agree.
230
+ */
231
+ function resolveMoodKey(doc: DopeDoc, mood: string): string {
232
+ return doc.baselines[mood] ? mood : defaultMoodKey(doc);
233
+ }
234
+
235
+ interface DopePalette {
236
+ hueSpread: number;
237
+ lightness: { baseline: string; perStop: [number, number, number] };
238
+ chroma: { from: ExprNode; perStop: [number, number, number] };
239
+ /** Color register fields per mood: hueCenter, hueRange, lightness, chroma. */
240
+ perMood: Record<string, { hueCenter: number; hueRange: number; lightness: number; chroma: number }>;
241
+ }
242
+
243
+ interface DopeParamSpec {
244
+ type?: "float" | "int";
245
+ from: ExprNode;
246
+ clamp01?: boolean;
247
+ clampMax?: string;
248
+ clampMin?: string;
249
+ }
250
+
251
+ /** The mapping mini-grammar (§4.1) — an expression tree. */
252
+ export type ExprNode =
253
+ | number
254
+ | { const: number }
255
+ | { control: string }
256
+ | { baseline: string }
257
+ | { lerp: [string, number, number] }
258
+ | { mul: ExprNode[] }
259
+ | { add: ExprNode[] }
260
+ | { sub: ExprNode[] }
261
+ | { round: ExprNode }
262
+ | { floor: ExprNode }
263
+ // Extensions (§10: nodes may grow without a major bump as long as old nodes
264
+ // keep their meaning). Used by the typography tables (Phase 3) so curves whose
265
+ // endpoints are themselves expressions stay declarative.
266
+ | { mix: [ExprNode, ExprNode, string] } // a + (b-a)*clamp01(control)
267
+ | { max: ExprNode[] }
268
+ | { min: ExprNode[] };
269
+
270
+ /** Evaluation context for the grammar. */
271
+ export interface EvalCtx {
272
+ controls: Record<string, number>;
273
+ baseline: Record<string, number>;
274
+ consts: Record<string, number>;
275
+ }
276
+
277
+ /** Evaluate a grammar node to a number. Pure; matches mood.ts arithmetic. */
278
+ export function evalExpr(node: ExprNode, ctx: EvalCtx): number {
279
+ if (typeof node === "number") return node;
280
+ if ("const" in node) return node.const;
281
+ if ("control" in node) return clamp01(ctx.controls[node.control] ?? 0);
282
+ if ("baseline" in node) {
283
+ const v = ctx.baseline[node.baseline];
284
+ if (v === undefined) throw new Error(`dope: unknown baseline "${node.baseline}"`);
285
+ return v;
286
+ }
287
+ if ("lerp" in node) {
288
+ const [c, a, b] = node.lerp;
289
+ return lerp(a, b, ctx.controls[c] ?? 0);
290
+ }
291
+ if ("mul" in node) return node.mul.reduce((p: number, n) => p * evalExpr(n, ctx), 1);
292
+ if ("add" in node) return node.add.reduce((p: number, n) => p + evalExpr(n, ctx), 0);
293
+ if ("sub" in node) {
294
+ const parts: number[] = node.sub.map((n) => evalExpr(n, ctx));
295
+ return parts.slice(1).reduce((p: number, n: number) => p - n, parts[0] ?? 0);
296
+ }
297
+ if ("round" in node) return Math.round(evalExpr(node.round, ctx));
298
+ if ("floor" in node) return Math.floor(evalExpr(node.floor, ctx));
299
+ if ("mix" in node) {
300
+ const [a, b, c] = node.mix;
301
+ const va = evalExpr(a, ctx);
302
+ const vb = evalExpr(b, ctx);
303
+ return va + (vb - va) * clamp01(ctx.controls[c] ?? 0);
304
+ }
305
+ if ("max" in node) return Math.max(...node.max.map((n) => evalExpr(n, ctx)));
306
+ if ("min" in node) return Math.min(...node.min.map((n) => evalExpr(n, ctx)));
307
+ throw new Error(`dope: unknown expr node ${JSON.stringify(node)}`);
308
+ }
309
+
310
+ /** Apply a param spec's post-clamp flags. */
311
+ function applyFlags(v: number, spec: DopeParamSpec, consts: Record<string, number>): number {
312
+ if (spec.clamp01) v = clamp01(v);
313
+ if (spec.clampMax) v = Math.min(v, consts[spec.clampMax] ?? Infinity);
314
+ if (spec.clampMin) v = Math.max(v, consts[spec.clampMin] ?? -Infinity);
315
+ return v;
316
+ }
317
+
318
+ export interface DopeResolveInput {
319
+ mood: string;
320
+ intensity: number;
321
+ whimsy: number;
322
+ seed: number;
323
+ }
324
+
325
+ /**
326
+ * Resolve a `.dope` doc + a feeling into the flat render-param bag (palette,
327
+ * style, durationMs, seed, scatter seed, and every `render.params` entry). The
328
+ * `scatterKey` is the name the legacy code gave the per-fire scatter offset
329
+ * (`moteSeed` / `inkSeed` / `comicSeed`) so the output keys match exactly.
330
+ *
331
+ * RNG order (the parity anchor): baseHue via buildPalette FIRST, then the
332
+ * scatter `rng()*1000` — identical to `resolve*Params`.
333
+ */
334
+ export function resolveDopeParams(
335
+ doc: DopeDoc,
336
+ input: DopeResolveInput,
337
+ consts: Record<string, number>,
338
+ scatterKey: string,
339
+ /**
340
+ * Host theme override: three explicit OKLCH stops that REPLACE the generated
341
+ * golden-angle palette (a pinned brand palette). The base-hue rng() is still
342
+ * consumed first, so the per-fire scatter offset stays identical to the
343
+ * generated path — pinning the palette never shifts the mote/spray layout.
344
+ */
345
+ paletteOverride?: [OKLCH, OKLCH, OKLCH],
346
+ ): Record<string, number | RGB[] | number[]> {
347
+ const i = clamp01(input.intensity);
348
+ const w = clamp01(input.whimsy);
349
+ // Degrade an undeclared mood to THIS effect's own default mood (not a hardcoded
350
+ // "celebratory", which the fail effect — and any non-success effect — has no
351
+ // baseline for). Declared moods always resolve to themselves, so parity holds.
352
+ const moodKey = resolveMoodKey(doc, input.mood);
353
+ const baseline = doc.baselines[moodKey];
354
+ const rng: Rng = mulberry32(input.seed);
355
+
356
+ const ctx: EvalCtx = {
357
+ controls: { intensity: i, whimsy: w },
358
+ baseline,
359
+ consts,
360
+ };
361
+
362
+ const out: Record<string, number | RGB[] | number[]> = {
363
+ seed: input.seed,
364
+ style: w,
365
+ };
366
+
367
+ // durationMs (tempo)
368
+ if (doc.tempo.durationMs) {
369
+ out.durationMs = applyFlags(evalExpr(doc.tempo.durationMs.from, ctx), doc.tempo.durationMs, consts);
370
+ }
371
+
372
+ // render.params
373
+ for (const [name, spec] of Object.entries(doc.render.params)) {
374
+ if (name === "style") continue; // style is the raw whimsy control, set above
375
+ out[name] = applyFlags(evalExpr(spec.from, ctx), spec, consts);
376
+ }
377
+
378
+ // Palette FIRST (consumes one rng() for the base hue inside buildPalette),
379
+ // matching the engine's call order exactly.
380
+ // Same resolved key as the baseline lookup, so palette + baselines always agree
381
+ // (an undeclared mood uses the effect's own default for both).
382
+ const reg = doc.palette.perMood[moodKey] ?? doc.palette.perMood[defaultMoodKey(doc)];
383
+ const chroma = evalExpr(doc.palette.chroma.from, { ...ctx, baseline: reg as Record<string, number> });
384
+ const generated = buildPalette(rng, {
385
+ lightness: reg.lightness,
386
+ chroma,
387
+ hueCenter: reg.hueCenter,
388
+ hueRange: reg.hueRange,
389
+ hueSpread: doc.palette.hueSpread,
390
+ }) as RGB[];
391
+ // A host palette override REPLACES the generated stops (the base-hue rng() above
392
+ // was still consumed, so scatter parity holds), pinning a brand palette.
393
+ out.palette = paletteOverride ? (paletteOverride.map(oklchToLinearSrgb) as RGB[]) : generated;
394
+
395
+ // THEN the per-fire scatter offset (same rng() * 1000 as the engine).
396
+ out[scatterKey] = rng() * 1000;
397
+
398
+ return out;
399
+ }
400
+
401
+ // A `.dope` must be SELF-CONTAINED — it may inline assets (e.g. `data:` URIs) or
402
+ // reference bundled programs/assets by key or by a path RELATIVE to the package
403
+ // (resolved inside a `.dope` zip), but it must never point at the network or an
404
+ // absolute filesystem path. This keeps every effect portable and offline.
405
+ const REMOTE_REF_RE = /^(?:[a-z][a-z0-9+.-]*:)?\/\//i; // http(s)://, ftp://, //host
406
+ const ABS_PATH_RE = /^(?:\/|[A-Za-z]:[\\/])/; // /etc/..., C:\...
407
+
408
+ function assertStandalone(node: unknown, path = "$"): void {
409
+ if (typeof node === "string") {
410
+ if (REMOTE_REF_RE.test(node) || ABS_PATH_RE.test(node)) {
411
+ throw new Error(
412
+ `dope: external asset reference is not allowed — a .dope must be ` +
413
+ `self-contained (inline or bundle assets). Offending value at ${path}: "${node}"`,
414
+ );
415
+ }
416
+ return;
417
+ }
418
+ if (Array.isArray(node)) {
419
+ node.forEach((v, i) => assertStandalone(v, `${path}[${i}]`));
420
+ } else if (node && typeof node === "object") {
421
+ for (const [k, v] of Object.entries(node)) assertStandalone(v, `${path}.${k}`);
422
+ }
423
+ }
424
+
425
+ // Tolerance for the loop whole-multiple checks (the step 1000/12 is not exactly
426
+ // representable, so an exact `% === 0` would be float-fragile).
427
+ const LOOP_EPS = 1e-6;
428
+ const isWhole = (x: number): boolean => Math.abs(x - Math.round(x)) < LOOP_EPS;
429
+
430
+ /**
431
+ * Validate a `tempo.loop` block against the doc's baselines: the period must be
432
+ * positive, must tile the "animate on twos" grid (unless `snapAligned` is
433
+ * false), and every per-mood baseline `durationMs` must be a whole number of
434
+ * periods — the seam guarantee, moved from per-effect convention into the
435
+ * parser (identical on every platform).
436
+ */
437
+ function assertValidLoop(loop: DopeLoopSpec, baselines: DopeDoc["baselines"]): void {
438
+ const p = loop.periodMs;
439
+ if (typeof p !== "number" || !Number.isFinite(p) || p <= 0) {
440
+ throw new Error(`dope: tempo.loop.periodMs must be a positive number (got ${JSON.stringify(p)})`);
441
+ }
442
+ if (loop.snapAligned !== false && !isWhole(p / NPR_TIME_STEP_MS)) {
443
+ throw new Error(
444
+ `dope: tempo.loop.periodMs (${p}) is not a whole number of animate-on-twos steps (1000/12 ms)`,
445
+ );
446
+ }
447
+ for (const [mood, row] of Object.entries(baselines)) {
448
+ const d = row.durationMs;
449
+ if (d !== undefined && !isWhole(d / p)) {
450
+ throw new Error(
451
+ `dope: baselines.${mood}.durationMs (${d}) is not a whole number of tempo.loop periods (${p} ms)`,
452
+ );
453
+ }
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Parse + validate a `.dope` document from a JSON string or already-parsed
459
+ * object. Rejects a wrong/absent magic or major version, any external
460
+ * (remote / absolute-path) asset reference — a `.dope` must be self-contained —
461
+ * and a `tempo.loop` that breaks the seam invariants.
462
+ * (A fuller JSON-Schema validation lives in CI against effect-format.schema.json.)
463
+ */
464
+ export function parseDope(src: string | object): DopeDoc {
465
+ const doc = (typeof src === "string" ? JSON.parse(src) : src) as DopeDoc;
466
+ if (doc.fmt !== "dopamine-effect") {
467
+ throw new Error(`dope: not a Dopamine effect document (fmt="${(doc as DopeDoc).fmt}")`);
468
+ }
469
+ const major = Number(doc.v?.split(".")[0]);
470
+ if (!Number.isFinite(major) || major > 1) {
471
+ throw new Error(`dope: unsupported format version "${doc.v}"`);
472
+ }
473
+ if (!doc.render?.params || !doc.palette?.perMood || !doc.baselines) {
474
+ throw new Error("dope: document missing render.params / palette.perMood / baselines");
475
+ }
476
+ if (doc.tempo?.loop) assertValidLoop(doc.tempo.loop, doc.baselines);
477
+ if (doc.render?.panel) assertValidPanel(doc.render.panel, doc.binding);
478
+ assertStandalone(doc);
479
+ return doc;
480
+ }
481
+
482
+ /**
483
+ * `render.panel` invariants: the panel sampler must be one of the declared
484
+ * `binding.samplers` (the panel is a texture binding like any other — one
485
+ * source of truth for the uniform list), and the unit must be a non-negative
486
+ * integer.
487
+ */
488
+ function assertValidPanel(
489
+ panel: NonNullable<DopeDoc["render"]["panel"]>,
490
+ binding: DopeDoc["binding"],
491
+ ): void {
492
+ const samplers = (binding?.samplers ?? []).map((s) => (typeof s === "string" ? s : s.web));
493
+ if (!panel.sampler || !samplers.includes(panel.sampler)) {
494
+ throw new Error(
495
+ `dope: render.panel.sampler "${panel.sampler}" is not a declared binding.samplers entry`,
496
+ );
497
+ }
498
+ const unit = panel.texture ?? 0;
499
+ if (!Number.isInteger(unit) || unit < 0) {
500
+ throw new Error(`dope: render.panel.texture must be a non-negative integer (got ${unit})`);
501
+ }
502
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Mood registry — the shared, effect-agnostic "emotional register" layer.
3
+ *
4
+ * A mood describes a *feeling baseline* (how warm, how energetic, how bright) in
5
+ * effect-neutral terms. Every effect reads the same resolved mood, so adding a
6
+ * mood ("triumphant", "focused", a brand mood) lights up across *all* effects at
7
+ * once — no per-effect edits.
8
+ *
9
+ * Why a register, not a full param table: the three built-in effects share their
10
+ * *color identity* per mood (the same `hueCenter`/`hueRange` — a serene blue, a
11
+ * celebratory roam, a hot electric band) but each effect has its own spatial /
12
+ * motion / typographic baselines (a bloom radius means nothing to a comic word).
13
+ * So the registry owns the shared register + a normalized `energy`, and each
14
+ * effect's `mood.ts` baseline table keys off the mood name (built-ins keep their
15
+ * exact tuned values; an unfamiliar mood is derived from the register + energy
16
+ * so it still renders sensibly everywhere). This keeps built-in output
17
+ * byte-identical to the legacy `resolve*Params` while making moods extensible.
18
+ */
19
+
20
+ /** Effect-neutral description of a mood's shared color register + energy. */
21
+ export interface MoodSpec {
22
+ /** Preferred hue center in degrees (arousal rises blue→green→red). */
23
+ hueCenter: number;
24
+ /** Width of the random hue band around the center, in degrees. */
25
+ hueRange: number;
26
+ /** Perceptual lightness reference for palettes, 0..1. */
27
+ lightness: number;
28
+ /** Base chroma (colorfulness) reference for palettes, ~0..0.4. */
29
+ chroma: number;
30
+ /**
31
+ * Normalized energy 0..1 (serene → electric). Effects use this to derive a
32
+ * baseline for a mood they have no tuned table entry for (faster, denser,
33
+ * harder slams toward 1). Built-in effects ignore it for their built-in moods.
34
+ */
35
+ energy: number;
36
+ }
37
+
38
+ /** A mood resolved for use: its spec plus the name it was registered under. */
39
+ export interface ResolvedMood extends MoodSpec {
40
+ readonly name: string;
41
+ }
42
+
43
+ /**
44
+ * The three built-in moods. The register values mirror the per-mood columns
45
+ * shared by all three effects' baseline tables in `mood.ts`; `energy` orders
46
+ * them serene(0) → celebratory(0.5) → electric(1).
47
+ */
48
+ const BUILTIN_MOODS: Record<string, MoodSpec> = {
49
+ serene: { hueCenter: 230, hueRange: 120, lightness: 0.83, chroma: 0.1, energy: 0.0 },
50
+ celebratory: { hueCenter: 50, hueRange: 320, lightness: 0.81, chroma: 0.17, energy: 0.5 },
51
+ electric: { hueCenter: 35, hueRange: 150, lightness: 0.79, chroma: 0.24, energy: 1.0 },
52
+ };
53
+
54
+ const moods = new Map<string, MoodSpec>(Object.entries(BUILTIN_MOODS));
55
+
56
+ /** The mood used when none is given or an unknown one is requested. */
57
+ export const DEFAULT_MOOD = "celebratory";
58
+
59
+ /**
60
+ * Register (or override) a mood. Returns the name so it can be used inline.
61
+ *
62
+ * ```ts
63
+ * registerMood("triumphant", { hueCenter: 280, hueRange: 160,
64
+ * lightness: 0.8, chroma: 0.22, energy: 0.9 });
65
+ * await celebrate({ mood: "triumphant" }); // now works for ALL effects
66
+ * ```
67
+ */
68
+ export function registerMood(name: string, spec: MoodSpec): string {
69
+ moods.set(name, spec);
70
+ return name;
71
+ }
72
+
73
+ /** Look up a mood, falling back to the default. Always returns a usable mood. */
74
+ export function resolveMood(name: string | undefined): ResolvedMood {
75
+ const key = name && moods.has(name) ? name : DEFAULT_MOOD;
76
+ return { name: key, ...moods.get(key)! };
77
+ }
78
+
79
+ /** Whether a mood name is currently registered. */
80
+ export function hasMood(name: string): boolean {
81
+ return moods.has(name);
82
+ }
83
+
84
+ /** Names of all registered moods (built-in + custom). */
85
+ export function moodNames(): string[] {
86
+ return [...moods.keys()];
87
+ }