@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,113 @@
1
+ /**
2
+ * `.dope` CONTENT + TYPOGRAPHY consumers (Phase 3).
3
+ *
4
+ * Phase 0–2 moved an effect's numeric/palette/tempo params + its icon geometry
5
+ * into the `.dope`. Phase 3 finishes the job for the last code-shaped data:
6
+ *
7
+ * - `content.words` / `content.checkToken` — Comic's affirmation pool + the
8
+ * checkmark sentinel, picked per-fire by seed.
9
+ * - `content.glyphBands` — Solarbloom's whimsy→check-glyph (face + char) bands.
10
+ * - `typography` — Comic's mood→face baselines + the whimsy/intensity CURVE
11
+ * table (skew/stretch/tracking/outlineLayers/extrude/jitter/roundness),
12
+ * evaluated with the mapping grammar (extended with mix/max/min).
13
+ *
14
+ * These resolvers reproduce the legacy `mood.ts` arithmetic EXACTLY (the legacy
15
+ * functions stay as the parity reference, just like the numeric path), so a
16
+ * built-in's output is byte-identical while reskinning (different words, font,
17
+ * curves) becomes pure `.dope` editing.
18
+ */
19
+
20
+ import { evalExpr, type EvalCtx, type ExprNode } from "./loader.js";
21
+ import { mulberry32 } from "../engine/seed.js";
22
+
23
+ const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x);
24
+
25
+ /**
26
+ * Deterministically pick one of `list` from a seed. Matches Comic's `pickWord`:
27
+ * `mulberry32(seed>>>0)()` → index. Same seed → same pick; un-pinned scatters.
28
+ */
29
+ export function pickFromList<T>(list: readonly T[], seed: number): T {
30
+ const r = mulberry32(seed >>> 0)();
31
+ const idx = Math.min(list.length - 1, Math.floor(r * list.length));
32
+ return list[idx]!;
33
+ }
34
+
35
+ /**
36
+ * Pick a band by whimsy (0..1), splitting the slider into equal bands. Matches
37
+ * Solarbloom's `pickCheckGlyph`: `floor(w * n)` clamped to the last band.
38
+ */
39
+ export function pickBand<T>(bands: readonly T[], whimsy: number): T {
40
+ const w = clamp01(whimsy);
41
+ const idx = Math.min(bands.length - 1, Math.floor(w * bands.length));
42
+ return bands[idx]!;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // TYPOGRAPHY — the declarative table that replaces `comicTypography`.
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /** Per-mood typographic baselines (the `ComicBaseline` typographic fields). */
50
+ export interface TypographyMoodBaseline {
51
+ face: string;
52
+ skew: number;
53
+ tilt: number;
54
+ stretchX: number;
55
+ tracking: number;
56
+ roundness: number;
57
+ }
58
+
59
+ /** The `typography` section of a `.dope`. */
60
+ export interface DopeTypography {
61
+ /** Robust CSS fallback chain appended after the mood's primary face. */
62
+ fallbackStack: string;
63
+ /** Per-mood baselines, keyed by mood name. */
64
+ perMood: Record<string, TypographyMoodBaseline>;
65
+ /**
66
+ * Derived numeric fields, each an expression over `control` (intensity/whimsy)
67
+ * + `baseline` (the per-mood typographic baseline). String fields (fontStack)
68
+ * are assembled separately. clamp01/round flags mirror the param specs.
69
+ */
70
+ fields: Record<string, { from: ExprNode; clamp01?: boolean; round?: boolean }>;
71
+ }
72
+
73
+ /** Resolved typography (the numeric fields + the assembled font stack). */
74
+ export interface ResolvedTypography {
75
+ fontStack: string;
76
+ [field: string]: number | string;
77
+ }
78
+
79
+ /**
80
+ * Evaluate a typography table for a mood + intensity + whimsy. Pure; the
81
+ * `baseline` context is the per-mood typographic baseline so a field expr can
82
+ * reference e.g. `{ "baseline": "stretchX" }` or `{ "baseline": "roundness" }`.
83
+ */
84
+ export function resolveTypography(
85
+ typo: DopeTypography,
86
+ mood: string,
87
+ intensity: number,
88
+ whimsy: number,
89
+ ): ResolvedTypography {
90
+ // Degrade an undeclared mood to the first declared typography mood (not a
91
+ // hardcoded "celebratory"), matching the loader's own-default fallback.
92
+ const base = typo.perMood[mood] ?? typo.perMood[Object.keys(typo.perMood)[0]!]!;
93
+ const ctx: EvalCtx = {
94
+ controls: { intensity: clamp01(intensity), whimsy: clamp01(whimsy) },
95
+ // Only the numeric baselines are visible to the grammar.
96
+ baseline: {
97
+ skew: base.skew,
98
+ tilt: base.tilt,
99
+ stretchX: base.stretchX,
100
+ tracking: base.tracking,
101
+ roundness: base.roundness,
102
+ },
103
+ consts: {},
104
+ };
105
+ const out: ResolvedTypography = { fontStack: `${base.face}, ${typo.fallbackStack}` };
106
+ for (const [name, spec] of Object.entries(typo.fields)) {
107
+ let v = evalExpr(spec.from, ctx);
108
+ if (spec.round) v = Math.round(v);
109
+ if (spec.clamp01) v = clamp01(v);
110
+ out[name] = v;
111
+ }
112
+ return out;
113
+ }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Generic DATA-DRIVEN pass factory — instantiates a pure-shader effect from
3
+ * `(dope, shader, hooks)` with no per-effect factory code.
4
+ *
5
+ * For a datafied effect the `.dope` carries everything the hand-written
6
+ * per-effect `PassConfig` used to: the per-frame logic (`tempo.frame`), the
7
+ * shadow height (`render.shadowHeightFrac`), the per-pass uniforms
8
+ * (`render.pass`), the SDF-sourced samplers (`binding.samplers[].outline`),
9
+ * the loop-cap consts (`render.consts`), the runner config
10
+ * (`render.config.usesOrigin`), the reduced-motion peak/hold
11
+ * (`tempo.reducedMotion`) and the uniform-binding contract (`binding`). This
12
+ * module derives the `PassConfig` from that data — uniform names by the same
13
+ * `name → u<Name>` convention `computeScalarBinds` applies, exceptions from
14
+ * the binding contract — so the only hand-written web source left for such an
15
+ * effect is its GLSL.
16
+ *
17
+ * The honest boundary stays honest: anything genuinely code-shaped (a sprite
18
+ * panel, frame arrays, a host-rasterized aux texture) is passed through
19
+ * `hooks`, the same seams `PassConfig` always had — and a hook overrides the
20
+ * derived `auxTextures`/`passUniforms` when both exist.
21
+ */
22
+
23
+ import { decodeSdf } from "../engine/sdf.js";
24
+ import { evalFrameExpr, evalParamExpr, evalPassExpr, type FrameExprNode } from "./frame-expr.js";
25
+ import { getOutline, resolveDopeParams, type DopeDoc, type DopeSampler } from "./loader.js";
26
+ import { cap } from "./pass-common.js";
27
+ import {
28
+ createPassInstance,
29
+ type AuxTextureSpec,
30
+ type FrameInfo,
31
+ type PassConfig,
32
+ type PassParams,
33
+ } from "./pass-runner.js";
34
+ import { createPanelInstance, type PanelConfig, type PanelDraw } from "./panel-runner.js";
35
+ import { registerEffect } from "./registry.js";
36
+ import { registerProgram, type RenderProgram } from "./programs.js";
37
+ import type { EffectContext, EffectFactory, EffectInstance, FeelingInput } from "./effect.js";
38
+
39
+ /** The code-shaped escape hatches a datafied effect may still need. */
40
+ export type DopePassHooks = Partial<
41
+ Pick<PassConfig, "auxTextures" | "passUniforms" | "panel" | "frameArrays">
42
+ > & {
43
+ /** Additional uniform names (beyond the derived set) the shader reads. */
44
+ extraUniforms?: readonly string[];
45
+ /**
46
+ * The Canvas2D draw for a PASS effect's dynamic sprite panel (`render.panel`
47
+ * with the doc still pass-shaped — e.g. solarbloom's motes). The `.dope`
48
+ * `render.panel` block supplies the unit + sampler; this hook supplies only
49
+ * the genuinely code-shaped draw. Ignored when `hooks.panel` is given whole.
50
+ */
51
+ panelDraw?: NonNullable<PassConfig["panel"]>["draw"];
52
+ };
53
+
54
+ /** The vertex + fragment GLSL pair (the per-effect look — code by design). */
55
+ export interface DopeShader {
56
+ vertex: string;
57
+ fragment: string;
58
+ }
59
+
60
+ /**
61
+ * The shared pass/panel derivation over a datafied `.dope`: the uniform list,
62
+ * the binding exceptions, the per-frame and per-pass expression tables and the
63
+ * declared SDF pass inputs — everything both `dopePassConfig` and
64
+ * `dopePanelConfig` read identically (the rules in the {@link dopePassConfig}
65
+ * doc comment).
66
+ */
67
+ function deriveDope(doc: DopeDoc, extraUniforms?: readonly string[]) {
68
+ const binding = doc.binding ?? {};
69
+ const exclude = binding.excludeParams ?? [];
70
+ const scatterKey = binding.scatterKey ?? undefined;
71
+ const extraDefs = binding.extras ?? [];
72
+
73
+ const frameSpec = doc.tempo.frame;
74
+ if (!frameSpec) throw new Error(`dope: ${doc.id} has no tempo.frame (not a datafied effect)`);
75
+ const loopPeriodMs = doc.tempo.loop?.periodMs;
76
+ const shadowSpec = doc.render.shadowHeightFrac;
77
+ if (shadowSpec === undefined) {
78
+ throw new Error(`dope: ${doc.id} has no render.shadowHeightFrac (not a datafied effect)`);
79
+ }
80
+
81
+ // --- uniforms ------------------------------------------------------------
82
+ const uniforms = new Set<string>();
83
+ for (const name of Object.keys(doc.render.params)) {
84
+ if (exclude.includes(name) || name === scatterKey) continue;
85
+ uniforms.add(cap(name));
86
+ }
87
+ if (scatterKey && binding.scatterWeb) uniforms.add(binding.scatterWeb);
88
+ for (const e of extraDefs) if (e.web) uniforms.add(e.web);
89
+ for (const s of binding.samplers ?? []) uniforms.add(typeof s === "string" ? s : s.web);
90
+ for (const a of binding.arrays ?? []) uniforms.add(a.web);
91
+ for (const u of extraUniforms ?? []) uniforms.add(u);
92
+
93
+ // --- bindings (exceptions to the `name → u<Name>` auto-bind) --------------
94
+ const bindings: Record<string, string | null> = {};
95
+ if (scatterKey) bindings[scatterKey] = binding.scatterWeb ?? null;
96
+ for (const name of exclude) {
97
+ if (name === "style" || name === "durationMs") continue; // see doc comment
98
+ bindings[name] = null;
99
+ }
100
+
101
+ // --- per-frame extras: canonical name → web uniform name ------------------
102
+ const extraExprs: Array<[string, FrameExprNode]> = Object.entries(frameSpec.extras ?? {}).map(
103
+ ([name, expr]) => {
104
+ const def = extraDefs.find((e) => e.name === name);
105
+ if (!def?.web) {
106
+ throw new Error(`dope: ${doc.id} tempo.frame.extras."${name}" has no binding.extras web name`);
107
+ }
108
+ return [def.web, expr];
109
+ },
110
+ );
111
+
112
+ // --- per-PASS uniforms (`render.pass`): canonical name → web uniform name --
113
+ // (a "note" key is documentation, not an expression — same convention as
114
+ // `binding.note`.)
115
+ const passExprs: Array<[string, FrameExprNode]> = Object.entries(doc.render.pass ?? {})
116
+ .filter(([name]) => name !== "note")
117
+ .map(([name, expr]) => {
118
+ const def = extraDefs.find((e) => e.name === name);
119
+ if (!def?.web) {
120
+ throw new Error(`dope: ${doc.id} render.pass."${name}" has no binding.extras web name`);
121
+ }
122
+ return [def.web, expr] as [string, FrameExprNode];
123
+ });
124
+
125
+ // SDF-sourced samplers: the first `outline` sampler supplies the pass-expr
126
+ // SDF inputs (declared metadata — readable even where the bitmap isn't bound).
127
+ const samplerDefs = (binding.samplers ?? []).filter((s): s is DopeSampler => typeof s !== "string");
128
+ const sdfOutline = samplerDefs.find((s) => s.outline);
129
+ const sdfSrc = sdfOutline ? getOutline(doc, sdfOutline.outline!)?.sdf : undefined;
130
+ const sdfInputs = { range: sdfSrc?.range ?? 0, viewBoxW: sdfSrc?.viewBox?.[2] ?? 0 };
131
+
132
+ return { binding, frameSpec, loopPeriodMs, shadowSpec, uniforms, bindings, extraExprs, passExprs, sdfInputs };
133
+ }
134
+
135
+ /**
136
+ * Derive a {@link PassConfig} from a datafied `.dope` + its shader (+ optional
137
+ * code hooks). The derived contract is pinned by the per-effect dope-config
138
+ * vitests:
139
+ *
140
+ * - `uniforms`: every `render.params` key not in `binding.excludeParams` and
141
+ * not the scatter key → `u<Name>`; the scatter key contributes
142
+ * `binding.scatterWeb` when present (else it is not a shader uniform); every
143
+ * `binding.extras[].web`; every `binding.samplers[].web`; every
144
+ * `binding.arrays[].web` (the `frameArrays` uniform arrays); plus
145
+ * `hooks.extraUniforms`.
146
+ * - `bindings`: the scatter key → `scatterWeb` (or `null`), plus `null` for
147
+ * each excluded param that would otherwise auto-bind. (`style` and
148
+ * `durationMs` need no entry: `durationMs` is skipped by
149
+ * `computeScalarBinds`, and `style`'s conventional `uStyle` auto-bind is the
150
+ * same value the runner already sets as a standard uniform.)
151
+ * - `frame`: `tempo.frame.amp` + `tempo.frame.extras` evaluated per frame
152
+ * (extras keyed by canonical name, emitted under their `binding` web name).
153
+ * - `shadowHeightFrac`: `render.shadowHeightFrac` (bare number passes
154
+ * through; an expression is params-only — `{input}` throws).
155
+ * - `passUniforms`: `render.pass` evaluated ONCE PER PASS (canonical names →
156
+ * `binding` web names) over the resolved params + the pass-geometry inputs
157
+ * (`targetMinDimPx`, plus `sdfRange`/`sdfViewBoxW` from the first sampler
158
+ * with an `outline` source).
159
+ * - `auxTextures`: every `binding.samplers` entry with an `outline` whose
160
+ * baked SDF decodes → a `kind:"sdf"` spec at its `texture` unit, flipping
161
+ * its `on` extra to 1; absent/undecodable → analytic fallback.
162
+ * - `usesOrigin`: `render.config.usesOrigin`.
163
+ */
164
+ export function dopePassConfig(doc: DopeDoc, shader: DopeShader, hooks: DopePassHooks = {}): PassConfig {
165
+ const { binding, frameSpec, loopPeriodMs, shadowSpec, uniforms, bindings, extraExprs, passExprs, sdfInputs } =
166
+ deriveDope(doc, hooks.extraUniforms);
167
+
168
+ const derivedPassUniforms: PassConfig["passUniforms"] | undefined = passExprs.length
169
+ ? (_canvas, params, targetPx, dpr) => {
170
+ const pass = {
171
+ targetMinDimPx: Math.min(targetPx.width, targetPx.height),
172
+ sdfRange: sdfInputs.range,
173
+ sdfViewBoxW: sdfInputs.viewBoxW,
174
+ dpr,
175
+ };
176
+ const out: Record<string, number> = {};
177
+ for (const [web, expr] of passExprs) out[web] = evalPassExpr(expr, params, pass);
178
+ return out;
179
+ }
180
+ : undefined;
181
+
182
+ // The declarative dynamic-panel WIRING (`render.panel`): the unit + sampler
183
+ // are data; the Canvas2D draw is the code-shaped hook. (A whole `hooks.panel`
184
+ // still overrides, like the other hooks.)
185
+ const panelSpec = doc.render.panel;
186
+ const derivedPanel: PassConfig["panel"] | undefined =
187
+ panelSpec && hooks.panelDraw
188
+ ? { unit: panelSpec.texture ?? 0, sampler: panelSpec.sampler, draw: hooks.panelDraw }
189
+ : undefined;
190
+
191
+ // Declarative aux textures: each sampler with an `outline` whose baked SDF
192
+ // decodes becomes a kind:"sdf" spec (its `on` extra flips to 1 when bound);
193
+ // an absent/undecodable SDF contributes nothing — the analytic fallback.
194
+ const extraDefs = binding.extras ?? [];
195
+ const samplerDefs = (binding.samplers ?? []).filter((s): s is DopeSampler => typeof s !== "string");
196
+ const sdfAux: AuxTextureSpec[] = [];
197
+ for (const s of samplerDefs) {
198
+ if (!s.outline) continue;
199
+ const baked = getOutline(doc, s.outline)?.sdf;
200
+ if (!baked) continue;
201
+ try {
202
+ const onDef = s.on ? extraDefs.find((e) => e.name === s.on) : undefined;
203
+ sdfAux.push({
204
+ kind: "sdf",
205
+ unit: s.texture ?? 0,
206
+ sdf: decodeSdf(baked),
207
+ sampler: s.web,
208
+ onUniform: onDef?.web,
209
+ });
210
+ } catch {
211
+ /* undecodable → analytic fallback */
212
+ }
213
+ }
214
+ const derivedAux: PassConfig["auxTextures"] | undefined = sdfAux.length ? () => sdfAux : undefined;
215
+
216
+ return {
217
+ vertex: shader.vertex,
218
+ fragment: shader.fragment,
219
+ uniforms: [...uniforms],
220
+ usesOrigin: doc.render.config?.usesOrigin ?? false,
221
+ loopPeriodMs,
222
+ bindings,
223
+ shadowHeightFrac:
224
+ typeof shadowSpec === "number" ? shadowSpec : (params) => evalParamExpr(shadowSpec, params),
225
+ frame(info: FrameInfo, params: PassParams) {
226
+ // Loop clocks (0 without tempo.loop) — the SAME formula the runner uses
227
+ // for uLoopS/uPhase, so a `{input:"phase"}` amp matches the shader.
228
+ const loopMs = loopPeriodMs ? info.animMs % loopPeriodMs : 0;
229
+ const ctx = {
230
+ animMs: info.animMs,
231
+ life: info.life,
232
+ elapsedMs: info.elapsedMs,
233
+ params,
234
+ loopS: loopMs / 1000,
235
+ phase: loopPeriodMs ? loopMs / loopPeriodMs : 0,
236
+ };
237
+ const out: { amp: number } & Record<string, number> = {
238
+ amp: evalFrameExpr(frameSpec.amp, ctx),
239
+ };
240
+ for (const [web, expr] of extraExprs) out[web] = evalFrameExpr(expr, ctx);
241
+ return out;
242
+ },
243
+ auxTextures: hooks.auxTextures ?? derivedAux,
244
+ passUniforms: hooks.passUniforms ?? derivedPassUniforms,
245
+ panel: hooks.panel ?? derivedPanel,
246
+ frameArrays: hooks.frameArrays,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Derive a {@link PanelConfig} (the Canvas2D-panel runner's config) from a
252
+ * datafied PANEL `.dope` + its shader + the one genuinely code-shaped piece —
253
+ * the per-frame Canvas2D `draw`. The derivation rules are the SAME as
254
+ * {@link dopePassConfig} (uniforms / bindings / `tempo.frame` /
255
+ * `render.shadowHeightFrac` / `render.pass`), with the panel runner's shape:
256
+ *
257
+ * - `panelSampler`: `render.panel.sampler` (required; `render.panel.texture`
258
+ * must be 0 — the panel runner binds the panel at TEXTURE0, the
259
+ * cross-platform panel slot).
260
+ * - `frame`: evaluated with `animMs := elapsedMs` — the panel runner never
261
+ * snaps "on twos" (declared as `render.config.stepping: "none"`).
262
+ * - `passUniforms`: `render.pass` over the resolved params + the pass inputs
263
+ * (`targetMinDimPx` from the targeted element box, `dpr`, and the SDF
264
+ * metadata when a sampler declares an outline).
265
+ */
266
+ export function dopePanelConfig(
267
+ doc: DopeDoc,
268
+ shader: DopeShader,
269
+ draw: PanelDraw,
270
+ hooks: Pick<DopePassHooks, "extraUniforms"> = {},
271
+ ): PanelConfig {
272
+ const { frameSpec, shadowSpec, uniforms, bindings, extraExprs, passExprs, sdfInputs } = deriveDope(
273
+ doc,
274
+ hooks.extraUniforms,
275
+ );
276
+ const panelSpec = doc.render.panel;
277
+ if (!panelSpec) throw new Error(`dope: ${doc.id} has no render.panel (not a panel effect)`);
278
+ if ((panelSpec.texture ?? 0) !== 0) {
279
+ throw new Error(
280
+ `dope: ${doc.id} render.panel.texture must be 0 for a panel-kind effect (TEXTURE0 is the panel slot)`,
281
+ );
282
+ }
283
+ if (doc.tempo.loop) throw new Error(`dope: ${doc.id} tempo.loop is not supported by the panel runner`);
284
+
285
+ const derivedPassUniforms: PanelConfig["passUniforms"] | undefined = passExprs.length
286
+ ? (_canvas, params, dpr, targetPx) => {
287
+ const pass = {
288
+ targetMinDimPx: Math.min(targetPx.width, targetPx.height),
289
+ sdfRange: sdfInputs.range,
290
+ sdfViewBoxW: sdfInputs.viewBoxW,
291
+ dpr,
292
+ };
293
+ const out: Record<string, number> = {};
294
+ for (const [web, expr] of passExprs) out[web] = evalPassExpr(expr, params, pass);
295
+ return out;
296
+ }
297
+ : undefined;
298
+
299
+ return {
300
+ vertex: shader.vertex,
301
+ fragment: shader.fragment,
302
+ uniforms: [...uniforms],
303
+ panelSampler: panelSpec.sampler,
304
+ bindings,
305
+ shadowHeightFrac:
306
+ typeof shadowSpec === "number" ? shadowSpec : (params) => evalParamExpr(shadowSpec, params),
307
+ draw,
308
+ frame(info, params) {
309
+ // Panels never snap on twos (`render.config.stepping: "none"`), so the
310
+ // snapped clock IS the wall clock — `animMs := elapsedMs`.
311
+ const ctx = {
312
+ animMs: info.elapsedMs,
313
+ life: info.life,
314
+ elapsedMs: info.elapsedMs,
315
+ params,
316
+ loopS: 0,
317
+ phase: 0,
318
+ };
319
+ const out: { amp: number } & Record<string, number> = {
320
+ amp: evalFrameExpr(frameSpec.amp, ctx),
321
+ };
322
+ for (const [web, expr] of extraExprs) out[web] = evalFrameExpr(expr, ctx);
323
+ return out;
324
+ },
325
+ passUniforms: derivedPassUniforms,
326
+ };
327
+ }
328
+
329
+ /** Options for {@link registerDopeEffect}. */
330
+ export interface RegisterDopeEffectOptions {
331
+ /** Code-shaped escape hatches forwarded to {@link dopePassConfig}. */
332
+ hooks?: DopePassHooks;
333
+ /**
334
+ * The registered effect/program name. Defaults to the last segment of
335
+ * `doc.id` (e.g. `dopamine.success.aurora` → `aurora`); pass explicitly when
336
+ * the public name differs (e.g. `dopamine.success.verdict` → `inkstroke`).
337
+ */
338
+ name?: string;
339
+ /** Also register as a bundled program for `loadEffect()`. Default true. */
340
+ program?: boolean;
341
+ /**
342
+ * Compose non-numeric, code-shaped params on top of the loader bag — applied
343
+ * to the factory's `resolve` AND forwarded to the bundled program.
344
+ */
345
+ composeParams?: RenderProgram["composeParams"];
346
+ /** Override `tempo.reducedMotion`. */
347
+ reducedMotion?: { peakMs?: number; holdMs?: number };
348
+ }
349
+
350
+ /**
351
+ * Build + register a fully data-driven effect from `(dope, shader, hooks)`:
352
+ * `resolve` is the `.dope` loader (with `render.consts` and the binding's
353
+ * scatter key), `create` is the generic pass runner over the derived config,
354
+ * and `reducedMotion` comes from `tempo.reducedMotion`. Registers the
355
+ * `EffectFactory` (and, by default, a bundled program under the same name) and
356
+ * returns the factory — the whole per-effect web factory, datafied.
357
+ */
358
+ export function registerDopeEffect(
359
+ doc: DopeDoc,
360
+ shader: DopeShader,
361
+ opts: RegisterDopeEffectOptions = {},
362
+ ): EffectFactory<PassParams> {
363
+ const config = dopePassConfig(doc, shader, opts.hooks);
364
+ const create = (params: PassParams, ctx: EffectContext): EffectInstance =>
365
+ createPassInstance(config, params, ctx);
366
+ return registerDerived(doc, create, opts);
367
+ }
368
+
369
+ /**
370
+ * Build + register a data-driven Canvas2D-PANEL effect from
371
+ * `(dope, shader, draw)`: the same registration as {@link registerDopeEffect}
372
+ * but `create` is the generic PANEL runner over the {@link dopePanelConfig}
373
+ * derivation. The only hand-written web sources left for such an effect are
374
+ * its GLSL and the Canvas2D `draw` — the per-platform panel-draw seam.
375
+ */
376
+ export function registerDopePanelEffect(
377
+ doc: DopeDoc,
378
+ shader: DopeShader,
379
+ draw: PanelDraw,
380
+ opts: RegisterDopeEffectOptions = {},
381
+ ): EffectFactory<PassParams> {
382
+ const config = dopePanelConfig(doc, shader, draw, { extraUniforms: opts.hooks?.extraUniforms });
383
+ const create = (params: PassParams, ctx: EffectContext): EffectInstance =>
384
+ createPanelInstance(config, params, ctx);
385
+ return registerDerived(doc, create, opts);
386
+ }
387
+
388
+ /** The shared registration tail: resolve/reducedMotion/loop + the bundled program. */
389
+ function registerDerived(
390
+ doc: DopeDoc,
391
+ create: (params: PassParams, ctx: EffectContext) => EffectInstance,
392
+ opts: RegisterDopeEffectOptions,
393
+ ): EffectFactory<PassParams> {
394
+ const scatterKey = doc.binding?.scatterKey;
395
+ if (!scatterKey) throw new Error(`dope: ${doc.id} has no binding.scatterKey`);
396
+ const consts = doc.render.consts ?? {};
397
+ const name = opts.name ?? doc.id.split(".").pop()!;
398
+ const reducedMotion = opts.reducedMotion ?? doc.tempo.reducedMotion;
399
+
400
+ const factory: EffectFactory<PassParams> = {
401
+ name,
402
+ resolve: (feeling: FeelingInput) => {
403
+ const numeric = resolveDopeParams(doc, feeling, consts, scatterKey);
404
+ return (
405
+ opts.composeParams
406
+ ? opts.composeParams(
407
+ numeric as Record<string, unknown>,
408
+ feeling as { mood: string; intensity: number; whimsy: number; seed: number },
409
+ )
410
+ : numeric
411
+ ) as unknown as PassParams;
412
+ },
413
+ create,
414
+ reducedMotion,
415
+ // The continuous-loop contract: the conductor re-arms at durationMs instead
416
+ // of tearing down (the host stops it via the returned handle).
417
+ loop: doc.tempo.loop ? { periodMs: doc.tempo.loop.periodMs } : undefined,
418
+ };
419
+
420
+ if (opts.program !== false) {
421
+ // Expose as a bundled program so loadEffect() can bind host-authored
422
+ // variants of this effect's .dope with no code.
423
+ registerProgram<PassParams>(name, {
424
+ create,
425
+ scatterKey,
426
+ consts,
427
+ reducedMotion,
428
+ ...(opts.composeParams ? { composeParams: opts.composeParams } : {}),
429
+ });
430
+ }
431
+ return registerEffect(factory);
432
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Minimal `.dope` zip (dotLottie-style) reader.
3
+ *
4
+ * A distributed `.dope` may be a zip: a `manifest.json` naming the effect doc
5
+ * plus an `assets/` dir referenced by RELATIVE paths only. This reads the zip
6
+ * entries (STORED or DEFLATE), finds the effect JSON via the manifest (or the
7
+ * first `*.json` under `effects/`), and resolves any relative asset `$ref`s in
8
+ * `geometry.outlines.*` by inlining them as `data:` URIs — so the doc handed to
9
+ * the loader is fully self-contained. Remote/absolute refs are rejected (the
10
+ * loader's standalone guard would reject them anyway; we fail early + clearly).
11
+ *
12
+ * Dependency-free: STORED entries are read directly; DEFLATE entries use the
13
+ * platform `DecompressionStream` when present (browsers + Node ≥18). We don't
14
+ * bundle a zlib.
15
+ */
16
+
17
+ interface ZipEntry {
18
+ name: string;
19
+ method: number;
20
+ data: Uint8Array; // raw (possibly compressed) bytes
21
+ }
22
+
23
+ function u16(b: Uint8Array, o: number): number {
24
+ return b[o]! | (b[o + 1]! << 8);
25
+ }
26
+ function u32(b: Uint8Array, o: number): number {
27
+ return (b[o]! | (b[o + 1]! << 8) | (b[o + 2]! << 16) | (b[o + 3]! << 24)) >>> 0;
28
+ }
29
+
30
+ /** Parse the local-file-header records of a zip into raw entries. */
31
+ function parseZip(buf: Uint8Array): ZipEntry[] {
32
+ const entries: ZipEntry[] = [];
33
+ const dec = new TextDecoder();
34
+ let i = 0;
35
+ while (i + 4 <= buf.length) {
36
+ const sig = u32(buf, i);
37
+ if (sig !== 0x04034b50) break; // local file header; central dir / EOCD follow
38
+ const method = u16(buf, i + 8);
39
+ const compSize = u32(buf, i + 18);
40
+ const nameLen = u16(buf, i + 26);
41
+ const extraLen = u16(buf, i + 28);
42
+ const nameStart = i + 30;
43
+ const name = dec.decode(buf.subarray(nameStart, nameStart + nameLen));
44
+ const dataStart = nameStart + nameLen + extraLen;
45
+ const data = buf.subarray(dataStart, dataStart + compSize);
46
+ entries.push({ name, method, data });
47
+ i = dataStart + compSize;
48
+ }
49
+ if (entries.length === 0) throw new Error("dope: not a zip (no local file headers)");
50
+ return entries;
51
+ }
52
+
53
+ async function inflate(entry: ZipEntry): Promise<Uint8Array> {
54
+ if (entry.method === 0) return entry.data.slice(); // STORED
55
+ if (entry.method === 8) {
56
+ // raw DEFLATE
57
+ if (typeof (globalThis as { DecompressionStream?: unknown }).DecompressionStream === "undefined") {
58
+ throw new Error("dope: DEFLATE zip entry but no DecompressionStream available");
59
+ }
60
+ const DS = (globalThis as unknown as { DecompressionStream: new (f: string) => unknown })
61
+ .DecompressionStream;
62
+ const ds = new DS("deflate-raw") as unknown as TransformStream<Uint8Array, Uint8Array>;
63
+ // Copy into a standalone ArrayBuffer-backed view for the Blob part.
64
+ const part = new Uint8Array(entry.data.length);
65
+ part.set(entry.data);
66
+ const stream = new Response(new Blob([part]).stream().pipeThrough(ds));
67
+ return new Uint8Array(await stream.arrayBuffer());
68
+ }
69
+ throw new Error(`dope: unsupported zip compression method ${entry.method}`);
70
+ }
71
+
72
+ const ABS_OR_REMOTE = /^(?:[a-z][a-z0-9+.-]*:)?\/\/|^\//i;
73
+
74
+ /**
75
+ * Read a `.dope` zip → the fully-inlined effect document (a parsed object).
76
+ * Resolves the effect JSON via manifest.json, then inlines relative `$ref`/asset
77
+ * paths under geometry/outlines from the zip's `assets/`.
78
+ */
79
+ export async function readDopeZip(buf: Uint8Array): Promise<object> {
80
+ const entries = parseZip(buf);
81
+ const files = new Map<string, ZipEntry>();
82
+ for (const e of entries) files.set(e.name.replace(/^\.?\//, ""), e);
83
+
84
+ const textOf = async (name: string): Promise<string> => {
85
+ const e = files.get(name);
86
+ if (!e) throw new Error(`dope zip: missing entry "${name}"`);
87
+ return new TextDecoder().decode(await inflate(e));
88
+ };
89
+
90
+ // Locate the effect doc.
91
+ let effectPath: string | undefined;
92
+ if (files.has("manifest.json")) {
93
+ const manifest = JSON.parse(await textOf("manifest.json")) as {
94
+ effects?: { path?: string }[];
95
+ };
96
+ effectPath = manifest.effects?.[0]?.path?.replace(/^\.?\//, "");
97
+ }
98
+ if (!effectPath) {
99
+ effectPath = [...files.keys()].find((n) => /^effects\/.+\.json$/.test(n)) ?? "effect.json";
100
+ }
101
+ const doc = JSON.parse(await textOf(effectPath)) as Record<string, unknown>;
102
+
103
+ // Inline relative asset refs in geometry.outlines.*.sdf when stored as a path.
104
+ const geo = doc.geometry as { outlines?: Record<string, Record<string, unknown>> } | undefined;
105
+ if (geo?.outlines) {
106
+ for (const outline of Object.values(geo.outlines)) {
107
+ const sdf = outline.sdf as { data?: string } | undefined;
108
+ const ref = (sdf?.data ?? outline.sdfRef) as string | undefined;
109
+ if (typeof ref === "string" && !ref.startsWith("data:")) {
110
+ if (ABS_OR_REMOTE.test(ref)) {
111
+ throw new Error(`dope zip: outline asset must be a relative path, got "${ref}"`);
112
+ }
113
+ const e = files.get(ref.replace(/^\.?\//, ""));
114
+ if (!e) throw new Error(`dope zip: missing asset "${ref}"`);
115
+ const bytes = await inflate(e);
116
+ const b64 =
117
+ typeof Buffer !== "undefined"
118
+ ? Buffer.from(bytes).toString("base64")
119
+ : btoa(String.fromCharCode(...bytes));
120
+ if (sdf) sdf.data = `data:application/octet-stream;base64,${b64}`;
121
+ }
122
+ }
123
+ }
124
+ return doc;
125
+ }