solid-drift 0.2.0 → 0.7.1

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 (48) hide show
  1. package/README.md +735 -10
  2. package/dist/animate.d.ts +2 -2
  3. package/dist/animate.js +2 -2
  4. package/dist/cartoon.d.ts +190 -0
  5. package/dist/cartoon.js +334 -0
  6. package/dist/color.d.ts +53 -0
  7. package/dist/color.js +391 -0
  8. package/dist/directive.d.ts +5 -5
  9. package/dist/directive.js +15 -7
  10. package/dist/easing.d.ts +22 -1
  11. package/dist/easing.js +49 -1
  12. package/dist/flip.d.ts +44 -0
  13. package/dist/flip.js +108 -0
  14. package/dist/horizontal.d.ts +107 -0
  15. package/dist/horizontal.js +208 -0
  16. package/dist/index.d.ts +16 -3
  17. package/dist/index.js +15 -3
  18. package/dist/inview.d.ts +4 -4
  19. package/dist/inview.js +5 -5
  20. package/dist/motion.d.ts +327 -0
  21. package/dist/motion.js +729 -0
  22. package/dist/physics.d.ts +146 -0
  23. package/dist/physics.js +352 -0
  24. package/dist/pointer.d.ts +76 -0
  25. package/dist/pointer.js +123 -0
  26. package/dist/reduced-motion.d.ts +3 -3
  27. package/dist/reduced-motion.js +5 -4
  28. package/dist/scroll.d.ts +3 -3
  29. package/dist/scroll.js +5 -5
  30. package/dist/scrollfx.d.ts +156 -0
  31. package/dist/scrollfx.js +148 -0
  32. package/dist/scrub.d.ts +51 -0
  33. package/dist/scrub.js +67 -0
  34. package/dist/spring.d.ts +39 -4
  35. package/dist/spring.js +26 -7
  36. package/dist/stagger.d.ts +4 -4
  37. package/dist/stagger.js +4 -4
  38. package/dist/timeline.d.ts +45 -0
  39. package/dist/timeline.js +93 -0
  40. package/dist/trail.d.ts +27 -0
  41. package/dist/trail.js +75 -0
  42. package/dist/tween.d.ts +3 -3
  43. package/dist/tween.js +3 -3
  44. package/dist/typography.d.ts +241 -0
  45. package/dist/typography.js +812 -0
  46. package/dist/velocity.d.ts +44 -0
  47. package/dist/velocity.js +88 -0
  48. package/package.json +1 -1
package/dist/motion.js ADDED
@@ -0,0 +1,729 @@
1
+ /**
2
+ * Motion-graphics family: showreel and launch-film primitives.
3
+ *
4
+ * These are the building blocks of code-driven motion design: kinetic
5
+ * typography, scene orchestration, camera moves, time-based color
6
+ * shifts, match-cut transitions, and a beat clock for cutting on the
7
+ * music. Each primitive is small and composable; a showreel is a
8
+ * `createScenePlayer` driving a few of these together, not one mega-API.
9
+ *
10
+ * All primitives are signal-native, SSR-safe, dependency-free, and
11
+ * define sensible static behavior under `prefers-reduced-motion`.
12
+ */
13
+ import { createSignal, onCleanup, } from "solid-js";
14
+ import { animate } from "./animate.js";
15
+ import { parseColorStops, sampleColorStops, } from "./color.js";
16
+ import { resolveEasing } from "./easing.js";
17
+ import { now, schedule } from "./engine.js";
18
+ import { prefersReducedMotion } from "./reduced-motion.js";
19
+ function ownerDoc(el) {
20
+ const od = el
21
+ .ownerDocument;
22
+ if (od)
23
+ return od ?? undefined;
24
+ return typeof document !== "undefined" ? document : undefined;
25
+ }
26
+ /**
27
+ * Split an element's text into per-unit inline-block spans so each
28
+ * letter (or word) can be transformed independently. The original text
29
+ * is preserved as an aria-label for screen readers.
30
+ */
31
+ function splitUnits(el, unit) {
32
+ const doc = ownerDoc(el);
33
+ if (!doc)
34
+ return [];
35
+ const text = el.textContent ?? "";
36
+ el.textContent = "";
37
+ el.setAttribute("aria-label", text);
38
+ const spans = [];
39
+ const push = (content) => {
40
+ const s = doc.createElement("span");
41
+ s.textContent = content;
42
+ s.setAttribute("aria-hidden", "true");
43
+ s.style.display = "inline-block";
44
+ s.style.willChange = "transform, opacity, filter";
45
+ el.appendChild(s);
46
+ spans.push(s);
47
+ };
48
+ if (unit === "words") {
49
+ for (const word of text.split(/(\s+)/)) {
50
+ if (word.length === 0)
51
+ continue;
52
+ if (/^\s+$/.test(word)) {
53
+ el.appendChild(doc.createTextNode(word));
54
+ }
55
+ else {
56
+ push(word);
57
+ }
58
+ }
59
+ }
60
+ else {
61
+ for (const ch of text)
62
+ push(ch === " " ? " " : ch);
63
+ }
64
+ return spans;
65
+ }
66
+ function clamp01(v) {
67
+ return v < 0 ? 0 : v > 1 ? 1 : v;
68
+ }
69
+ /** Round to 2 decimals for style values. */
70
+ function fmt(v) {
71
+ return String(Math.round(v * 100) / 100);
72
+ }
73
+ function applyKineticStyle(el, e, from) {
74
+ const t = 1 - e;
75
+ const y = from.y * t;
76
+ const scale = from.scale + (1 - from.scale) * e;
77
+ const rotate = from.rotate * t;
78
+ const blur = from.blur * t;
79
+ const opacity = from.opacity + (1 - from.opacity) * e;
80
+ el.style.transform =
81
+ `translateY(${fmt(y)}px) scale(${fmt(scale)}) rotate(${fmt(rotate)}deg)`;
82
+ el.style.filter = blur > 0.05 ? `blur(${fmt(blur)}px)` : "none";
83
+ el.style.opacity = fmt(opacity);
84
+ }
85
+ /**
86
+ * Kinetic typography: each character (or word) flies in with position,
87
+ * blur, scale, and opacity, staggered for that showreel title feel.
88
+ *
89
+ * One master clock drives every unit, so a headline with 40 characters
90
+ * costs a single rAF task, not 40 timers. Units animate through the
91
+ * same `from` state with per-unit easing.
92
+ *
93
+ * SSR-safe: no-op on the server. Under reduced motion every unit jumps
94
+ * to its final state when `play()` runs, so the text is fully readable.
95
+ *
96
+ * ```tsx
97
+ * let title!: HTMLHeadingElement
98
+ * const kinetic = createKineticType(() => title, {
99
+ * unit: "chars",
100
+ * stagger: 35,
101
+ * from: { y: 40, blur: 12, scale: 0.8 },
102
+ * easing: "easeOutExpo",
103
+ * })
104
+ * onMount(() => kinetic.play())
105
+ * <h1 ref={title}>Showreel</h1>
106
+ * ```
107
+ */
108
+ export function createKineticType(ref, options = {}) {
109
+ const { unit = "chars", duration = 550, stagger = 45, easing: easingOpt = "easeOutExpo", } = options;
110
+ const from = {
111
+ y: options.from?.y ?? 28,
112
+ blur: options.from?.blur ?? 10,
113
+ scale: options.from?.scale ?? 0.85,
114
+ opacity: options.from?.opacity ?? 0,
115
+ rotate: options.from?.rotate ?? 0,
116
+ };
117
+ const easing = resolveEasing(easingOpt);
118
+ const [status, setStatus] = createSignal("idle");
119
+ let units = [];
120
+ let controls = null;
121
+ let runToken = 0;
122
+ const play = () => {
123
+ const token = ++runToken;
124
+ controls?.stop();
125
+ controls = null;
126
+ units = [];
127
+ if (typeof window !== "undefined") {
128
+ const el = ref();
129
+ if (el)
130
+ units = splitUnits(el, unit);
131
+ }
132
+ if (units.length === 0) {
133
+ setStatus("done");
134
+ return Promise.resolve();
135
+ }
136
+ setStatus("running");
137
+ const total = duration + stagger * (units.length - 1);
138
+ return new Promise((resolve) => {
139
+ // Declared before animate() runs: under reduced motion animate()
140
+ // calls onComplete synchronously, before its return value exists.
141
+ let stepControls = null;
142
+ let completed = false;
143
+ stepControls = animate(0, total, {
144
+ duration: total,
145
+ easing: "linear",
146
+ onUpdate: (elapsed) => {
147
+ for (let i = 0; i < units.length; i++) {
148
+ const local = clamp01((elapsed - i * stagger) / duration);
149
+ applyKineticStyle(units[i], easing(local), from);
150
+ }
151
+ },
152
+ onComplete: () => {
153
+ completed = true;
154
+ if (token === runToken) {
155
+ controls = null;
156
+ setStatus("done");
157
+ }
158
+ resolve();
159
+ },
160
+ });
161
+ if (!completed)
162
+ controls = stepControls;
163
+ // `finished` also resolves when the reveal is stopped, which
164
+ // unblocks the play() promise. The token check in onComplete keeps
165
+ // a stale stop from marking a newer run done.
166
+ void stepControls.finished.then(() => {
167
+ if (controls === stepControls)
168
+ controls = null;
169
+ resolve();
170
+ });
171
+ });
172
+ };
173
+ const stop = () => {
174
+ runToken++;
175
+ controls?.stop();
176
+ controls = null;
177
+ setStatus("idle");
178
+ };
179
+ onCleanup(stop);
180
+ return {
181
+ play,
182
+ stop,
183
+ replay: () => {
184
+ stop();
185
+ return play();
186
+ },
187
+ status,
188
+ };
189
+ }
190
+ /**
191
+ * Scene orchestrator for showreels and launch films: an ordered list of
192
+ * scenes, each with a duration and enter/exit hooks. Think of it as the
193
+ * paused-master-timeline pattern from motion-design tools, expressed as
194
+ * signals: `scene()` tells your view which scene is live, and the hooks
195
+ * trigger each scene's choreography (a `createKineticType`, a camera
196
+ * move, a color shift).
197
+ *
198
+ * SSR-safe: scenes never advance on the server. Under reduced motion
199
+ * `play()` jumps straight to the last scene (the clean final frame)
200
+ * instead of stepping through.
201
+ *
202
+ * ```ts
203
+ * const player = createScenePlayer([
204
+ * { duration: 1200, onEnter: () => hookTitle.play() },
205
+ * { duration: 2000, onEnter: () => cameraZoom.play() },
206
+ * { duration: 1500, onEnter: () => showLogo() }, // final frame
207
+ * ])
208
+ * player.scene() // 0, 1, 2 as the reel plays
209
+ * await player.play()
210
+ * ```
211
+ */
212
+ export function createScenePlayer(scenes) {
213
+ const [scene, setScene] = createSignal(-1);
214
+ const [status, setStatus] = createSignal("idle");
215
+ let cancel = null;
216
+ let index = -1;
217
+ let elapsed = 0;
218
+ let lastTick = 0;
219
+ let resolvePlay = null;
220
+ const finish = () => {
221
+ cancel?.();
222
+ cancel = null;
223
+ const done = resolvePlay;
224
+ resolvePlay = null;
225
+ done?.();
226
+ setStatus("done");
227
+ };
228
+ const enter = (i) => {
229
+ index = i;
230
+ elapsed = 0;
231
+ setScene(i);
232
+ scenes[i].onEnter?.(i);
233
+ };
234
+ const advance = () => {
235
+ const prev = index;
236
+ scenes[prev]?.onExit?.(prev);
237
+ if (prev + 1 >= scenes.length) {
238
+ finish();
239
+ }
240
+ else {
241
+ enter(prev + 1);
242
+ }
243
+ };
244
+ const tick = (t) => {
245
+ const dt = t - lastTick;
246
+ lastTick = t;
247
+ elapsed += dt;
248
+ if (elapsed >= Math.max(scenes[index].duration, 0)) {
249
+ advance();
250
+ }
251
+ return status() === "running";
252
+ };
253
+ const startClock = () => {
254
+ cancel?.();
255
+ lastTick = now();
256
+ cancel = schedule(tick);
257
+ };
258
+ const play = () => {
259
+ cancel?.();
260
+ cancel = null;
261
+ // A superseded play() must not leave its caller hanging.
262
+ const prev = resolvePlay;
263
+ resolvePlay = null;
264
+ prev?.();
265
+ if (scenes.length === 0) {
266
+ setScene(-1);
267
+ setStatus("done");
268
+ return Promise.resolve();
269
+ }
270
+ if (prefersReducedMotion() || typeof window === "undefined") {
271
+ // Reduced motion / SSR: jump to the clean final frame.
272
+ const last = scenes.length - 1;
273
+ if (index >= 0)
274
+ scenes[index]?.onExit?.(index);
275
+ enter(last);
276
+ setStatus("done");
277
+ return Promise.resolve();
278
+ }
279
+ const resuming = status() === "paused" && index >= 0 && index < scenes.length;
280
+ if (!resuming) {
281
+ // Fresh start, including after done(): begin at the first scene.
282
+ enter(0);
283
+ }
284
+ setStatus("running");
285
+ startClock();
286
+ return new Promise((resolve) => {
287
+ resolvePlay = resolve;
288
+ });
289
+ };
290
+ const pause = () => {
291
+ if (status() !== "running")
292
+ return;
293
+ cancel?.();
294
+ cancel = null;
295
+ // Resolve the pending play() promise: the reel is suspended, and the
296
+ // caller must not hang forever waiting for the last scene.
297
+ const done = resolvePlay;
298
+ resolvePlay = null;
299
+ done?.();
300
+ setStatus("paused");
301
+ };
302
+ const stop = () => {
303
+ cancel?.();
304
+ cancel = null;
305
+ if (index >= 0)
306
+ scenes[index]?.onExit?.(index);
307
+ const done = resolvePlay;
308
+ resolvePlay = null;
309
+ done?.();
310
+ index = -1;
311
+ elapsed = 0;
312
+ setScene(-1);
313
+ setStatus("idle");
314
+ };
315
+ const goTo = (i) => {
316
+ if (scenes.length === 0)
317
+ return;
318
+ const clamped = Math.max(0, Math.min(scenes.length - 1, i));
319
+ const wasRunning = status() === "running";
320
+ if (index >= 0 && index !== clamped)
321
+ scenes[index]?.onExit?.(index);
322
+ enter(clamped);
323
+ if (wasRunning) {
324
+ setStatus("running");
325
+ startClock();
326
+ }
327
+ };
328
+ onCleanup(stop);
329
+ return {
330
+ scene,
331
+ status,
332
+ play,
333
+ pause,
334
+ stop,
335
+ replay: () => {
336
+ stop();
337
+ return play();
338
+ },
339
+ next: () => goTo(index + 1),
340
+ prev: () => goTo(index - 1),
341
+ goTo,
342
+ };
343
+ }
344
+ /**
345
+ * Camera moves for a motion-design stage: pan and zoom driven by a
346
+ * 0-to-1 progress signal, returned as a compositor-friendly transform
347
+ * string (`translate3d(...) scale(...)`).
348
+ *
349
+ * Drive `progress` with anything: a `createScenePlayer` scene's own
350
+ * clock, an `animate()` tween, or scroll. Keyframes sort themselves by
351
+ * `at`; progress outside the range clamps to the end poses.
352
+ *
353
+ * Pure computation, no listeners, no rAF: SSR-safe by construction.
354
+ * Under reduced motion it holds the final keyframe's pose.
355
+ *
356
+ * ```tsx
357
+ * const [p, setP] = createSignal(0)
358
+ * // Slow dolly-in across the scene.
359
+ * const cam = createCamera({
360
+ * progress: p,
361
+ * keyframes... // via options below
362
+ * })
363
+ * ```
364
+ */
365
+ export function createCamera(keyframes, options) {
366
+ if (keyframes.length === 0) {
367
+ throw new Error("createCamera needs at least one keyframe.");
368
+ }
369
+ const sorted = [...keyframes]
370
+ .sort((a, b) => a.at - b.at)
371
+ .map((k) => ({
372
+ at: k.at,
373
+ x: k.x ?? 0,
374
+ y: k.y ?? 0,
375
+ scale: k.scale ?? 1,
376
+ easing: resolveEasing(k.easing ?? options.easing ?? "linear"),
377
+ }));
378
+ const pose = (p) => {
379
+ const t = clamp01(p);
380
+ let a = sorted[0];
381
+ let b = sorted[sorted.length - 1];
382
+ let local = 0;
383
+ if (t <= sorted[0].at) {
384
+ a = sorted[0];
385
+ b = sorted[0];
386
+ }
387
+ else if (t >= sorted[sorted.length - 1].at) {
388
+ a = sorted[sorted.length - 1];
389
+ b = sorted[sorted.length - 1];
390
+ }
391
+ else {
392
+ let i = 0;
393
+ while (i < sorted.length - 2 && sorted[i + 1].at <= t)
394
+ i++;
395
+ a = sorted[i];
396
+ b = sorted[i + 1];
397
+ const span = b.at - a.at;
398
+ local = span <= 0 ? 0 : b.easing((t - a.at) / span);
399
+ }
400
+ const x = a.x + (b.x - a.x) * local;
401
+ const y = a.y + (b.y - a.y) * local;
402
+ const s = a.scale + (b.scale - a.scale) * local;
403
+ return `translate3d(${fmt(x)}px, ${fmt(y)}px, 0) scale(${fmt(s)})`;
404
+ };
405
+ if (prefersReducedMotion()) {
406
+ const end = pose(1);
407
+ return () => end;
408
+ }
409
+ return () => pose(options.progress());
410
+ }
411
+ /**
412
+ * Time-based color interpolation across stops: the sibling of
413
+ * `createScrollColor` for motion graphics, where color shifts run on a
414
+ * clock instead of scroll. Colors interpolate in linear light, so
415
+ * midpoints stay vivid, and alpha channels interpolate too.
416
+ *
417
+ * Under reduced motion `play()` jumps straight to the final stop's
418
+ * color. SSR-safe: `color()` returns the final stop's color.
419
+ *
420
+ * ```ts
421
+ * const shift = createColorShift(
422
+ * [
423
+ * { at: 0, color: "#0a1220" },
424
+ * { at: 0.5, color: "#2f8fdd" },
425
+ * { at: 1, color: "#d9a441", easing: "easeInOutQuad" },
426
+ * ],
427
+ * { duration: 2000 },
428
+ * )
429
+ * shift.color() // "#0a1220" ... "#d9a441" as it plays
430
+ * await shift.play()
431
+ * ```
432
+ */
433
+ export function createColorShift(stops, options = {}) {
434
+ if (stops.length === 0) {
435
+ throw new Error("createColorShift needs at least one color stop.");
436
+ }
437
+ const { duration = 1200, delay = 0, format = "hex", } = options;
438
+ const parsed = parseColorStops(stops, options.easing);
439
+ const [progress, setProgress] = createSignal(0);
440
+ const [status, setStatus] = createSignal("idle");
441
+ let controls = null;
442
+ let runToken = 0;
443
+ const color = () => sampleColorStops(parsed, progress(), format);
444
+ const play = () => {
445
+ const token = ++runToken;
446
+ controls?.stop();
447
+ controls = null;
448
+ setProgress(0);
449
+ setStatus("running");
450
+ return new Promise((resolve) => {
451
+ // Declared before animate() runs: under reduced motion animate()
452
+ // calls onComplete synchronously, before its return value exists.
453
+ let shiftControls = null;
454
+ let completed = false;
455
+ shiftControls = animate(0, 1, {
456
+ duration,
457
+ delay,
458
+ easing: "linear",
459
+ onUpdate: (v) => {
460
+ if (token === runToken)
461
+ setProgress(v);
462
+ },
463
+ onComplete: () => {
464
+ completed = true;
465
+ if (token === runToken) {
466
+ controls = null;
467
+ setStatus("done");
468
+ }
469
+ resolve();
470
+ },
471
+ });
472
+ if (!completed)
473
+ controls = shiftControls;
474
+ // `finished` also resolves when the shift is stopped, which
475
+ // unblocks the play() promise.
476
+ void shiftControls.finished.then(() => {
477
+ if (controls === shiftControls)
478
+ controls = null;
479
+ resolve();
480
+ });
481
+ });
482
+ };
483
+ const stop = () => {
484
+ runToken++;
485
+ controls?.stop();
486
+ controls = null;
487
+ setStatus("idle");
488
+ };
489
+ onCleanup(stop);
490
+ return {
491
+ color,
492
+ play,
493
+ stop,
494
+ replay: () => {
495
+ stop();
496
+ return play();
497
+ },
498
+ status,
499
+ };
500
+ }
501
+ function transitionStyles(type, direction, p) {
502
+ const inv = fmt((1 - p) * 100);
503
+ const none = {
504
+ opacity: "1",
505
+ transform: "none",
506
+ clipPath: "none",
507
+ };
508
+ if (type === "cut") {
509
+ return p >= 1
510
+ ? {
511
+ out: { ...none, opacity: "0" },
512
+ in: { ...none, opacity: "1" },
513
+ }
514
+ : {
515
+ out: { ...none, opacity: "1" },
516
+ in: { ...none, opacity: "0" },
517
+ };
518
+ }
519
+ if (type === "fade") {
520
+ return {
521
+ out: { ...none, opacity: fmt(1 - p) },
522
+ in: { ...none, opacity: fmt(p) },
523
+ };
524
+ }
525
+ if (type === "slide") {
526
+ const axis = direction === "left" || direction === "right" ? "X" : "Y";
527
+ const sign = direction === "left" || direction === "up" ? -1 : 1;
528
+ return {
529
+ out: {
530
+ ...none,
531
+ transform: `translate${axis}(${fmt(sign * p * 100)}%)`,
532
+ },
533
+ in: {
534
+ ...none,
535
+ transform: `translate${axis}(${fmt(-sign * (1 - p) * 100)}%)`,
536
+ opacity: "1",
537
+ },
538
+ };
539
+ }
540
+ // wipe: the incoming scene reveals over the outgoing one.
541
+ const inset = p >= 1
542
+ ? "none"
543
+ : direction === "left"
544
+ ? `inset(0 ${inv}% 0 0)`
545
+ : direction === "right"
546
+ ? `inset(0 0 0 ${inv}%)`
547
+ : direction === "up"
548
+ ? `inset(0 0 ${inv}% 0)`
549
+ : `inset(${inv}% 0 0 0)`;
550
+ return {
551
+ out: { ...none, opacity: "1" },
552
+ in: { ...none, opacity: "1", clipPath: inset },
553
+ };
554
+ }
555
+ /**
556
+ * Match-cut style scene handoffs: `outgoing()` and `incoming()` return
557
+ * style objects for the two scene layers, driven by one 0-to-1 progress.
558
+ *
559
+ * - "cut": instant swap, no animation.
560
+ * - "fade": crossfade.
561
+ * - "slide": the outgoing scene exits one way while the incoming scene
562
+ * enters from the opposite side.
563
+ * - "wipe": the incoming scene reveals over the outgoing one with a
564
+ * clip-path wipe.
565
+ *
566
+ * Everything animates on opacity, transform, or clip-path, so handoffs
567
+ * stay on the compositor. Under reduced motion every type degrades to a
568
+ * cut: the swap happens instantly.
569
+ *
570
+ * ```tsx
571
+ * const cut = createTransition({ type: "wipe", direction: "left", duration: 600 })
572
+ * const go = async () => {
573
+ * showSceneB()
574
+ * await cut.play()
575
+ * }
576
+ * <div style={cut.outgoing()}>{sceneA}</div>
577
+ * <div style={cut.incoming()}>{sceneB}</div>
578
+ * ```
579
+ */
580
+ export function createTransition(options = {}) {
581
+ const { type = "fade", direction = "left", duration = 500, easing: easingOpt = "easeInOutCubic", } = options;
582
+ const easing = resolveEasing(easingOpt);
583
+ const [raw, setRaw] = createSignal(0);
584
+ const [status, setStatus] = createSignal("idle");
585
+ let controls = null;
586
+ let runToken = 0;
587
+ const progress = () => easing(clamp01(raw()));
588
+ const outgoing = () => transitionStyles(type, direction, progress()).out;
589
+ const incoming = () => transitionStyles(type, direction, progress()).in;
590
+ const play = () => {
591
+ const token = ++runToken;
592
+ controls?.stop();
593
+ controls = null;
594
+ // Reduced motion (or an explicit cut) degrades to an instant swap.
595
+ if (type === "cut" || prefersReducedMotion()) {
596
+ setRaw(1);
597
+ setStatus("done");
598
+ return Promise.resolve();
599
+ }
600
+ setRaw(0);
601
+ setStatus("running");
602
+ return new Promise((resolve) => {
603
+ // Declared before animate() runs: under reduced motion animate()
604
+ // calls onComplete synchronously, before its return value exists.
605
+ let cutControls = null;
606
+ let completed = false;
607
+ cutControls = animate(0, 1, {
608
+ duration,
609
+ easing: "linear",
610
+ onUpdate: (v) => {
611
+ if (token === runToken)
612
+ setRaw(v);
613
+ },
614
+ onComplete: () => {
615
+ completed = true;
616
+ if (token === runToken) {
617
+ controls = null;
618
+ setStatus("done");
619
+ }
620
+ resolve();
621
+ },
622
+ });
623
+ if (!completed)
624
+ controls = cutControls;
625
+ // `finished` also resolves when the handoff is stopped, which
626
+ // unblocks the play() promise.
627
+ void cutControls.finished.then(() => {
628
+ if (controls === cutControls)
629
+ controls = null;
630
+ resolve();
631
+ });
632
+ });
633
+ };
634
+ const stop = () => {
635
+ runToken++;
636
+ controls?.stop();
637
+ controls = null;
638
+ setStatus("idle");
639
+ };
640
+ onCleanup(stop);
641
+ return {
642
+ progress,
643
+ outgoing,
644
+ incoming,
645
+ play,
646
+ stop,
647
+ replay: () => {
648
+ stop();
649
+ return play();
650
+ },
651
+ status,
652
+ };
653
+ }
654
+ /**
655
+ * A beat clock for cutting on the music: at 120 BPM it ticks twice a
656
+ * second, and `onBeat` fires your scene cuts, kinetic type replays, or
657
+ * color shifts in time. `phase()` gives the fractional position inside
658
+ * the current beat for syncing continuous motion to the rhythm.
659
+ *
660
+ * Beats are timing, not motion, so the clock keeps ticking under
661
+ * reduced motion (your callbacks decide what that means visually).
662
+ * SSR-safe: `start()` is a no-op without requestAnimationFrame.
663
+ *
664
+ * ```ts
665
+ * const beat = createBeat({ bpm: 128 })
666
+ * const off = beat.onBeat((b) => {
667
+ * if (b % 8 === 0) player.next() // cut scenes every 2 bars
668
+ * })
669
+ * beat.start()
670
+ * ```
671
+ */
672
+ export function createBeat(options = {}) {
673
+ const { bpm = 120, beatsPerBar = 4 } = options;
674
+ const msPerBeat = 60000 / Math.max(bpm, 1);
675
+ const perBar = Math.max(1, Math.floor(beatsPerBar));
676
+ const [beat, setBeat] = createSignal(0);
677
+ const [bar, setBar] = createSignal(0);
678
+ const [phase, setPhase] = createSignal(0);
679
+ const [status, setStatus] = createSignal("idle");
680
+ const listeners = new Set();
681
+ let cancel = null;
682
+ let t0 = 0;
683
+ let lastBeat = -1;
684
+ const start = () => {
685
+ if (status() === "running")
686
+ return;
687
+ if (typeof window === "undefined")
688
+ return;
689
+ t0 = now();
690
+ lastBeat = -1;
691
+ setBeat(0);
692
+ setBar(0);
693
+ setPhase(0);
694
+ setStatus("running");
695
+ cancel = schedule((t) => {
696
+ const floating = (t - t0) / msPerBeat;
697
+ const b = Math.floor(floating);
698
+ if (b !== lastBeat) {
699
+ lastBeat = b;
700
+ setBeat(b);
701
+ setBar(Math.floor(b / perBar));
702
+ for (const cb of listeners)
703
+ cb(b);
704
+ }
705
+ setPhase(floating - b);
706
+ return status() === "running";
707
+ });
708
+ };
709
+ const stop = () => {
710
+ cancel?.();
711
+ cancel = null;
712
+ setStatus("idle");
713
+ };
714
+ onCleanup(stop);
715
+ return {
716
+ beat,
717
+ bar,
718
+ phase,
719
+ onBeat: (cb) => {
720
+ listeners.add(cb);
721
+ return () => {
722
+ listeners.delete(cb);
723
+ };
724
+ },
725
+ start,
726
+ stop,
727
+ status,
728
+ };
729
+ }