@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.
- package/dist/engine/color.d.ts +71 -0
- package/dist/engine/color.d.ts.map +1 -0
- package/dist/engine/color.js +107 -0
- package/dist/engine/color.js.map +1 -0
- package/dist/engine/context.d.ts +54 -0
- package/dist/engine/context.d.ts.map +1 -0
- package/dist/engine/context.js +0 -0
- package/dist/engine/context.js.map +1 -0
- package/dist/engine/gl.d.ts +9 -0
- package/dist/engine/gl.d.ts.map +1 -0
- package/dist/engine/gl.js +39 -0
- package/dist/engine/gl.js.map +1 -0
- package/dist/engine/look/glsl.d.ts +95 -0
- package/dist/engine/look/glsl.d.ts.map +1 -0
- package/dist/engine/look/glsl.js +171 -0
- package/dist/engine/look/glsl.js.map +1 -0
- package/dist/engine/look/particles.glsl.d.ts +21 -0
- package/dist/engine/look/particles.glsl.d.ts.map +1 -0
- package/dist/engine/look/particles.glsl.js +44 -0
- package/dist/engine/look/particles.glsl.js.map +1 -0
- package/dist/engine/sdf.d.ts +77 -0
- package/dist/engine/sdf.d.ts.map +1 -0
- package/dist/engine/sdf.js +255 -0
- package/dist/engine/sdf.js.map +1 -0
- package/dist/engine/seed.d.ts +10 -0
- package/dist/engine/seed.d.ts.map +1 -0
- package/dist/engine/seed.js +20 -0
- package/dist/engine/seed.js.map +1 -0
- package/dist/engine/shadow.d.ts +41 -0
- package/dist/engine/shadow.d.ts.map +1 -0
- package/dist/engine/shadow.js +39 -0
- package/dist/engine/shadow.js.map +1 -0
- package/dist/engine/tempo.d.ts +33 -0
- package/dist/engine/tempo.d.ts.map +1 -0
- package/dist/engine/tempo.js +51 -0
- package/dist/engine/tempo.js.map +1 -0
- package/dist/framework/conductor.d.ts +100 -0
- package/dist/framework/conductor.d.ts.map +1 -0
- package/dist/framework/conductor.js +493 -0
- package/dist/framework/conductor.js.map +1 -0
- package/dist/framework/content.d.ts +67 -0
- package/dist/framework/content.d.ts.map +1 -0
- package/dist/framework/content.js +72 -0
- package/dist/framework/content.js.map +1 -0
- package/dist/framework/dope-pass.d.ts +131 -0
- package/dist/framework/dope-pass.d.ts.map +1 -0
- package/dist/framework/dope-pass.js +346 -0
- package/dist/framework/dope-pass.js.map +1 -0
- package/dist/framework/dope-zip.d.ts +22 -0
- package/dist/framework/dope-zip.d.ts.map +1 -0
- package/dist/framework/dope-zip.js +116 -0
- package/dist/framework/dope-zip.js.map +1 -0
- package/dist/framework/effect.d.ts +128 -0
- package/dist/framework/effect.d.ts.map +1 -0
- package/dist/framework/effect.js +19 -0
- package/dist/framework/effect.js.map +1 -0
- package/dist/framework/frame-expr.d.ts +124 -0
- package/dist/framework/frame-expr.d.ts.map +1 -0
- package/dist/framework/frame-expr.js +135 -0
- package/dist/framework/frame-expr.js.map +1 -0
- package/dist/framework/load-effect.d.ts +77 -0
- package/dist/framework/load-effect.d.ts.map +1 -0
- package/dist/framework/load-effect.js +135 -0
- package/dist/framework/load-effect.js.map +1 -0
- package/dist/framework/loader.d.ts +309 -0
- package/dist/framework/loader.d.ts.map +1 -0
- package/dist/framework/loader.js +266 -0
- package/dist/framework/loader.js.map +1 -0
- package/dist/framework/mood-registry.d.ts +58 -0
- package/dist/framework/mood-registry.d.ts.map +1 -0
- package/dist/framework/mood-registry.js +58 -0
- package/dist/framework/mood-registry.js.map +1 -0
- package/dist/framework/panel-runner.d.ts +96 -0
- package/dist/framework/panel-runner.d.ts.map +1 -0
- package/dist/framework/panel-runner.js +137 -0
- package/dist/framework/panel-runner.js.map +1 -0
- package/dist/framework/pass-common.d.ts +97 -0
- package/dist/framework/pass-common.d.ts.map +1 -0
- package/dist/framework/pass-common.js +178 -0
- package/dist/framework/pass-common.js.map +1 -0
- package/dist/framework/pass-runner.d.ts +183 -0
- package/dist/framework/pass-runner.d.ts.map +1 -0
- package/dist/framework/pass-runner.js +212 -0
- package/dist/framework/pass-runner.js.map +1 -0
- package/dist/framework/programs.d.ts +54 -0
- package/dist/framework/programs.d.ts.map +1 -0
- package/dist/framework/programs.js +33 -0
- package/dist/framework/programs.js.map +1 -0
- package/dist/framework/registry.d.ts +29 -0
- package/dist/framework/registry.d.ts.map +1 -0
- package/dist/framework/registry.js +38 -0
- package/dist/framework/registry.js.map +1 -0
- package/dist/framework/runtime.d.ts +19 -0
- package/dist/framework/runtime.d.ts.map +1 -0
- package/dist/framework/runtime.js +37 -0
- package/dist/framework/runtime.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/overlay.d.ts +46 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +79 -0
- package/dist/overlay.js.map +1 -0
- package/dist/types.d.ts +68 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
- package/src/engine/color.ts +154 -0
- package/src/engine/context.ts +0 -0
- package/src/engine/gl.ts +46 -0
- package/src/engine/look/glsl.ts +183 -0
- package/src/engine/look/particles.glsl.ts +44 -0
- package/src/engine/sdf.ts +298 -0
- package/src/engine/seed.ts +23 -0
- package/src/engine/shadow.ts +66 -0
- package/src/engine/tempo.ts +54 -0
- package/src/framework/conductor.ts +604 -0
- package/src/framework/content.ts +113 -0
- package/src/framework/dope-pass.ts +432 -0
- package/src/framework/dope-zip.ts +125 -0
- package/src/framework/effect.ts +127 -0
- package/src/framework/frame-expr.ts +217 -0
- package/src/framework/load-effect.ts +204 -0
- package/src/framework/loader.ts +502 -0
- package/src/framework/mood-registry.ts +87 -0
- package/src/framework/panel-runner.ts +233 -0
- package/src/framework/pass-common.ts +222 -0
- package/src/framework/pass-runner.ts +391 -0
- package/src/framework/programs.ts +62 -0
- package/src/framework/registry.ts +44 -0
- package/src/framework/runtime.ts +38 -0
- package/src/index.ts +227 -0
- package/src/overlay.ts +109 -0
- 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
|
+
}
|