@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,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conductor — the single runtime that owns everything an effect must NOT:
|
|
3
|
+
* the overlay canvases (light + shadow), their shared WebGL contexts (+ program
|
|
4
|
+
* caches), the RAF loop, device-pixel-ratio + resize handling, document
|
|
5
|
+
* visibility pausing, and the reduced-motion fallback.
|
|
6
|
+
*
|
|
7
|
+
* Key design choices:
|
|
8
|
+
* - **One persistent host per target.** A target (usually `document.body`) gets
|
|
9
|
+
* a single overlay (light + shadow canvas) + two GL contexts that live until
|
|
10
|
+
* explicitly torn down. Firing N effects reuses that one overlay and those
|
|
11
|
+
* contexts — so the expensive shader LINK happens once per page, not per fire,
|
|
12
|
+
* and we never leak the per-fire WebGL contexts that browsers cap at ~16.
|
|
13
|
+
* - **Concurrent effects.** Many effects can play at once on the same host. The
|
|
14
|
+
* conductor clears each canvas once per frame and composites the active
|
|
15
|
+
* effects: the light canvas blends ADDITIVELY (`ONE, ONE`) so layers sum as
|
|
16
|
+
* light (matching the `screen` overlay); the shadow canvas blends with `MIN`
|
|
17
|
+
* so the darkest occlusion wins (a faithful single-effect identity, a sane
|
|
18
|
+
* stack for many). For a single effect both reduce to exactly the legacy
|
|
19
|
+
* output (additive over black == replace; min over white == replace).
|
|
20
|
+
* - **Frame budgeting.** A single RAF drives every active effect; when nothing
|
|
21
|
+
* is active the loop stops (no idle RAF churn). Hidden tabs skip the GPU work
|
|
22
|
+
* but keep timing so effects still resolve.
|
|
23
|
+
* - **Reduced motion.** When the user prefers reduced motion, an effect renders
|
|
24
|
+
* a single calm frame held briefly instead of the full animation.
|
|
25
|
+
* - **SSR-safe.** Every browser global is reached through `runtime.ts`; off-DOM,
|
|
26
|
+
* `play()` resolves immediately and `prepare()` returns null.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createGLContext, type GLContext } from "../engine/context.js";
|
|
30
|
+
import { createOverlay, type Overlay } from "../overlay.js";
|
|
31
|
+
import type { Anchor, EffectContext, EffectFactory, FeelingInput } from "./effect.js";
|
|
32
|
+
import { resolveMood } from "./mood-registry.js";
|
|
33
|
+
import {
|
|
34
|
+
deviceDpr,
|
|
35
|
+
isBrowser,
|
|
36
|
+
isDocumentHidden,
|
|
37
|
+
prefersReducedMotion,
|
|
38
|
+
} from "./runtime.js";
|
|
39
|
+
|
|
40
|
+
interface ActiveEffect {
|
|
41
|
+
renderAt(elapsedMs: number): void;
|
|
42
|
+
dispose(): void;
|
|
43
|
+
startedAt: number;
|
|
44
|
+
durationMs: number;
|
|
45
|
+
resolve: () => void;
|
|
46
|
+
/** CONTINUOUS effect: re-arm at durationMs instead of tearing down. */
|
|
47
|
+
loop: boolean;
|
|
48
|
+
/** Set by the play handle's `stop()`; the next frame disposes + resolves. */
|
|
49
|
+
stopRequested: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Wall-clock time (`performance.now()`) at which the effect was paused, or 0
|
|
52
|
+
* while running. A paused effect's timeline is frozen — the frame loop neither
|
|
53
|
+
* advances its clock nor draws it — and `resume()` shifts `startedAt` forward
|
|
54
|
+
* by the paused span, so the clock (and a loop's seam) resumes exactly where
|
|
55
|
+
* it left off: drift-free, no battery spent on a paused background loop.
|
|
56
|
+
*/
|
|
57
|
+
pausedAt: number;
|
|
58
|
+
/**
|
|
59
|
+
* `true` when the pause was triggered automatically by the document going
|
|
60
|
+
* hidden (the visibility/idle economics), not by an explicit `handle.pause()`.
|
|
61
|
+
* Auto-pause is lifted only when the document becomes visible again — so a
|
|
62
|
+
* manual `pause()` survives a tab hide/show, and a manual `resume()` does not
|
|
63
|
+
* defeat the host's idle policy.
|
|
64
|
+
*/
|
|
65
|
+
autoPaused: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Backdrop-aware compositing for a host. When set, the host composites against a
|
|
70
|
+
* known surface colour: the light canvas uses premultiplied source-over
|
|
71
|
+
* (`mix-blend-mode: normal`) instead of `screen` — so the effect stays visible
|
|
72
|
+
* on any surface, white included — and the multiply shadow is strengthened as
|
|
73
|
+
* the surface lightens (where the source-over light reads faintest). `null` is
|
|
74
|
+
* the classic dark path (screen light + multiply shadow, byte-identical).
|
|
75
|
+
*/
|
|
76
|
+
export interface CompositeMode {
|
|
77
|
+
/** Backdrop relative luminance 0 (black) .. 1 (white). */
|
|
78
|
+
luminance: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A persistent overlay + GL contexts + RAF loop bound to one target element. */
|
|
82
|
+
interface Host {
|
|
83
|
+
overlay: Overlay;
|
|
84
|
+
light: GLContext;
|
|
85
|
+
shadow: GLContext | null;
|
|
86
|
+
dpr: number;
|
|
87
|
+
active: Set<ActiveEffect>;
|
|
88
|
+
raf: number;
|
|
89
|
+
resize: () => void;
|
|
90
|
+
/** Backdrop compositing, or null for the classic screen/multiply path. */
|
|
91
|
+
composite: CompositeMode | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const hosts = new Map<HTMLElement, Host>();
|
|
95
|
+
|
|
96
|
+
// Cap the overlay's drawing-buffer area so a heavy fullscreen effect (confetti,
|
|
97
|
+
// lightning, solarbloom — large per-fragment loops) doesn't pay for millions of
|
|
98
|
+
// retina pixels on a big viewport. Past the budget the EFFECTIVE dpr is scaled
|
|
99
|
+
// down — the buffer shrinks and the browser upscales the canvas (CSS size
|
|
100
|
+
// unchanged), which is imperceptible for soft glow but a big fill-cost win. Small
|
|
101
|
+
// surfaces (phones) stay at full dpr. The SAME effective dpr drives the effect's
|
|
102
|
+
// coordinate math (ctx.dpr), so origin/resolution stay consistent.
|
|
103
|
+
const MAX_OVERLAY_PIXELS = 2_000_000;
|
|
104
|
+
|
|
105
|
+
function effectiveDpr(c: HTMLCanvasElement): number {
|
|
106
|
+
const dpr = deviceDpr();
|
|
107
|
+
const cw = Math.max(1, c.clientWidth);
|
|
108
|
+
const ch = Math.max(1, c.clientHeight);
|
|
109
|
+
const px = cw * ch * dpr * dpr;
|
|
110
|
+
return px > MAX_OVERLAY_PIXELS ? Math.max(1, dpr * Math.sqrt(MAX_OVERLAY_PIXELS / px)) : dpr;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function syncCanvasSize(c: HTMLCanvasElement, dpr: number): void {
|
|
114
|
+
const w = Math.max(1, Math.round(c.clientWidth * dpr));
|
|
115
|
+
const h = Math.max(1, Math.round(c.clientHeight * dpr));
|
|
116
|
+
if (c.width !== w || c.height !== h) {
|
|
117
|
+
c.width = w;
|
|
118
|
+
c.height = h;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function syncHostSize(host: Host): void {
|
|
123
|
+
syncCanvasSize(host.light.canvas, host.dpr);
|
|
124
|
+
if (host.shadow) syncCanvasSize(host.shadow.canvas, host.dpr);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getHost(target: HTMLElement, wantShadow: boolean, composite: CompositeMode | null): Host {
|
|
128
|
+
let host = hosts.get(target);
|
|
129
|
+
if (host) {
|
|
130
|
+
// The light canvas's premultiplied-alpha buffer is a context-CREATION
|
|
131
|
+
// attribute, so flipping between the screen path and the backdrop path means
|
|
132
|
+
// rebuilding the host. (A backdrop COLOUR change within the same mode just
|
|
133
|
+
// updates the luminance-driven styles below — no rebuild.) Switching modes
|
|
134
|
+
// is a deliberate host-level action (a theme toggle), so dropping the warm
|
|
135
|
+
// contexts + any in-flight effect is acceptable and rare.
|
|
136
|
+
if (!!host.composite !== !!composite) {
|
|
137
|
+
teardown(target);
|
|
138
|
+
host = undefined;
|
|
139
|
+
} else {
|
|
140
|
+
// A later effect may need a shadow canvas the host wasn't created with.
|
|
141
|
+
if (wantShadow && !host.shadow) {
|
|
142
|
+
const shadowCanvas = host.overlay.ensureShadow();
|
|
143
|
+
host.shadow = createGLContext(shadowCanvas);
|
|
144
|
+
}
|
|
145
|
+
host.composite = composite;
|
|
146
|
+
applyCompositeStyles(host);
|
|
147
|
+
return host;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const overlay = createOverlay(target, { shadow: wantShadow });
|
|
152
|
+
const light = createGLContext(overlay.canvas, { premultiplied: !!composite });
|
|
153
|
+
const shadow = overlay.shadow ? createGLContext(overlay.shadow) : null;
|
|
154
|
+
const h: Host = {
|
|
155
|
+
overlay,
|
|
156
|
+
light,
|
|
157
|
+
shadow,
|
|
158
|
+
dpr: deviceDpr(),
|
|
159
|
+
active: new Set(),
|
|
160
|
+
raf: 0,
|
|
161
|
+
resize: () => {},
|
|
162
|
+
composite,
|
|
163
|
+
};
|
|
164
|
+
h.resize = () => {
|
|
165
|
+
h.dpr = effectiveDpr(h.light.canvas);
|
|
166
|
+
syncHostSize(h);
|
|
167
|
+
};
|
|
168
|
+
h.dpr = effectiveDpr(overlay.canvas);
|
|
169
|
+
syncHostSize(h);
|
|
170
|
+
applyCompositeStyles(h);
|
|
171
|
+
window.addEventListener("resize", h.resize);
|
|
172
|
+
hosts.set(target, h);
|
|
173
|
+
return h;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Clear the light canvas + arm additive blending. The default screen path
|
|
178
|
+
* clears to OPAQUE black (screen identity); the backdrop path clears to
|
|
179
|
+
* TRANSPARENT black so the premultiplied light composites source-over (dark
|
|
180
|
+
* regions stay transparent, the page shows through). Both accumulate concurrent
|
|
181
|
+
* effects additively (`ONE, ONE`).
|
|
182
|
+
*/
|
|
183
|
+
function beginLight(host: Host): void {
|
|
184
|
+
const { gl, canvas } = host.light;
|
|
185
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
186
|
+
gl.clearColor(0, 0, 0, host.composite ? 0 : 1);
|
|
187
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
188
|
+
gl.enable(gl.BLEND);
|
|
189
|
+
gl.blendEquation(gl.FUNC_ADD);
|
|
190
|
+
gl.blendFunc(gl.ONE, gl.ONE);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reflect the host's compositing mode onto the canvas CSS: the light layer
|
|
197
|
+
* blends `normal` (source-over) in backdrop mode vs `screen` by default, and
|
|
198
|
+
* the shadow layer's opacity ramps with backdrop luminance (a near-black
|
|
199
|
+
* surface hides multiply shadow anyway, while a light surface leans on it for
|
|
200
|
+
* depth). In the default path the styles are left exactly as the overlay set
|
|
201
|
+
* them (screen + full-strength shadow), so nothing changes.
|
|
202
|
+
*/
|
|
203
|
+
function applyCompositeStyles(host: Host): void {
|
|
204
|
+
host.light.canvas.style.mixBlendMode = host.composite ? "normal" : "screen";
|
|
205
|
+
if (host.shadow) {
|
|
206
|
+
host.shadow.canvas.style.opacity = host.composite
|
|
207
|
+
? String(clamp01(0.4 + 0.6 * host.composite.luminance))
|
|
208
|
+
: "";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Clear the shadow canvas to white (multiply identity) + arm MIN blending. */
|
|
213
|
+
function beginShadow(host: Host): void {
|
|
214
|
+
if (!host.shadow) return;
|
|
215
|
+
const { gl, canvas } = host.shadow;
|
|
216
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
217
|
+
gl.clearColor(1, 1, 1, 1);
|
|
218
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
219
|
+
gl.enable(gl.BLEND);
|
|
220
|
+
gl.blendEquation(gl.MIN);
|
|
221
|
+
gl.blendFunc(gl.ONE, gl.ONE);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Clear both canvases (no draw) — used to wipe the last held frame on idle. */
|
|
225
|
+
function clearHost(host: Host): void {
|
|
226
|
+
beginLight(host);
|
|
227
|
+
beginShadow(host);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Stop the RAF loop and clear the last frame when a host goes idle, but KEEP the
|
|
232
|
+
* overlay + contexts (and thus the compiled-program caches) alive for the page's
|
|
233
|
+
* lifetime. Re-firing reuses everything — the expensive shader link happens once
|
|
234
|
+
* per page, not once per fire.
|
|
235
|
+
*/
|
|
236
|
+
function quiesce(host: Host): void {
|
|
237
|
+
if (host.raf) {
|
|
238
|
+
cancelAnimationFrame(host.raf);
|
|
239
|
+
host.raf = 0;
|
|
240
|
+
}
|
|
241
|
+
if (!isDocumentHidden()) {
|
|
242
|
+
syncHostSize(host);
|
|
243
|
+
clearHost(host);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ensureLoop(host: Host): void {
|
|
248
|
+
if (host.raf) return;
|
|
249
|
+
const frame = (now: number): void => {
|
|
250
|
+
host.raf = 0;
|
|
251
|
+
if (host.active.size === 0) return;
|
|
252
|
+
// Visibility economics: a perpetual loop (or any effect) in a hidden tab is
|
|
253
|
+
// AUTO-PAUSED — its timeline freezes so it costs no battery in a long-lived
|
|
254
|
+
// background view — and resumes drift-free when the tab is shown again.
|
|
255
|
+
if (isDocumentHidden()) autoPauseHidden(host);
|
|
256
|
+
const hidden = isDocumentHidden();
|
|
257
|
+
if (!hidden) {
|
|
258
|
+
syncHostSize(host);
|
|
259
|
+
beginLight(host);
|
|
260
|
+
beginShadow(host);
|
|
261
|
+
}
|
|
262
|
+
for (const fx of [...host.active]) {
|
|
263
|
+
if (fx.stopRequested) {
|
|
264
|
+
host.active.delete(fx);
|
|
265
|
+
fx.dispose();
|
|
266
|
+
fx.resolve();
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// A paused effect (manual pause() or the hidden-tab auto-pause) holds: its
|
|
270
|
+
// clock does not advance and it is not redrawn. The last drawn frame stays
|
|
271
|
+
// on screen until resume() (or a stop/teardown).
|
|
272
|
+
if (fx.pausedAt) continue;
|
|
273
|
+
const elapsed = now - fx.startedAt;
|
|
274
|
+
// Skip the (invisible) draw on hidden tabs, but keep the timeline moving.
|
|
275
|
+
if (!hidden) fx.renderAt(Math.min(elapsed, fx.durationMs));
|
|
276
|
+
if (elapsed >= fx.durationMs) {
|
|
277
|
+
if (fx.loop) {
|
|
278
|
+
// CONTINUOUS effect: re-arm at the seam instead of tearing down. The
|
|
279
|
+
// .dope loop contract guarantees t == durationMs renders as t == 0, so
|
|
280
|
+
// advancing startedAt by whole durations (several at once if frames
|
|
281
|
+
// stalled, e.g. a backgrounded tab) is seamless and drift-free.
|
|
282
|
+
fx.startedAt += Math.floor(elapsed / fx.durationMs) * fx.durationMs;
|
|
283
|
+
} else {
|
|
284
|
+
host.active.delete(fx);
|
|
285
|
+
fx.dispose();
|
|
286
|
+
fx.resolve();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (host.active.size === 0) {
|
|
291
|
+
quiesce(host);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// When EVERY live effect is paused there is nothing to advance or draw, so
|
|
295
|
+
// we stop spinning the RAF (no idle churn). The pause's resume() — or, for
|
|
296
|
+
// the hidden-tab auto-pause, the visibilitychange listener — re-arms it.
|
|
297
|
+
if (allPaused(host)) {
|
|
298
|
+
if (host.raf) {
|
|
299
|
+
cancelAnimationFrame(host.raf);
|
|
300
|
+
host.raf = 0;
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
host.raf = requestAnimationFrame(frame);
|
|
305
|
+
};
|
|
306
|
+
host.raf = requestAnimationFrame(frame);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** True when every live effect on the host is paused (manual or auto). */
|
|
310
|
+
function allPaused(host: Host): boolean {
|
|
311
|
+
for (const fx of host.active) if (!fx.pausedAt) return false;
|
|
312
|
+
return host.active.size > 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Pause an effect: freeze its timeline at `now` so it neither advances nor
|
|
317
|
+
* draws. `auto` marks a hidden-tab auto-pause (lifted only by the tab becoming
|
|
318
|
+
* visible). A re-pause keeps the original `pausedAt` so the frozen clock is
|
|
319
|
+
* stable; `auto` only ever escalates (a manual pause that later goes hidden
|
|
320
|
+
* stays manual; an auto-pause that the host later pauses manually becomes
|
|
321
|
+
* manual, so showing the tab won't auto-resume it).
|
|
322
|
+
*/
|
|
323
|
+
function pauseEffect(fx: ActiveEffect, now: number, auto: boolean): void {
|
|
324
|
+
if (!fx.pausedAt) fx.pausedAt = now;
|
|
325
|
+
if (!auto) fx.autoPaused = false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resume a paused effect: shift `startedAt` forward by the span it was paused,
|
|
330
|
+
* so its clock continues exactly where it froze (drift-free; a loop's seam is
|
|
331
|
+
* preserved). No-op if it isn't paused.
|
|
332
|
+
*/
|
|
333
|
+
function resumeEffect(fx: ActiveEffect, now: number): void {
|
|
334
|
+
if (!fx.pausedAt) return;
|
|
335
|
+
fx.startedAt += now - fx.pausedAt;
|
|
336
|
+
fx.pausedAt = 0;
|
|
337
|
+
fx.autoPaused = false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Auto-pause every running effect on a host because the document went hidden. */
|
|
341
|
+
function autoPauseHidden(host: Host): void {
|
|
342
|
+
const now = performance.now();
|
|
343
|
+
for (const fx of host.active) {
|
|
344
|
+
if (!fx.pausedAt) {
|
|
345
|
+
fx.pausedAt = now;
|
|
346
|
+
fx.autoPaused = true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Auto-resume the effects a hidden-tab auto-pause paused (the tab is visible). */
|
|
352
|
+
function autoResumeVisible(host: Host): void {
|
|
353
|
+
const now = performance.now();
|
|
354
|
+
let any = false;
|
|
355
|
+
for (const fx of host.active) {
|
|
356
|
+
if (fx.pausedAt && fx.autoPaused) {
|
|
357
|
+
resumeEffect(fx, now);
|
|
358
|
+
any = true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (any) ensureLoop(host);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Install ONE document-level `visibilitychange` listener that auto-resumes
|
|
366
|
+
* every host when the tab is shown again (the hide path is handled in-loop, so
|
|
367
|
+
* a tab that's already hidden when an effect fires is covered too). Idempotent.
|
|
368
|
+
*/
|
|
369
|
+
let visibilityListenerInstalled = false;
|
|
370
|
+
function ensureVisibilityListener(): void {
|
|
371
|
+
if (visibilityListenerInstalled || typeof document === "undefined") return;
|
|
372
|
+
visibilityListenerInstalled = true;
|
|
373
|
+
document.addEventListener("visibilitychange", () => {
|
|
374
|
+
if (isDocumentHidden()) return;
|
|
375
|
+
for (const host of hosts.values()) autoResumeVisible(host);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Fully release the persistent host(s): cancel RAF, drop the GL contexts, remove
|
|
381
|
+
* the overlay. Called rarely (test teardown, an SPA route that wants a hard
|
|
382
|
+
* reset, offline capture between effects). With no arg, tears down every host.
|
|
383
|
+
*/
|
|
384
|
+
export function teardown(target?: HTMLElement): void {
|
|
385
|
+
const release = (t: HTMLElement, host: Host): void => {
|
|
386
|
+
if (host.raf) cancelAnimationFrame(host.raf);
|
|
387
|
+
for (const fx of host.active) {
|
|
388
|
+
fx.dispose();
|
|
389
|
+
fx.resolve();
|
|
390
|
+
}
|
|
391
|
+
host.active.clear();
|
|
392
|
+
if (typeof window !== "undefined") {
|
|
393
|
+
window.removeEventListener("resize", host.resize);
|
|
394
|
+
}
|
|
395
|
+
host.light.destroy();
|
|
396
|
+
host.shadow?.destroy();
|
|
397
|
+
host.overlay.destroy();
|
|
398
|
+
hosts.delete(t);
|
|
399
|
+
};
|
|
400
|
+
if (target) {
|
|
401
|
+
const host = hosts.get(target);
|
|
402
|
+
if (host) release(target, host);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
for (const [t, host] of [...hosts]) release(t, host);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function buildEffectContext(
|
|
409
|
+
host: Host,
|
|
410
|
+
anchor: Anchor,
|
|
411
|
+
targetSize?: { width: number; height: number },
|
|
412
|
+
): EffectContext {
|
|
413
|
+
return {
|
|
414
|
+
light: host.light,
|
|
415
|
+
shadow: host.shadow,
|
|
416
|
+
anchor,
|
|
417
|
+
targetSize,
|
|
418
|
+
get dpr() {
|
|
419
|
+
return host.dpr;
|
|
420
|
+
},
|
|
421
|
+
composite: host.composite ? { premultiplied: true } : undefined,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface PlayRequest {
|
|
426
|
+
factory: EffectFactory;
|
|
427
|
+
target: HTMLElement;
|
|
428
|
+
/** Anchor in CSS px relative to the *target's* box (overlay-local). */
|
|
429
|
+
anchor: Anchor;
|
|
430
|
+
/** Targeted element size (CSS px); the centrepiece is sized to this box. */
|
|
431
|
+
targetSize?: { width: number; height: number };
|
|
432
|
+
feeling: FeelingInput;
|
|
433
|
+
/**
|
|
434
|
+
* Backdrop-aware compositing for this fire (from the public `backdrop`
|
|
435
|
+
* option). `null`/omitted ⇒ the classic screen/multiply path. All effects on
|
|
436
|
+
* one target share a compositing mode; a fire that changes it rebuilds the host.
|
|
437
|
+
*/
|
|
438
|
+
composite?: CompositeMode | null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* What `play()` returns: awaitable as before (resolves when the effect has
|
|
443
|
+
* fully played out — or, for a CONTINUOUS effect, when the host stops it), plus
|
|
444
|
+
* lifecycle controls. `stop()` ends it (a one-shot early; an already-finished
|
|
445
|
+
* effect: a no-op). `pause()`/`resume()` FREEZE and RESUME the timeline — for a
|
|
446
|
+
* perpetual loop in a long-lived view, pausing parks it so it costs no battery,
|
|
447
|
+
* and resuming continues drift-free (the loop seam is preserved). All three are
|
|
448
|
+
* no-ops off-DOM or once the effect has resolved.
|
|
449
|
+
*/
|
|
450
|
+
export type PlayHandle = Promise<void> & {
|
|
451
|
+
stop(): void;
|
|
452
|
+
pause(): void;
|
|
453
|
+
resume(): void;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const resolvedHandle = (): PlayHandle =>
|
|
457
|
+
Object.assign(Promise.resolve(), { stop() {}, pause() {}, resume() {} });
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Play an effect in real time. Resolves when it has fully played out. A
|
|
461
|
+
* CONTINUOUS effect (`factory.loop`) instead re-arms at every `durationMs`
|
|
462
|
+
* seam and plays until the host calls the returned handle's `stop()`. The host
|
|
463
|
+
* (overlay + contexts + loop) is created lazily and kept warm for reuse when
|
|
464
|
+
* idle; the RAF loop stops between fires. Call {@link teardown} to release it.
|
|
465
|
+
*/
|
|
466
|
+
export function play(req: PlayRequest): PlayHandle {
|
|
467
|
+
if (!isBrowser()) return resolvedHandle();
|
|
468
|
+
|
|
469
|
+
const wantShadow = req.factory.castsShadow !== false;
|
|
470
|
+
const host = getHost(req.target, wantShadow, req.composite ?? null);
|
|
471
|
+
const mood = resolveMood(req.feeling.mood);
|
|
472
|
+
const params = req.factory.resolve(req.feeling, mood);
|
|
473
|
+
|
|
474
|
+
let instance;
|
|
475
|
+
try {
|
|
476
|
+
instance = req.factory.create(params, buildEffectContext(host, req.anchor, req.targetSize));
|
|
477
|
+
} catch (err) {
|
|
478
|
+
if (host.active.size === 0) quiesce(host);
|
|
479
|
+
return Object.assign(Promise.reject(err), { stop() {}, pause() {}, resume() {} });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Reduced motion: draw one calm frame, hold briefly (a looping effect holds
|
|
483
|
+
// until stopped — it never animates, let alone loops), done.
|
|
484
|
+
if (prefersReducedMotion()) {
|
|
485
|
+
return playReduced(host, instance, req.factory);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let resolve!: () => void;
|
|
489
|
+
const done = new Promise<void>((res) => (resolve = res));
|
|
490
|
+
const fx: ActiveEffect = {
|
|
491
|
+
renderAt: (ms) => instance.renderAt(ms),
|
|
492
|
+
dispose: () => instance.dispose(),
|
|
493
|
+
startedAt: performance.now(),
|
|
494
|
+
durationMs: instance.durationMs,
|
|
495
|
+
resolve,
|
|
496
|
+
loop: !!req.factory.loop,
|
|
497
|
+
stopRequested: false,
|
|
498
|
+
pausedAt: 0,
|
|
499
|
+
autoPaused: false,
|
|
500
|
+
};
|
|
501
|
+
host.active.add(fx);
|
|
502
|
+
ensureVisibilityListener();
|
|
503
|
+
ensureLoop(host);
|
|
504
|
+
return Object.assign(done, {
|
|
505
|
+
stop: () => {
|
|
506
|
+
fx.stopRequested = true;
|
|
507
|
+
// Stopping a paused effect must still wake the loop so the next frame
|
|
508
|
+
// disposes + resolves it (the loop is parked while everything's paused).
|
|
509
|
+
fx.pausedAt = 0;
|
|
510
|
+
ensureLoop(host);
|
|
511
|
+
},
|
|
512
|
+
pause: () => pauseEffect(fx, performance.now(), false),
|
|
513
|
+
resume: () => {
|
|
514
|
+
resumeEffect(fx, performance.now());
|
|
515
|
+
ensureLoop(host);
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function playReduced(
|
|
521
|
+
host: Host,
|
|
522
|
+
instance: { renderAt(ms: number): void; dispose(): void; durationMs: number },
|
|
523
|
+
factory: EffectFactory,
|
|
524
|
+
): PlayHandle {
|
|
525
|
+
const rm = factory.reducedMotion ?? {};
|
|
526
|
+
const peakMs = rm.peakMs ?? Math.min(260, instance.durationMs * 0.18);
|
|
527
|
+
const holdMs = rm.holdMs ?? 360;
|
|
528
|
+
if (!isDocumentHidden()) {
|
|
529
|
+
syncHostSize(host);
|
|
530
|
+
beginLight(host);
|
|
531
|
+
beginShadow(host);
|
|
532
|
+
instance.renderAt(peakMs);
|
|
533
|
+
}
|
|
534
|
+
let resolve!: () => void;
|
|
535
|
+
const done = new Promise<void>((res) => (resolve = res));
|
|
536
|
+
let finished = false;
|
|
537
|
+
const finish = (): void => {
|
|
538
|
+
if (finished) return;
|
|
539
|
+
finished = true;
|
|
540
|
+
instance.dispose();
|
|
541
|
+
// Clear the held frame; keep the (reusable) host alive but quiesced.
|
|
542
|
+
if (host.active.size === 0) quiesce(host);
|
|
543
|
+
resolve();
|
|
544
|
+
};
|
|
545
|
+
// A CONTINUOUS effect's calm frame holds until the host stops it (the
|
|
546
|
+
// reduced-motion analog of the loop); a one-shot's holds for holdMs.
|
|
547
|
+
if (!factory.loop) setTimeout(finish, holdMs);
|
|
548
|
+
// Reduced motion renders one static held frame (no animation, no loop), so
|
|
549
|
+
// pause/resume have nothing to freeze — they are no-ops, but present so the
|
|
550
|
+
// handle's shape is identical to the animated path.
|
|
551
|
+
return Object.assign(done, { stop: finish, pause() {}, resume() {} });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Build a frame-perfect, manually-driven instance (no RAF, no auto-teardown) for
|
|
556
|
+
* offline capture or external timelines. The caller owns `renderAt` + `dispose`.
|
|
557
|
+
* Returns `null` off-DOM. The host stays alive until `dispose()`.
|
|
558
|
+
*/
|
|
559
|
+
export interface PreparedHandle {
|
|
560
|
+
readonly durationMs: number;
|
|
561
|
+
renderAt(elapsedMs: number): void;
|
|
562
|
+
dispose(): void;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function prepare(req: PlayRequest): PreparedHandle | null {
|
|
566
|
+
if (!isBrowser()) return null;
|
|
567
|
+
const wantShadow = req.factory.castsShadow !== false;
|
|
568
|
+
const host = getHost(req.target, wantShadow, req.composite ?? null);
|
|
569
|
+
const mood = resolveMood(req.feeling.mood);
|
|
570
|
+
const params = req.factory.resolve(req.feeling, mood);
|
|
571
|
+
|
|
572
|
+
let instance;
|
|
573
|
+
try {
|
|
574
|
+
instance = req.factory.create(params, buildEffectContext(host, req.anchor, req.targetSize));
|
|
575
|
+
} catch (err) {
|
|
576
|
+
if (host.active.size === 0) quiesce(host);
|
|
577
|
+
throw err;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
let disposed = false;
|
|
581
|
+
return {
|
|
582
|
+
durationMs: instance.durationMs,
|
|
583
|
+
renderAt(elapsedMs: number): void {
|
|
584
|
+
if (disposed) return;
|
|
585
|
+
syncHostSize(host);
|
|
586
|
+
beginLight(host);
|
|
587
|
+
beginShadow(host);
|
|
588
|
+
instance.renderAt(elapsedMs);
|
|
589
|
+
},
|
|
590
|
+
dispose(): void {
|
|
591
|
+
if (disposed) return;
|
|
592
|
+
disposed = true;
|
|
593
|
+
instance.dispose();
|
|
594
|
+
// Manually-driven handles fully release their host (offline capture wants
|
|
595
|
+
// a clean slate between effects), unless live effects are still animating.
|
|
596
|
+
if (host.active.size === 0) teardown(req.target);
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Test/SSR helper: how many live hosts (overlay+contexts) currently exist. */
|
|
602
|
+
export function activeHostCount(): number {
|
|
603
|
+
return hosts.size;
|
|
604
|
+
}
|