@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,127 @@
1
+ /**
2
+ * The Dopamine effect framework — the backbone every visual effect plugs into.
3
+ *
4
+ * An *effect* (Solarbloom, Calligraphic Verdict, Comic Impact, and future
5
+ * progress / error / attention effects) is a self-contained module that knows
6
+ * two things:
7
+ *
8
+ * 1. how to turn the human-facing "feeling" knobs (mood / intensity / whimsy)
9
+ * plus a seed into its own concrete, deterministic render parameters, and
10
+ * 2. how to draw a single frame at an arbitrary `elapsedMs` into a shared GPU
11
+ * surface — both the light pass and (if present) the multiply shadow pass.
12
+ *
13
+ * Effects never create the DOM overlay, the GL context, or the RAF loop — the
14
+ * runtime (the conductor) owns all of that. That separation is what lets a new
15
+ * effect be a small file that self-registers, and keeps the library
16
+ * tree-shakeable: importing one effect pulls in nothing from the others.
17
+ */
18
+
19
+ import type { GLContext } from "../engine/context.js";
20
+ import type { ResolvedMood } from "./mood-registry.js";
21
+
22
+ /** Origin/anchor in CSS pixels, relative to the render surface's top-left. */
23
+ export interface Anchor {
24
+ x: number;
25
+ y: number;
26
+ }
27
+
28
+ /**
29
+ * The "feeling" API, shared by every effect. Individual effects map these onto
30
+ * their own low-level parameters via {@link EffectFactory.resolve}. This is the
31
+ * deliberate seam between *what a developer expresses* (a feeling) and *what the
32
+ * shader consumes* (numbers).
33
+ */
34
+ export interface FeelingInput {
35
+ /** Emotional register — a registered mood name. */
36
+ mood: string;
37
+ /** 0..1 — arousal/valence: saturation, brightness, scale, overshoot. */
38
+ intensity: number;
39
+ /** 0..1 — stylization: photoreal (0) ↔ cel / hand-drawn "animate on twos" (1). */
40
+ whimsy: number;
41
+ /** Deterministic seed for the algorithmic color + motion. */
42
+ seed: number;
43
+ }
44
+
45
+ /**
46
+ * Everything an effect instance needs to draw, supplied by the runtime. The
47
+ * effect draws its light pass into `light` and, when `shadow` is non-null, its
48
+ * occlusion silhouette into `shadow` (a separate `mix-blend-mode: multiply`
49
+ * canvas + context). Both contexts are persistent + program-cached.
50
+ */
51
+ export interface EffectContext {
52
+ /** Shared WebGL2 light context (`screen` blend) + program cache. */
53
+ readonly light: GLContext;
54
+ /** Shared WebGL2 shadow context (`multiply` blend), or null if disabled. */
55
+ readonly shadow: GLContext | null;
56
+ /** Where the effect is anchored, in CSS px relative to the surface. */
57
+ readonly anchor: Anchor;
58
+ /**
59
+ * Size (CSS px) of the underlying element the effect targets, centred on
60
+ * {@link anchor}. The centrepiece (checkmark, ✗, comic word, hero heart, ink
61
+ * gesture) is sized to THIS box so it matches the page element. Omitted ⇒ the
62
+ * full render surface (the centrepiece fills the canvas, as before).
63
+ */
64
+ readonly targetSize?: { width: number; height: number };
65
+ /** Device-pixel ratio to render at (already capped by the runtime). */
66
+ readonly dpr: number;
67
+ /**
68
+ * Present when the host composites against a known backdrop colour rather than
69
+ * the default `mix-blend-mode: screen`. The runners then emit PREMULTIPLIED
70
+ * light (alpha = brightness) on the light pass so the effect stays visible on
71
+ * any surface — white included — composited source-over. Absent ⇒ the classic
72
+ * screen/opaque path (byte-identical to before).
73
+ */
74
+ readonly composite?: { premultiplied: boolean };
75
+ }
76
+
77
+ /** A live, drawable effect. Pure function of time: same `elapsedMs` → same frame. */
78
+ export interface EffectInstance {
79
+ /** Total length in ms after which the effect has fully played out. */
80
+ readonly durationMs: number;
81
+ /** Draw the frame at `elapsedMs` since the effect started. */
82
+ renderAt(elapsedMs: number): void;
83
+ /** Release any per-instance GPU resources (not shared/cached ones). */
84
+ dispose(): void;
85
+ }
86
+
87
+ /**
88
+ * The contract a new effect implements. `Params` is the effect's private,
89
+ * fully-resolved parameter shape — opaque to the runtime.
90
+ */
91
+ export interface EffectFactory<Params = unknown> {
92
+ /** Stable, unique id, e.g. `"solarbloom"`. Used by the registry + API. */
93
+ readonly name: string;
94
+ /**
95
+ * Map the shared feeling knobs + a resolved mood into this effect's own
96
+ * deterministic params. Pure — no DOM, no GL, no randomness beyond the seed.
97
+ */
98
+ resolve(feeling: FeelingInput, mood: ResolvedMood): Params;
99
+ /** Build a drawable instance for the given resolved params + context. */
100
+ create(params: Params, ctx: EffectContext): EffectInstance;
101
+ /**
102
+ * Whether this effect wants a shadow (multiply) companion canvas. Defaults to
103
+ * true; an effect that casts no shadow can opt out to skip the second context.
104
+ */
105
+ readonly castsShadow?: boolean;
106
+ /**
107
+ * Optional reduced-motion handling: which `elapsedMs` of the timeline best
108
+ * represents a calm peak, and how long to hold that single static frame
109
+ * instead of animating. Sensible defaults are used if omitted.
110
+ */
111
+ readonly reducedMotion?: {
112
+ /** How long to hold the minimal frame, ms. Default 360. */
113
+ holdMs?: number;
114
+ /** Which `elapsedMs` of the full timeline best represents a calm peak. */
115
+ peakMs?: number;
116
+ };
117
+ /**
118
+ * CONTINUOUS-loop contract (from `tempo.loop`): the effect repeats seamlessly
119
+ * with this period and `durationMs` is a whole number of periods. The
120
+ * conductor re-arms it at `durationMs` instead of tearing down — the host
121
+ * stops it via the handle `play()` returns. Absent for one-shot effects.
122
+ */
123
+ readonly loop?: { periodMs: number };
124
+ }
125
+
126
+ /** Public alias: an `Effect` is what you register and play by name. */
127
+ export type Effect<Params = unknown> = EffectFactory<Params>;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Per-FRAME expression evaluator — the datafied form of an effect's `frame()` /
3
+ * `shadowHeightFrac` logic hooks.
4
+ *
5
+ * The resolve-time grammar (`loader.ts` `evalExpr`) maps a feeling into the
6
+ * resolved param bag ONCE per fire. This module is its per-frame sibling: it
7
+ * evaluates the `.dope` `tempo.frame` / `render.shadowHeightFrac` expression
8
+ * trees EVERY frame against the live clocks (`animMs` / `life` / `elapsedMs`,
9
+ * plus the `loopS` / `phase` loop clocks for effects with `tempo.loop`) and
10
+ * the resolved params — so the per-frame logic, like the resolve mapping,
11
+ * is authored once in the `.dope` and interpreted identically on every
12
+ * platform. The same grammar also powers the PER-PASS `render.pass`
13
+ * expressions ({@link evalPassExpr}): params plus the pass-geometry inputs
14
+ * (`targetMinDimPx` / `sdfRange` / `sdfViewBoxW`), with the frame clocks
15
+ * rejected — pass values are computed once per pass, not per frame.
16
+ *
17
+ * Like `evalExpr`, nodes are evaluated RAW (no decode step) and anything
18
+ * outside the grammar THROWS. The tempo primitives (`envelope`, `easeOutBack`,
19
+ * `easeOutCubic`, `clamp01`) are the SAME functions the hand-written hooks
20
+ * called (imported from `engine/tempo.ts`), so a datafied effect's output is
21
+ * bit-identical to the code it replaced.
22
+ */
23
+
24
+ import { clamp01, easeOutBack, easeOutCubic, envelope } from "../engine/tempo.js";
25
+
26
+ /** The per-frame expression grammar — an expression tree over the frame ctx. */
27
+ export type FrameExprNode =
28
+ | number
29
+ | { const: number }
30
+ | { param: string }
31
+ | {
32
+ input:
33
+ | "animMs"
34
+ | "life"
35
+ | "elapsedMs"
36
+ | "loopS"
37
+ | "phase"
38
+ // Pass-geometry inputs — only valid in a `render.pass` expression
39
+ // (evalPassExpr); the frame/params modes reject them.
40
+ | "targetMinDimPx"
41
+ | "sdfRange"
42
+ | "sdfViewBoxW"
43
+ | "dpr";
44
+ }
45
+ | { add: FrameExprNode[] }
46
+ | { sub: FrameExprNode[] }
47
+ | { mul: FrameExprNode[] }
48
+ | { div: FrameExprNode[] }
49
+ | { min: FrameExprNode[] }
50
+ | { max: FrameExprNode[] }
51
+ | { pow: [FrameExprNode, FrameExprNode] }
52
+ | { sin: FrameExprNode }
53
+ | { cos: FrameExprNode }
54
+ | { exp: FrameExprNode }
55
+ | { clamp01: FrameExprNode }
56
+ | { lt: [FrameExprNode, FrameExprNode, FrameExprNode, FrameExprNode] }
57
+ | { envelope: [FrameExprNode, FrameExprNode] }
58
+ | { easeOutCubic: FrameExprNode }
59
+ | { easeOutBack: [FrameExprNode, FrameExprNode] };
60
+
61
+ /** Evaluation context for a per-frame expression. */
62
+ export interface FrameExprCtx {
63
+ /** The "on twos"-snapped animation clock in ms (stepping already applied). */
64
+ animMs: number;
65
+ /** Normalized life 0..1 (animMs / durationMs, clamped). */
66
+ life: number;
67
+ /** The REAL un-stepped wall clock in ms (mirrors the Swift/Android runners). */
68
+ elapsedMs: number;
69
+ /** The resolved render-param bag (numeric entries are addressable). */
70
+ params: Record<string, unknown>;
71
+ /**
72
+ * Seconds within the current loop (`(animMs % tempo.loop.periodMs) / 1000`).
73
+ * 0 for an effect with no `tempo.loop` — the caller (the dope-pass frame
74
+ * derivation) fills these from the doc's loop contract.
75
+ */
76
+ loopS?: number;
77
+ /** Normalized loop phase in [0, 1) (`animMs % periodMs / periodMs`); 0 without a loop. */
78
+ phase?: number;
79
+ /** Pass-geometry inputs (see {@link PassExprInputs}); only read in "pass" mode. */
80
+ pass?: PassExprInputs;
81
+ }
82
+
83
+ /**
84
+ * The pass-geometry inputs a `render.pass` expression may read (evaluated ONCE
85
+ * per pass by the runners, never per resolve or per frame).
86
+ */
87
+ export interface PassExprInputs {
88
+ /**
89
+ * Min dimension of the TARGETED element box in device px, falling back to
90
+ * the full canvas when untargeted — the same target-fallback the standard
91
+ * `uTarget` uniform uses, so a pass-sized centrepiece tracks the element.
92
+ */
93
+ targetMinDimPx: number;
94
+ /**
95
+ * The declared `range` of the SDF behind the first `binding.samplers` entry
96
+ * with an `outline` source (author units → the full byte range); 0 when no
97
+ * sampler declares one.
98
+ */
99
+ sdfRange: number;
100
+ /** That SDF's `viewBox[2]` (author-units width); 0 when absent. */
101
+ sdfViewBoxW: number;
102
+ /**
103
+ * The device-pixel ratio (web `devicePixelRatio` / Android `density` / the
104
+ * Metal layer's content scale) the surface renders at — so a pass value can
105
+ * be expressed in CSS-ish units and scaled to device px (e.g. heartburst's
106
+ * halftone cell `uDotSize = dotSize · dpr`).
107
+ */
108
+ dpr: number;
109
+ }
110
+
111
+ /** Which inputs an expression may read: the three evaluation entry points. */
112
+ type ExprMode = "frame" | "params" | "pass";
113
+
114
+ const FRAME_INPUTS = ["animMs", "life", "elapsedMs", "loopS", "phase"] as const;
115
+ const PASS_INPUTS = ["targetMinDimPx", "sdfRange", "sdfViewBoxW", "dpr"] as const;
116
+
117
+ function evalInput(name: string, ctx: FrameExprCtx, mode: ExprMode): number {
118
+ const isFrame = (FRAME_INPUTS as readonly string[]).includes(name);
119
+ const isPass = (PASS_INPUTS as readonly string[]).includes(name);
120
+ if (mode === "pass") {
121
+ if (isFrame) {
122
+ throw new Error(
123
+ `dope: frame input "${name}" is not allowed in a render.pass expression (pass expressions are not frame-clocked)`,
124
+ );
125
+ }
126
+ if (isPass) return ctx.pass?.[name as keyof PassExprInputs] ?? 0;
127
+ throw new Error(`dope: unknown frame input "${name}"`);
128
+ }
129
+ if (isPass) {
130
+ throw new Error(`dope: pass input "${name}" is only allowed in a render.pass expression`);
131
+ }
132
+ if (mode === "params") {
133
+ throw new Error(`dope: {input} is not allowed in a params-only expression (got "${name}")`);
134
+ }
135
+ if (name === "animMs") return ctx.animMs;
136
+ if (name === "life") return ctx.life;
137
+ if (name === "elapsedMs") return ctx.elapsedMs;
138
+ if (name === "loopS") return ctx.loopS ?? 0;
139
+ if (name === "phase") return ctx.phase ?? 0;
140
+ throw new Error(`dope: unknown frame input "${name}"`);
141
+ }
142
+
143
+ function evalNode(node: FrameExprNode, ctx: FrameExprCtx, mode: ExprMode): number {
144
+ if (typeof node === "number") return node;
145
+ if ("const" in node) return node.const;
146
+ if ("param" in node) {
147
+ const raw = ctx.params[node.param];
148
+ if (typeof raw !== "number") {
149
+ throw new Error(`dope: frame expr references missing/non-numeric param "${node.param}"`);
150
+ }
151
+ return Number(raw);
152
+ }
153
+ if ("input" in node) return evalInput(String(node.input), ctx, mode);
154
+ if ("add" in node) return node.add.reduce((p: number, n) => p + evalNode(n, ctx, mode), 0);
155
+ if ("sub" in node) {
156
+ const parts: number[] = node.sub.map((n) => evalNode(n, ctx, mode));
157
+ return parts.slice(1).reduce((p: number, n: number) => p - n, parts[0] ?? 0);
158
+ }
159
+ if ("mul" in node) return node.mul.reduce((p: number, n) => p * evalNode(n, ctx, mode), 1);
160
+ if ("div" in node) {
161
+ const parts: number[] = node.div.map((n) => evalNode(n, ctx, mode));
162
+ return parts.slice(1).reduce((p: number, n: number) => p / n, parts[0] ?? 0);
163
+ }
164
+ if ("min" in node) return Math.min(...node.min.map((n) => evalNode(n, ctx, mode)));
165
+ if ("max" in node) return Math.max(...node.max.map((n) => evalNode(n, ctx, mode)));
166
+ if ("pow" in node) {
167
+ return Math.pow(evalNode(node.pow[0], ctx, mode), evalNode(node.pow[1], ctx, mode));
168
+ }
169
+ if ("sin" in node) return Math.sin(evalNode(node.sin, ctx, mode));
170
+ if ("cos" in node) return Math.cos(evalNode(node.cos, ctx, mode));
171
+ if ("exp" in node) return Math.exp(evalNode(node.exp, ctx, mode));
172
+ if ("clamp01" in node) return clamp01(evalNode(node.clamp01, ctx, mode));
173
+ if ("lt" in node) {
174
+ // Branches are evaluated LAZILY (only the taken branch), so a guard like
175
+ // `0 < elapsedMs ? f(elapsedMs) : 0` never evaluates f outside its domain.
176
+ const [a, b, then, otherwise] = node.lt;
177
+ return evalNode(a, ctx, mode) < evalNode(b, ctx, mode)
178
+ ? evalNode(then, ctx, mode)
179
+ : evalNode(otherwise, ctx, mode);
180
+ }
181
+ if ("envelope" in node) {
182
+ return envelope(evalNode(node.envelope[0], ctx, mode), evalNode(node.envelope[1], ctx, mode));
183
+ }
184
+ if ("easeOutCubic" in node) return easeOutCubic(evalNode(node.easeOutCubic, ctx, mode));
185
+ if ("easeOutBack" in node) {
186
+ return easeOutBack(evalNode(node.easeOutBack[0], ctx, mode), evalNode(node.easeOutBack[1], ctx, mode));
187
+ }
188
+ throw new Error(`dope: unknown frame expr node ${JSON.stringify(node)}`);
189
+ }
190
+
191
+ /** Evaluate a per-frame grammar node to a number. Pure; throws outside the grammar. */
192
+ export function evalFrameExpr(node: FrameExprNode, ctx: FrameExprCtx): number {
193
+ return evalNode(node, ctx, "frame");
194
+ }
195
+
196
+ /**
197
+ * Evaluate a PARAMS-ONLY expression (e.g. `render.shadowHeightFrac`): the same
198
+ * grammar, but `{input}` nodes THROW — a shadow-geometry expression must be a
199
+ * pure function of the resolved params, never of the frame clock.
200
+ */
201
+ export function evalParamExpr(node: FrameExprNode, params: Record<string, unknown>): number {
202
+ return evalNode(node, { animMs: 0, life: 0, elapsedMs: 0, params }, "params");
203
+ }
204
+
205
+ /**
206
+ * Evaluate a PER-PASS expression (`render.pass`): the same grammar over the
207
+ * resolved params plus the pass-geometry inputs (`targetMinDimPx` / `sdfRange`
208
+ * / `sdfViewBoxW`). Frame clocks (`animMs` / `life` / …) THROW — a pass
209
+ * expression is evaluated once per pass, not per frame.
210
+ */
211
+ export function evalPassExpr(
212
+ node: FrameExprNode,
213
+ params: Record<string, unknown>,
214
+ pass: PassExprInputs,
215
+ ): number {
216
+ return evalNode(node, { animMs: 0, life: 0, elapsedMs: 0, params, pass }, "pass");
217
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * `loadEffect()` — the public, no-code entry point for arbitrary `.dope` effects.
3
+ *
4
+ * A host drops in a `.dope` (a parsed object, a JSON string, or a `.dope` zip),
5
+ * optionally patches it (clamp control ranges, pin a brand palette, swap an
6
+ * outline path), and gets back a registered `EffectFactory` playable via
7
+ * `play()` / `prepare()`. The effect binds to a BUNDLED render program
8
+ * (framework/programs.ts) referenced by the doc's
9
+ * `render.backends.webgl2.shader.program` key — the format carries data + a
10
+ * program key; the runtime owns the GLSL. No new shader/renderer code is needed
11
+ * to ship a recolored / re-iconed / retimed variant of a bundled effect.
12
+ *
13
+ * Overrides are a shallow JSON-pointer-style patch applied to the parsed doc,
14
+ * then the merged doc is RE-VALIDATED (parseDope: magic/version + the
15
+ * standalone guard) so a host can't push the effect into an invalid or
16
+ * non-self-contained state. Swapped outline paths are re-baked to an SDF here so
17
+ * the runtime still only samples.
18
+ */
19
+
20
+ import { parseDope, type DopeDoc, type DopeOutline } from "./loader.js";
21
+ import type { OKLCH } from "../engine/color.js";
22
+ import { bakeSdf } from "../engine/sdf.js";
23
+ import { getProgram, programNames } from "./programs.js";
24
+ import { registerEffect } from "./registry.js";
25
+ import { resolveDopeParams } from "./loader.js";
26
+ import type { EffectContext, EffectFactory, FeelingInput } from "./effect.js";
27
+
28
+ /** A control descriptor as it appears in a `.dope` `controls` block. */
29
+ interface ControlDesc {
30
+ type?: string;
31
+ min?: number;
32
+ max?: number;
33
+ default?: number | null;
34
+ [k: string]: unknown;
35
+ }
36
+
37
+ /** Host customization patch — all no-code from the host's POV (docs §9.1). */
38
+ export interface LoadOverrides {
39
+ /**
40
+ * Clamp/retune a control's range or default, by control name. The loader
41
+ * re-validates that default ∈ [min, max] after merging.
42
+ * e.g. `{ intensity: { max: 0.8, default: 0.6 } }`.
43
+ */
44
+ controls?: Record<string, { min?: number; max?: number; default?: number | null }>;
45
+ /**
46
+ * THEME: replace the generated palette with three explicit OKLCH brand stops
47
+ * (the base-hue rng is still consumed, so per-fire scatter is unchanged), OR
48
+ * pin `seed` to lock the generated palette. `palette` wins over `seed`.
49
+ */
50
+ palette?: [OKLCH, OKLCH, OKLCH];
51
+ /** THEME: pin the seed so the generated palette reproduces every fire. */
52
+ seed?: number;
53
+ /**
54
+ * RESKIN: swap an outline's SVG path by outline name; re-baked to an SDF here.
55
+ * e.g. `{ checkmark: "M5 55 L40 88 L95 8" }`.
56
+ */
57
+ outlines?: Record<string, string>;
58
+ }
59
+
60
+ export interface LoadEffectOptions {
61
+ /** Register the effect under this name (default: the doc's `id`). */
62
+ name?: string;
63
+ /** Host customization patch (§9.1). */
64
+ overrides?: LoadOverrides;
65
+ /** SDF bake resolution for swapped outlines (default 64). */
66
+ sdfSize?: number;
67
+ /** SDF bake distance range in author units for swapped outlines (default 18). */
68
+ sdfRange?: number;
69
+ }
70
+
71
+ /** A loaded, registered effect ready to fire. */
72
+ export interface LoadedEffect {
73
+ /** The registered name (use with `play(name, …)`). */
74
+ readonly name: string;
75
+ /** The registered factory. */
76
+ readonly factory: EffectFactory;
77
+ /** The merged, validated `.dope` document. */
78
+ readonly doc: DopeDoc;
79
+ }
80
+
81
+ const REMOTE_RE = /^(?:[a-z][a-z0-9+.-]*:)?\/\//i;
82
+
83
+ /** Apply control range/default overrides, validating default ∈ [min, max]. */
84
+ function applyControlOverrides(doc: DopeDoc, overrides: NonNullable<LoadOverrides["controls"]>): void {
85
+ const controls = (doc as { controls?: Record<string, ControlDesc> }).controls;
86
+ if (!controls) throw new Error("dope: cannot override controls — doc has no `controls` block");
87
+ for (const [name, patch] of Object.entries(overrides)) {
88
+ const c = controls[name];
89
+ if (!c) throw new Error(`dope: cannot override unknown control "${name}"`);
90
+ if (patch.min !== undefined) c.min = patch.min;
91
+ if (patch.max !== undefined) c.max = patch.max;
92
+ if (patch.default !== undefined) c.default = patch.default;
93
+ const lo = typeof c.min === "number" ? c.min : -Infinity;
94
+ const hi = typeof c.max === "number" ? c.max : Infinity;
95
+ if (typeof c.min === "number" && typeof c.max === "number" && c.min > c.max) {
96
+ throw new Error(`dope: control "${name}" override has min > max`);
97
+ }
98
+ if (typeof c.default === "number" && (c.default < lo || c.default > hi)) {
99
+ throw new Error(`dope: control "${name}" default ${c.default} out of range [${lo}, ${hi}]`);
100
+ }
101
+ }
102
+ }
103
+
104
+ /** Swap outline svgPaths and re-bake their SDFs (the runtime still only samples). */
105
+ function applyOutlineOverrides(
106
+ doc: DopeDoc,
107
+ outlines: NonNullable<LoadOverrides["outlines"]>,
108
+ size: number,
109
+ range: number,
110
+ ): void {
111
+ const geo = doc.geometry;
112
+ if (!geo?.outlines) throw new Error("dope: cannot swap outline — doc has no `geometry.outlines`");
113
+ const viewBox = geo.viewBox ?? [0, 0, 100, 100];
114
+ for (const [name, svgPath] of Object.entries(outlines)) {
115
+ const o: DopeOutline | undefined = geo.outlines[name];
116
+ if (!o) throw new Error(`dope: cannot swap unknown outline "${name}"`);
117
+ if (REMOTE_RE.test(svgPath) || svgPath.trim().startsWith("/")) {
118
+ throw new Error(`dope: swapped outline path must be a self-contained svgPath, not a ref`);
119
+ }
120
+ o.svgPath = svgPath;
121
+ o.sdf = bakeSdf(svgPath, viewBox, size, range);
122
+ o.source = "baked-sdf";
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Parse + (optionally) patch a `.dope`, bind it to its bundled render program,
128
+ * register it, and return a playable factory. The merged doc is re-validated.
129
+ */
130
+ export function loadEffectSync(
131
+ src: string | object,
132
+ opts: LoadEffectOptions = {},
133
+ ): LoadedEffect {
134
+ // Parse + validate the base doc first (rejects remote/absolute refs).
135
+ let doc = parseDope(src);
136
+ // Deep-clone so overrides never mutate a caller's object (and so re-bakes land
137
+ // on a private copy).
138
+ doc = JSON.parse(JSON.stringify(doc)) as DopeDoc;
139
+
140
+ const ov = opts.overrides ?? {};
141
+ if (ov.controls) applyControlOverrides(doc, ov.controls);
142
+ if (ov.outlines) {
143
+ applyOutlineOverrides(doc, ov.outlines, opts.sdfSize ?? 64, opts.sdfRange ?? 18);
144
+ }
145
+
146
+ // Re-validate the merged doc (magic/version + standalone guard). A swapped
147
+ // outline that smuggled a remote/absolute ref is rejected here.
148
+ doc = parseDope(doc);
149
+
150
+ // Resolve the bundled render program the doc references.
151
+ const backends = (doc.render.backends ?? {}) as Record<string, { shader?: { program?: string } }>;
152
+ const programKey = backends.webgl2?.shader?.program;
153
+ if (!programKey) {
154
+ throw new Error("dope: render.backends.webgl2.shader.program is required for loadEffect");
155
+ }
156
+ const program = getProgram(programKey);
157
+ if (!program) {
158
+ throw new Error(
159
+ `dope: unknown render program "${programKey}". Known: ${programNames().join(", ") || "import the effect that registers it"}`,
160
+ );
161
+ }
162
+
163
+ const name = opts.name ?? doc.id;
164
+ const seed = ov.seed;
165
+ const paletteOverride = ov.palette;
166
+
167
+ const factory: EffectFactory = {
168
+ name,
169
+ castsShadow: program.castsShadow,
170
+ reducedMotion: program.reducedMotion,
171
+ resolve(feeling: FeelingInput) {
172
+ // A pinned override seed wins over the per-fire seed (locks the palette).
173
+ const f = { ...feeling, seed: seed ?? feeling.seed };
174
+ const numeric = resolveDopeParams(doc, f, program.consts, program.scatterKey, paletteOverride);
175
+ return program.composeParams
176
+ ? program.composeParams(numeric as Record<string, unknown>, f)
177
+ : numeric;
178
+ },
179
+ create(params, ctx: EffectContext) {
180
+ return program.create(params as Record<string, unknown>, ctx);
181
+ },
182
+ };
183
+
184
+ registerEffect(factory);
185
+ return { name, factory, doc };
186
+ }
187
+
188
+ /**
189
+ * Public async `loadEffect`. Accepts a parsed doc, a JSON string, or a `.dope`
190
+ * zip (Uint8Array/ArrayBuffer/Blob). Resolves to the registered, playable effect.
191
+ */
192
+ export async function loadEffect(
193
+ src: string | object | Uint8Array | ArrayBuffer | Blob,
194
+ opts: LoadEffectOptions = {},
195
+ ): Promise<LoadedEffect> {
196
+ if (src instanceof Blob) src = new Uint8Array(await src.arrayBuffer());
197
+ if (src instanceof ArrayBuffer) src = new Uint8Array(src);
198
+ if (src instanceof Uint8Array) {
199
+ const { readDopeZip } = await import("./dope-zip.js");
200
+ const json = await readDopeZip(src);
201
+ return loadEffectSync(json, opts);
202
+ }
203
+ return loadEffectSync(src, opts);
204
+ }