@cardstack/choreo 0.0.0 → 0.1.0-unstable.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 (165) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -6
  4. package/addon-main.cjs +4 -0
  5. package/declarations/anchors.d.ts +26 -0
  6. package/declarations/anchors.d.ts.map +1 -0
  7. package/declarations/arming.d.ts +33 -0
  8. package/declarations/arming.d.ts.map +1 -0
  9. package/declarations/beacon.d.ts +9 -0
  10. package/declarations/beacon.d.ts.map +1 -0
  11. package/declarations/beacons.d.ts +23 -0
  12. package/declarations/beacons.d.ts.map +1 -0
  13. package/declarations/changeset.d.ts +46 -0
  14. package/declarations/changeset.d.ts.map +1 -0
  15. package/declarations/choreo.d.ts +261 -0
  16. package/declarations/choreo.d.ts.map +1 -0
  17. package/declarations/compile.d.ts +45 -0
  18. package/declarations/compile.d.ts.map +1 -0
  19. package/declarations/deliver.d.ts +44 -0
  20. package/declarations/deliver.d.ts.map +1 -0
  21. package/declarations/easings.d.ts +20 -0
  22. package/declarations/easings.d.ts.map +1 -0
  23. package/declarations/far.d.ts +33 -0
  24. package/declarations/far.d.ts.map +1 -0
  25. package/declarations/film/clip.d.ts +51 -0
  26. package/declarations/film/clip.d.ts.map +1 -0
  27. package/declarations/film/clips.d.ts +155 -0
  28. package/declarations/film/clips.d.ts.map +1 -0
  29. package/declarations/film/film.d.ts +1208 -0
  30. package/declarations/film/film.d.ts.map +1 -0
  31. package/declarations/film/graph/adjust.d.ts +112 -0
  32. package/declarations/film/graph/adjust.d.ts.map +1 -0
  33. package/declarations/film/graph/compile.d.ts +140 -0
  34. package/declarations/film/graph/compile.d.ts.map +1 -0
  35. package/declarations/film/graph/host.d.ts +67 -0
  36. package/declarations/film/graph/host.d.ts.map +1 -0
  37. package/declarations/film/graph/nodes.d.ts +287 -0
  38. package/declarations/film/graph/nodes.d.ts.map +1 -0
  39. package/declarations/film/index.d.ts +24 -0
  40. package/declarations/film/index.d.ts.map +1 -0
  41. package/declarations/film/joins.d.ts +143 -0
  42. package/declarations/film/joins.d.ts.map +1 -0
  43. package/declarations/film/math.d.ts +15 -0
  44. package/declarations/film/math.d.ts.map +1 -0
  45. package/declarations/film/overlays.d.ts +54 -0
  46. package/declarations/film/overlays.d.ts.map +1 -0
  47. package/declarations/film/picture.d.ts +104 -0
  48. package/declarations/film/picture.d.ts.map +1 -0
  49. package/declarations/film/plate.d.ts +37 -0
  50. package/declarations/film/plate.d.ts.map +1 -0
  51. package/declarations/film/player.d.ts +120 -0
  52. package/declarations/film/player.d.ts.map +1 -0
  53. package/declarations/film/rail.d.ts +40 -0
  54. package/declarations/film/rail.d.ts.map +1 -0
  55. package/declarations/film/schedule.d.ts +93 -0
  56. package/declarations/film/schedule.d.ts.map +1 -0
  57. package/declarations/film/seam.d.ts +38 -0
  58. package/declarations/film/seam.d.ts.map +1 -0
  59. package/declarations/film/titles.d.ts +37 -0
  60. package/declarations/film/titles.d.ts.map +1 -0
  61. package/declarations/film/types.d.ts +544 -0
  62. package/declarations/film/types.d.ts.map +1 -0
  63. package/declarations/film.d.ts +3 -0
  64. package/declarations/film.d.ts.map +1 -0
  65. package/declarations/gesture.d.ts +33 -0
  66. package/declarations/gesture.d.ts.map +1 -0
  67. package/declarations/index.d.ts +26 -0
  68. package/declarations/index.d.ts.map +1 -0
  69. package/declarations/measure.d.ts +29 -0
  70. package/declarations/measure.d.ts.map +1 -0
  71. package/declarations/path.d.ts +73 -0
  72. package/declarations/path.d.ts.map +1 -0
  73. package/declarations/registry.d.ts +46 -0
  74. package/declarations/registry.d.ts.map +1 -0
  75. package/declarations/run.d.ts +406 -0
  76. package/declarations/run.d.ts.map +1 -0
  77. package/declarations/space.d.ts +31 -0
  78. package/declarations/space.d.ts.map +1 -0
  79. package/declarations/steps.d.ts +479 -0
  80. package/declarations/steps.d.ts.map +1 -0
  81. package/declarations/test-support/index.d.ts +30 -0
  82. package/declarations/test-support/index.d.ts.map +1 -0
  83. package/declarations/types.d.ts +761 -0
  84. package/declarations/types.d.ts.map +1 -0
  85. package/dist/anchors.js +34 -0
  86. package/dist/anchors.js.map +1 -0
  87. package/dist/arming.js +120 -0
  88. package/dist/arming.js.map +1 -0
  89. package/dist/beacon.js +25 -0
  90. package/dist/beacon.js.map +1 -0
  91. package/dist/beacons.js +77 -0
  92. package/dist/beacons.js.map +1 -0
  93. package/dist/changeset.js +129 -0
  94. package/dist/changeset.js.map +1 -0
  95. package/dist/choreo.js +1026 -0
  96. package/dist/choreo.js.map +1 -0
  97. package/dist/compile.js +1403 -0
  98. package/dist/compile.js.map +1 -0
  99. package/dist/deliver.js +326 -0
  100. package/dist/deliver.js.map +1 -0
  101. package/dist/easings.js +39 -0
  102. package/dist/easings.js.map +1 -0
  103. package/dist/far.js +147 -0
  104. package/dist/far.js.map +1 -0
  105. package/dist/film/clip.js +100 -0
  106. package/dist/film/clip.js.map +1 -0
  107. package/dist/film/clips.js +147 -0
  108. package/dist/film/clips.js.map +1 -0
  109. package/dist/film/film.js +4424 -0
  110. package/dist/film/film.js.map +1 -0
  111. package/dist/film/graph/adjust.js +160 -0
  112. package/dist/film/graph/adjust.js.map +1 -0
  113. package/dist/film/graph/compile.js +225 -0
  114. package/dist/film/graph/compile.js.map +1 -0
  115. package/dist/film/graph/host.js +77 -0
  116. package/dist/film/graph/host.js.map +1 -0
  117. package/dist/film/graph/nodes.js +500 -0
  118. package/dist/film/graph/nodes.js.map +1 -0
  119. package/dist/film/index.js +18 -0
  120. package/dist/film/index.js.map +1 -0
  121. package/dist/film/joins.js +260 -0
  122. package/dist/film/joins.js.map +1 -0
  123. package/dist/film/math.js +49 -0
  124. package/dist/film/math.js.map +1 -0
  125. package/dist/film/overlays.js +66 -0
  126. package/dist/film/overlays.js.map +1 -0
  127. package/dist/film/picture.js +58 -0
  128. package/dist/film/picture.js.map +1 -0
  129. package/dist/film/plate.js +36 -0
  130. package/dist/film/plate.js.map +1 -0
  131. package/dist/film/player.js +79 -0
  132. package/dist/film/player.js.map +1 -0
  133. package/dist/film/rail.js +38 -0
  134. package/dist/film/rail.js.map +1 -0
  135. package/dist/film/schedule.js +238 -0
  136. package/dist/film/schedule.js.map +1 -0
  137. package/dist/film/seam.js +64 -0
  138. package/dist/film/seam.js.map +1 -0
  139. package/dist/film/titles.js +45 -0
  140. package/dist/film/titles.js.map +1 -0
  141. package/dist/film/types.js +2 -0
  142. package/dist/film/types.js.map +1 -0
  143. package/dist/film.js +18 -0
  144. package/dist/film.js.map +1 -0
  145. package/dist/gesture.js +111 -0
  146. package/dist/gesture.js.map +1 -0
  147. package/dist/index.js +9 -0
  148. package/dist/index.js.map +1 -0
  149. package/dist/measure.js +99 -0
  150. package/dist/measure.js.map +1 -0
  151. package/dist/path.js +272 -0
  152. package/dist/path.js.map +1 -0
  153. package/dist/registry.js +55 -0
  154. package/dist/registry.js.map +1 -0
  155. package/dist/run.js +2423 -0
  156. package/dist/run.js.map +1 -0
  157. package/dist/space.js +57 -0
  158. package/dist/space.js.map +1 -0
  159. package/dist/steps.js +831 -0
  160. package/dist/steps.js.map +1 -0
  161. package/dist/test-support/index.js +150 -0
  162. package/dist/test-support/index.js.map +1 -0
  163. package/dist/types.js +2 -0
  164. package/dist/types.js.map +1 -0
  165. package/package.json +202 -6
package/dist/run.js ADDED
@@ -0,0 +1,2423 @@
1
+ import { motionSpeed, scaleTransition } from 'glimmer-motion';
2
+ import { frame, cancelFrame, flushKeyframeResolvers, spring, generateLinearEasing, animateTarget } from 'motion-dom';
3
+ import { easingDefinitionToFunction } from 'motion-utils';
4
+ import { deliver, cssEasing, keyframesOf } from './deliver.js';
5
+ import { settleThrough, sampleThrough } from './path.js';
6
+ import { choreoHostById } from './registry.js';
7
+
8
+ /**
9
+ * Play a cue list on the motion-dom engine — as a run you can hold (§4.6).
10
+ *
11
+ * The old executor fired timers and forgot; this one owns a master clock.
12
+ * Cues start when the clock crosses them, a gate parks the clock until
13
+ * `advance()` (§4.1), `time` is settable in either direction (a scrubbed
14
+ * frame is a still), and `speed` scales the whole score. Ambient loops and
15
+ * text deliveries ride the platform's own animations, so a busy probe never
16
+ * mistakes an idle loop for motion.
17
+ *
18
+ * The clock runs in tempo-scaled milliseconds; the public face (`time`,
19
+ * `duration`) speaks seconds at 1×, as Motion's playback controls do.
20
+ */
21
+ /** the properties whose mid-flight drive means a sprite is IN SPACE — a
22
+ * replacement pass must carry these on, never snap them (§3.1) */
23
+ const MIDFLIGHT_KEYS = ['height', 'rotate', 'scale', 'scaleX', 'scaleY', 'width', 'x', 'y'];
24
+
25
+ /** how fast each value was moving, in units per second, keyed by element then property */
26
+
27
+ function isSpring(transition) {
28
+ if (!transition) {
29
+ return false;
30
+ }
31
+ if (transition['type']) {
32
+ return transition['type'] === 'spring';
33
+ }
34
+ return 'bounce' in transition || 'damping' in transition || 'stiffness' in transition || 'visualDuration' in transition;
35
+ }
36
+
37
+ /**
38
+ * A platform Animation in the controls shape the transport speaks, so
39
+ * setPaused / speed / stopTrack drive it exactly like an engine animation.
40
+ */
41
+ function platformControls(animation) {
42
+ return {
43
+ cancel: () => animation.cancel(),
44
+ pause: () => animation.pause(),
45
+ play: () => animation.play(),
46
+ get speed() {
47
+ return animation.playbackRate;
48
+ },
49
+ set speed(rate) {
50
+ animation.playbackRate = rate;
51
+ },
52
+ get state() {
53
+ return animation.playState;
54
+ },
55
+ stop: () => animation.cancel(),
56
+ get time() {
57
+ return (animation.currentTime ?? 0) / 1000;
58
+ },
59
+ set time(seconds) {
60
+ animation.currentTime = seconds * 1000;
61
+ }
62
+ };
63
+ }
64
+ function carryVelocity(transition, cue, inherit) {
65
+ if (!inherit || !cue.target || !isSpring(transition)) {
66
+ return transition;
67
+ }
68
+ const was = inherit.get(cue.sprite.element) ?? (cue.sprite.counterpart && inherit.get(cue.sprite.counterpart.element));
69
+ if (!was) {
70
+ return transition;
71
+ }
72
+ let out = transition;
73
+ for (const key in cue.target) {
74
+ const velocity = was.get(key);
75
+ if (velocity) {
76
+ if (out === transition) {
77
+ out = {
78
+ ...transition
79
+ };
80
+ }
81
+ out[key] = {
82
+ ...transition,
83
+ velocity
84
+ };
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+ const dash = key => key.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
90
+ const noop = () => {};
91
+ const SIZES = {
92
+ height: true,
93
+ width: true
94
+ };
95
+ function applyHold(ve, el, values) {
96
+ const held = [];
97
+ for (const key in values) {
98
+ const had = ve.hasValue(key);
99
+ const prior = had ? ve.getValue(key).get() : undefined;
100
+ const priorStyle = key.startsWith('--') ? el.style.getPropertyValue(key) : el.style[key] ?? '';
101
+ const mv = ve.getValue(key, values[key]);
102
+ mv.jump(values[key]);
103
+ held.push({
104
+ had,
105
+ key,
106
+ prior,
107
+ priorStyle
108
+ });
109
+ }
110
+ ve.scheduleRender();
111
+ return held;
112
+ }
113
+ function releaseHold(ve, el, held) {
114
+ for (const {
115
+ had,
116
+ key,
117
+ prior,
118
+ priorStyle
119
+ } of held) {
120
+ if (had) {
121
+ ve.getValue(key)?.jump(prior);
122
+ } else {
123
+ ve.removeValue(key);
124
+ if (priorStyle) {
125
+ el.style.setProperty(dash(key), priorStyle);
126
+ } else {
127
+ el.style.removeProperty(dash(key));
128
+ }
129
+ }
130
+ }
131
+ ve.scheduleRender();
132
+ }
133
+
134
+ /** what one cue is doing right now */
135
+
136
+ class ChoreoRun {
137
+ cues;
138
+ velocities = new Map();
139
+ finished;
140
+
141
+ // deliberately untracked: the clock mutates inside render-adjacent
142
+ // computations (the pass barrier, engine frames), where a tracked write
143
+ // after a read is an error. Reactive mirrors can wrap this handle later.
144
+ master = 0;
145
+ playing = true;
146
+ parkedAt = null;
147
+ rate = 1;
148
+ scale = motionSpeed();
149
+ standing;
150
+ total;
151
+ gates;
152
+ tracks = [];
153
+ rowEnd = new Map();
154
+ released = new Set();
155
+ pending;
156
+ options;
157
+ borrowedValues = [];
158
+ /** rest is what releaseForMeasure jumps to: 0 for translates, 1 for scales, 'none' for clips */
159
+ movedValues = [];
160
+ /** what the last releaseForMeasure retired, so a kept pass can put it back */
161
+ retired = {
162
+ borrowed: [],
163
+ moved: []
164
+ };
165
+ owned = [];
166
+ cancelled = false;
167
+ ended = false;
168
+ resolveFinished;
169
+ ticking = false;
170
+ autoTimer;
171
+ constructor(compiled, options) {
172
+ this.options = options;
173
+ this.camera = options.camera ?? {
174
+ x: 0,
175
+ y: 0,
176
+ zoom: 1
177
+ };
178
+ this.camera3d = options.camera3d ?? {
179
+ dolly: 1,
180
+ pitch: 0,
181
+ x: 0,
182
+ y: 0,
183
+ yaw: 0
184
+ };
185
+ this.initial3d = {
186
+ ...this.camera3d
187
+ };
188
+ this.cameraAim = options.cameraAim ?? null;
189
+ // frozen: the fold a random-access seek reconstructs the camera from
190
+ this.initialCamera = {
191
+ ...this.camera
192
+ };
193
+ this.initialAim = this.cameraAim ? {
194
+ ...this.cameraAim
195
+ } : null;
196
+ this.cues = compiled.cues;
197
+ this.pending = new Set(options.removed);
198
+ const s = this.scale;
199
+ this.gates = compiled.gates.map(g => ({
200
+ at: g.at * s,
201
+ auto: g.auto === undefined ? undefined : g.auto * s,
202
+ opened: false
203
+ }));
204
+ this.standing = compiled.open ?? false;
205
+ let total = 0;
206
+ for (const cue of this.cues) {
207
+ const start = cue.start * s;
208
+ const duration = cue.duration * s;
209
+ this.tracks.push({
210
+ cue,
211
+ end: start + duration,
212
+ passed: false,
213
+ start,
214
+ started: false
215
+ });
216
+ // an ambient loop plays past the run's end and a STANDING cue has no
217
+ // end at all: neither may lend the run a length
218
+ if (!cue.loop && !cue.standing) {
219
+ total = Math.max(total, start + duration);
220
+ }
221
+ // …and neither may hold a leaver's row open forever
222
+ this.rowEnd.set(cue.sprite, Math.max(this.rowEnd.get(cue.sprite) ?? 0, cue.standing ? start : start + duration));
223
+ }
224
+ for (const g of this.gates) {
225
+ total = Math.max(total, g.at);
226
+ }
227
+ // a counterpart lives at least as long as the sprite that replaced it
228
+ for (const [sprite, end] of this.rowEnd) {
229
+ if (sprite.counterpart) {
230
+ this.rowEnd.set(sprite.counterpart, Math.max(this.rowEnd.get(sprite.counterpart) ?? 0, end));
231
+ }
232
+ }
233
+ this.total = total;
234
+ this.finished = new Promise(resolve => {
235
+ this.resolveFinished = resolve;
236
+ });
237
+ this.pinStarts();
238
+ // everything at t=0 starts NOW, synchronously — holds land and first
239
+ // keyframes pin before the browser paints the destination layout
240
+ this.evaluate();
241
+ this.startTicking();
242
+ activeRuns.add(this);
243
+ void this.finished.then(() => activeRuns.delete(this));
244
+ }
245
+
246
+ /* ---- the public face, in seconds at 1× ---- */
247
+
248
+ get duration() {
249
+ return this.total / this.scale / 1000;
250
+ }
251
+ get paused() {
252
+ return !this.playing;
253
+ }
254
+ get time() {
255
+ return this.master / this.scale / 1000;
256
+ }
257
+ set time(seconds) {
258
+ this.seekTo(seconds * 1000 * this.scale);
259
+ }
260
+ get speed() {
261
+ return this.rate;
262
+ }
263
+ set speed(rate) {
264
+ this.rate = rate;
265
+ for (const t of this.tracks) {
266
+ t.controls?.forEach(c => c.speed = rate);
267
+ if (t.delivery) {
268
+ t.delivery.speed(rate);
269
+ }
270
+ if (t.loopAnimation) {
271
+ t.loopAnimation.playbackRate = rate;
272
+ }
273
+ if (t.cameraAnimation) {
274
+ t.cameraAnimation.playbackRate = rate;
275
+ }
276
+ }
277
+ }
278
+ get segment() {
279
+ let n = 0;
280
+ for (const g of this.gates) {
281
+ if (this.master > g.at || this.master === g.at && g.opened) {
282
+ n++;
283
+ }
284
+ }
285
+ return n;
286
+ }
287
+ get parked() {
288
+ return this.parkedAt !== null;
289
+ }
290
+
291
+ /**
292
+ * ATTACH: write each attached region's clock from this one's.
293
+ *
294
+ * Several windows may name one region — a film's every beat has a plate
295
+ * window, and the plate is one region re-keyed per beat — so the run
296
+ * resolves ONE governing window per region on every evaluate: the window
297
+ * that holds `now`; else the latest past one, if it holds; else the
298
+ * earliest future one, which stands the child at its head (Remotion's
299
+ * premount: mounted early, clock frozen at `in`). The child's run is
300
+ * paused and told `in + (now − start) × rate`. No history is kept on
301
+ * either side, which is what makes a seek into a window land where
302
+ * playing there would.
303
+ */
304
+ driveAttachments(now) {
305
+ let governing;
306
+ for (const t of this.tracks) {
307
+ const a = t.cue.attach;
308
+ if (!a || t.refused) {
309
+ continue;
310
+ }
311
+ governing ??= new Map();
312
+ const g = governing.get(a.region);
313
+ if (!g) {
314
+ governing.set(a.region, t);
315
+ continue;
316
+ }
317
+ const rank = x => now >= x.start && now < x.end ? 2 : now >= x.end ? 1 : 0;
318
+ const rt = rank(t);
319
+ const rg = rank(g);
320
+ if (rt > rg || rt === rg && rt === 1 && t.end > g.end || rt === rg && rt === 0 && t.start < g.start) {
321
+ governing.set(a.region, t);
322
+ }
323
+ }
324
+ if (!governing) {
325
+ return;
326
+ }
327
+ for (const [region, t] of governing) {
328
+ const a = t.cue.attach;
329
+ const child = choreoHostById(region)?.currentRun() ?? null;
330
+ if (!child || child === this) {
331
+ continue;
332
+ }
333
+ if (a.exact && child.hasIntegrator()) {
334
+ // refused ONCE, loudly, and never driven: the film goes on, the
335
+ // attachment stands dead, and the error names the region
336
+ t.refused = true;
337
+ console.error(`choreo: <Choreo "${region}"> is attached under an exact clock, ` + 'but its score integrates (a spring or a follow): its state is ' + 'its history, and a driven clock has none');
338
+ continue;
339
+ }
340
+ let local;
341
+ if (now < t.start) {
342
+ local = a.in;
343
+ } else if (now < t.end) {
344
+ local = a.in + (now - t.start) / 1000 / this.scale * a.rate;
345
+ } else if (a.end === 'hold') {
346
+ local = a.in + t.cue.duration / 1000 * a.rate;
347
+ } else {
348
+ continue;
349
+ }
350
+ // PLAY WHEN PLAYING, SEEK WHEN SEEKING. A parent that is playing
351
+ // lets the child play too — natively, on the compositor, as smooth
352
+ // as it ever was — and corrects it only past a frame of drift; a
353
+ // parent that is paused or scrubbed holds the child and writes its
354
+ // clock. Both are the same statement of where the child stands at
355
+ // `now`; only the means differ, and the means is what smoothness
356
+ // is made of.
357
+ const inside = now >= t.start && now < t.end;
358
+ if (this.playing && inside && !this.ended) {
359
+ if (child.paused) {
360
+ child.time = local;
361
+ child.play();
362
+ } else if (Math.abs(child.time - local) > 1 / 30) {
363
+ child.time = local;
364
+ }
365
+ const speed = this.rate * a.rate;
366
+ if (Math.abs(child.speed - speed) > 1e-6) {
367
+ child.speed = speed;
368
+ }
369
+ continue;
370
+ }
371
+ if (!child.paused) {
372
+ child.pause();
373
+ }
374
+ if (Math.abs(child.time - local) > 1e-4) {
375
+ child.time = local;
376
+ }
377
+ }
378
+ }
379
+ /** does this run's score integrate — a spring, a follow — so that its state is its history? */
380
+ hasIntegrator() {
381
+ return this.tracks.some(t => t.cue.kind === 'spring' || !!t.cue.derive);
382
+ }
383
+ isDone() {
384
+ return this.ended || this.cancelled;
385
+ }
386
+ midflight() {
387
+ const out = new Map();
388
+ if (this.isDone()) {
389
+ return out;
390
+ }
391
+ for (const t of this.tracks) {
392
+ const {
393
+ cue
394
+ } = t;
395
+ if (!t.started || t.passed) {
396
+ continue;
397
+ }
398
+ const geometry = cue.flight !== undefined || cue.target !== undefined && MIDFLIGHT_KEYS.some(key => key in cue.target);
399
+ if (geometry) {
400
+ out.set(cue.sprite.node, cue.transition);
401
+ }
402
+ }
403
+ return out;
404
+ }
405
+
406
+ /* ---- transport ---- */
407
+
408
+ pause() {
409
+ if (!this.playing) {
410
+ return;
411
+ }
412
+ this.playing = false;
413
+ this.setPaused(true);
414
+ // a paused parent holds what it drives, where the clock stands
415
+ this.driveAttachments(this.master);
416
+ this.scheduleRestill();
417
+ }
418
+ play() {
419
+ if (this.playing || this.ended || this.cancelled) {
420
+ return;
421
+ }
422
+ if (this.parkedAt) {
423
+ return; // parked is a gate's business; advance() opens it
424
+ }
425
+ this.playing = true;
426
+ this.setPaused(false);
427
+ this.startTicking();
428
+ }
429
+ advance() {
430
+ if (this.ended || this.cancelled) {
431
+ return;
432
+ }
433
+ const gate = this.parkedAt;
434
+ if (gate) {
435
+ // open the gate and go
436
+ gate.opened = true;
437
+ this.parkedAt = null;
438
+ clearTimeout(this.autoTimer);
439
+ this.playing = true;
440
+ this.setPaused(false);
441
+ this.startTicking();
442
+ return;
443
+ }
444
+ // mid-segment: complete it instantly and park at the gate (§4.1) —
445
+ // Keynote's click-through. Past the last gate, complete the run.
446
+ const next = this.gates.find(g => !g.opened && g.at >= this.master);
447
+ this.seekTo(next ? next.at : this.total);
448
+ if (!next && this.master >= this.total && !this.standing) {
449
+ this.finish();
450
+ }
451
+ }
452
+ retreat() {
453
+ if (this.cancelled) {
454
+ return false;
455
+ }
456
+ // the boundary behind the clock: parked AT a gate, the build behind
457
+ // is the previous one; mid-segment, it is the start of this segment
458
+ const eps = 1e-6;
459
+ const behind = this.gates.filter(g => g.at < this.master - eps);
460
+ const target = behind[behind.length - 1];
461
+ if (!target) {
462
+ return false;
463
+ }
464
+ clearTimeout(this.autoTimer);
465
+ // everything at or ahead of the landing replays forward from here —
466
+ // an opened gate left open would let the next advance() sail PAST
467
+ // the un-built segment instead of playing it
468
+ for (const g of this.gates) {
469
+ if (g.at >= target.at) {
470
+ g.opened = false;
471
+ }
472
+ }
473
+ // a finished run has state behind it too: un-end so advance() works.
474
+ // (Leavers a finish released stay released — a retreat un-builds the
475
+ // score, not the exits the region already settled.)
476
+ this.ended = false;
477
+ // Land a half-millisecond SHY of the gate, not on it: a backward
478
+ // still restores a cleared cue's committed finals only when the
479
+ // playhead stands strictly before its start, and half a millisecond
480
+ // is invisible while advance() — which opens the gate first — plays
481
+ // straight through it.
482
+ this.master = Math.max(0, target.at - 0.5);
483
+ this.playing = false;
484
+ this.setPaused(true);
485
+ this.evaluate();
486
+ this.park(target, false);
487
+ return true;
488
+ }
489
+
490
+ /* ---- the clock ---- */
491
+
492
+ startTicking() {
493
+ if (this.ticking || !this.playing || this.cancelled || this.ended) {
494
+ return;
495
+ }
496
+ this.ticking = true;
497
+ frame.update(this.tick, true);
498
+ }
499
+ stopTicking() {
500
+ if (this.ticking) {
501
+ this.ticking = false;
502
+ cancelFrame(this.tick);
503
+ }
504
+ }
505
+ tick = data => {
506
+ if (!this.playing || this.cancelled || this.ended) {
507
+ this.stopTicking();
508
+ return;
509
+ }
510
+ const next = this.master + data.delta * this.rate;
511
+ const gate = this.gates.find(g => !g.opened && g.at >= this.master && g.at <= next);
512
+ if (gate) {
513
+ this.master = gate.at;
514
+ // the boundary is exclusive: a cue starting AT the gate is the next
515
+ // segment's first word, and must not begin while the run parks
516
+ this.evaluate(gate.at);
517
+ this.park(gate);
518
+ return;
519
+ }
520
+ this.master = next;
521
+ this.evaluate();
522
+ // playing forward is the only clock that releases a leaver — a scrub is
523
+ // a still and must stay reversible
524
+ for (const [sprite, end] of this.rowEnd) {
525
+ if (this.master >= end && !this.released.has(sprite)) {
526
+ this.released.add(sprite);
527
+ if (this.pending.delete(sprite)) {
528
+ this.options.onSpriteDone(sprite);
529
+ }
530
+ }
531
+ }
532
+ // A standing run has played everything it has and still is not over:
533
+ // its open cues are the picture, and they hold until something replaces
534
+ // them. It keeps ticking so the wires it draws keep following.
535
+ if (this.master >= this.total && !this.standing) {
536
+ this.finish();
537
+ }
538
+ };
539
+ park(gate, auto = true) {
540
+ gate.opened = false;
541
+ this.parkedAt = gate;
542
+ this.playing = false;
543
+ this.stopTicking();
544
+ /**
545
+ * HAND BACK THE LEAVERS, but only when nobody is left to open the gate.
546
+ *
547
+ * `pending` is removed sprites and nothing else — elements a <Presence>
548
+ * is holding in the document only because this run undertook to see them
549
+ * out. Parking stops the clock (above), so from here nothing advances:
550
+ * every row end still ahead of `master` is unreachable, `onSpriteDone`
551
+ * can never fire for it, and `finished` never resolves. The Presence
552
+ * waits on a beat that is not coming.
553
+ *
554
+ * `doomed` is what separates that from an ordinary build, and the
555
+ * distinction is real rather than a hedge. A gated build parks with the
556
+ * same sprites pending and means it: the slide is still on screen, the
557
+ * click is still coming, and the leaver is meant to fly out when it
558
+ * arrives — releasing it there would make the item vanish instead, which
559
+ * is the animation the registration exists to protect. A region where
560
+ * every participant is leaving has no slide left to click on. Nothing
561
+ * stays, so nothing can ask, and the gate is waiting on an event that
562
+ * cannot happen.
563
+ *
564
+ * That is not hypothetical. Rack's score is a gate followed by one
565
+ * parallel over sixty-one tiles, held still until its slider asks — so
566
+ * filtering its card out of the gallery compiled a score that parked at
567
+ * t=0 with all sixty-one of its participants pending and a clock that
568
+ * never moved. Because <Presence> releases its leavers as a BATCH, those
569
+ * sixty-one held all forty cards leaving beside them: under the default
570
+ * mode the survivors never closed up, and under popLayout forty
571
+ * invisible cards stayed lifted over the grid taking clicks meant for
572
+ * the two you could see.
573
+ *
574
+ * A parked run is a STILL, in this file's own words — settled, not in
575
+ * flight. A still is not an exit animation, so there is nothing here to
576
+ * cut short. If the gate is ever opened the elements are simply gone,
577
+ * which is what leaving means.
578
+ */
579
+ if (this.options.doomed) {
580
+ for (const sprite of [...this.pending]) {
581
+ this.pending.delete(sprite);
582
+ this.options.onSpriteDone(sprite);
583
+ }
584
+ }
585
+ // the engine's clock and this one drift by a frame: anything that ends
586
+ // at or before the gate LANDS — a parked segment is complete, exactly
587
+ // (§4.1's click-through rule, applied to the natural arrival too)
588
+ for (const t of this.tracks) {
589
+ if (t.started && !t.cue.loop && !t.cue.hold && t.end <= this.master) {
590
+ this.completeTrack(t);
591
+ }
592
+ }
593
+ this.setPaused(true);
594
+ this.scheduleRestill();
595
+ // a @delay gate opens itself only when the clock ARRIVES forward: a
596
+ // park reached by stepping back holds until the user advances — the
597
+ // Keynote rule. Without this, backing onto a self-opening gate
598
+ // replayed the build on its own a beat later.
599
+ if (auto && gate.auto !== undefined) {
600
+ this.autoTimer = setTimeout(() => {
601
+ if (this.parkedAt === gate && !this.cancelled) {
602
+ this.advance();
603
+ }
604
+ }, gate.auto / this.rate);
605
+ }
606
+ }
607
+ finish() {
608
+ if (this.ended) {
609
+ return;
610
+ }
611
+ this.ended = true;
612
+ this.stopTicking();
613
+ // the windows it drove are past: hold or leave them, per their policy
614
+ this.driveAttachments(this.master);
615
+ this.options.onCamera?.({
616
+ ...this.camera
617
+ });
618
+ // land every remaining final before handing the layout back; ambient
619
+ // loops play on until the next pass cancels them
620
+ for (const t of this.tracks) {
621
+ if (t.started && !t.cue.loop && !t.cue.hold && !t.cue.delivery) {
622
+ this.completeTrack(t);
623
+ }
624
+ }
625
+ // the stylesheet owns the elements again between runs
626
+ this.releaseForMeasure();
627
+ for (const sprite of [...this.pending]) {
628
+ this.pending.delete(sprite);
629
+ this.options.onSpriteDone(sprite);
630
+ }
631
+ this.resolveFinished();
632
+ }
633
+
634
+ /* ---- cue lifecycle against the clock ---- */
635
+
636
+ setPaused(paused) {
637
+ const now = this.master;
638
+ for (const t of this.tracks) {
639
+ if (!t.started) {
640
+ continue;
641
+ }
642
+ // resume only what the clock is inside — an animation kept alive for
643
+ // scrubbing must not play before its start or after its end
644
+ const due = now >= t.start && (now < t.end || t.cue.loop);
645
+ const run = !paused && due;
646
+ // Touch only live animations. An accelerated animation that FINISHED
647
+ // has already committed its finals and been cancelled — and the WAAPI
648
+ // pause procedure RESURRECTS a cancelled animation, parked at zero
649
+ // with fill:both, its FIRST keyframe overriding the committed finals.
650
+ // That is how the Playhead's receipt reappeared at the park: pausing
651
+ // the run paused its long-finished [1→0] spring, which came back as
652
+ // a standing opacity 1. play() on a finished control is the same
653
+ // hazard in the other direction: it restarts the spring from zero.
654
+ t.controls?.forEach(c => {
655
+ if (run) {
656
+ if (c.state === 'running' || c.state === 'paused') {
657
+ c.play();
658
+ }
659
+ } else if (c.state === 'running') {
660
+ c.pause();
661
+ }
662
+ });
663
+ if (t.delivery) {
664
+ (run ? t.delivery.play : t.delivery.pause)();
665
+ }
666
+ if (t.loopAnimation) {
667
+ if (run) {
668
+ t.loopAnimation.play();
669
+ } else {
670
+ t.loopAnimation.pause();
671
+ }
672
+ }
673
+ if (t.cameraAnimation) {
674
+ if (run) {
675
+ t.cameraAnimation.play();
676
+ } else {
677
+ t.cameraAnimation.pause();
678
+ }
679
+ }
680
+ }
681
+ }
682
+ seekTo(target) {
683
+ // setting time across an unopened gate parks at the gate (§4.6)
684
+ const gate = this.gates.find(g => !g.opened && g.at >= this.master && g.at <= target);
685
+ const clamped = Math.max(0, Math.min(gate ? gate.at : target, this.total));
686
+ this.master = clamped;
687
+ // a finished run seeked back inside itself is a run again: an
688
+ // attachment driven past its window and back must be playable, and
689
+ // a retreat already un-ends for the same reason
690
+ if (this.ended && clamped < this.total) {
691
+ this.ended = false;
692
+ }
693
+ // seeking is a still: hold the transport
694
+ const wasPlaying = this.playing;
695
+ this.playing = false;
696
+ this.setPaused(true);
697
+ this.reconstructCameraAt(gate ? gate.at : Infinity);
698
+ this.evaluate(gate ? gate.at : Infinity);
699
+ if (gate) {
700
+ this.park(gate);
701
+ return;
702
+ }
703
+ if (wasPlaying && !this.ended) {
704
+ this.playing = true;
705
+ this.setPaused(false);
706
+ this.startTicking();
707
+ return;
708
+ }
709
+ this.scheduleRestill();
710
+ }
711
+
712
+ /**
713
+ * The one cost WAAPI acceleration charges a scrubbable run: a finished
714
+ * accelerated animation commits its finals asynchronously, and that
715
+ * commit can land AFTER a still's jump and overwrite it. So a still is
716
+ * re-asserted on the two frames after it is entered — by then any
717
+ * pending commit has landed, and the computed frame wins again.
718
+ */
719
+ restillPending = 0;
720
+ scheduleRestill() {
721
+ if (this.restillPending > 0) {
722
+ return;
723
+ }
724
+ this.restillPending = 2;
725
+ const pass = () => {
726
+ if (this.playing || this.cancelled) {
727
+ this.restillPending = 0;
728
+ return;
729
+ }
730
+ this.restill();
731
+ this.restillPending -= 1;
732
+ if (this.restillPending > 0) {
733
+ frame.postRender(pass);
734
+ }
735
+ };
736
+ frame.postRender(pass);
737
+ }
738
+ restill() {
739
+ const now = this.master;
740
+ const rewinds = [];
741
+ for (const t of this.tracks) {
742
+ const {
743
+ cue
744
+ } = t;
745
+ const ve = cue.sprite.node.visualElement;
746
+ if (!ve || cue.camera || cue.tether) {
747
+ continue;
748
+ }
749
+ if (!t.started && t.origin && now < t.start) {
750
+ // scrubbed back before its start: stand the origin up again —
751
+ // deferred and ordered with the others (see rewind())
752
+ rewinds.push(t);
753
+ } else if (t.started && !t.controls && !t.delivery && !t.loopAnimation && (cue.target || cue.flight) && now >= t.start && now < t.end) {
754
+ this.sampleStill(t, now);
755
+ }
756
+ }
757
+ this.rewind(rewinds, now);
758
+ }
759
+ evaluate(limit = Infinity) {
760
+ const now = this.master;
761
+ const rewinds = [];
762
+ /**
763
+ * The shot is FOLDED, not remembered.
764
+ *
765
+ * Every other cue kind owns an element and can be started and rewound
766
+ * in place. A camera3d cue owns one shared triple, and per-cue state
767
+ * does not survive that: seeking back from cue two to inside cue one
768
+ * runs cue one's lerp (correct) and then cue two's rewind (which
769
+ * restores the pose cue two INHERITED — cue one's end), and the
770
+ * second write wins because it comes later in the list. The shot
771
+ * ended up at the boundary value for every seek backwards.
772
+ *
773
+ * So it is recomputed from the score on every evaluate: start at the
774
+ * pose the run inherited, walk the cues in order, and let each one
775
+ * pass through, lerp, or land. A relative cue resolves against
776
+ * whatever the walk has accumulated by the time it is reached, which
777
+ * is precisely "the pose in force at cue start" — and it means a
778
+ * direct seek is identical to having played there, by construction
779
+ * rather than by bookkeeping.
780
+ */
781
+ let shot = null;
782
+ for (const t of this.tracks) {
783
+ const {
784
+ cue
785
+ } = t;
786
+ // a completed track scrubbed back into range plays again; its
787
+ // animation was kept, so only the flag moves
788
+ if (t.passed && now < t.end) {
789
+ t.passed = false;
790
+ if (now < t.start && t.origin) {
791
+ // completed, and the jump cleared its start entirely: its
792
+ // committed finals must not stand — restored with the others
793
+ rewinds.push(t);
794
+ }
795
+ }
796
+ const fill = cue.hold?.fill ?? false;
797
+ const inside = now >= t.start && t.start < limit && (now < t.end || cue.loop || fill);
798
+ if (cue.camera) {
799
+ if (inside && !t.started) {
800
+ t.started = true;
801
+ t.cameraFrom = {
802
+ ...this.camera
803
+ };
804
+ // Transform-origin is pinned at 0 0, so aiming at P is the
805
+ // translate (1−z)·P and the frame's centre cancels out of the
806
+ // model entirely. The aim point is frozen per cue and lerped
807
+ // from the point in force — a re-aim is smooth by construction,
808
+ // and z = 1 is the identity frame for any aim.
809
+ //
810
+ // A cue with NO origin of its own holds the aim in force rather
811
+ // than recentring: zooming back out of a dive must back straight
812
+ // out of the tile it dived on — an aim lerping toward the centre
813
+ // mid-flight reads as sliding onto the NEIGHBOURING tile first.
814
+ const aimTo = cue.camera.origin ?? this.cameraAim ?? cue.camera.centre ?? {
815
+ x: 0,
816
+ y: 0
817
+ };
818
+ t.aimFrom = this.cameraAim ?? aimTo;
819
+ t.aimTo = aimTo;
820
+ this.cameraAim = aimTo;
821
+ t.cameraTo = ChoreoRun.resolveCameraTo(cue.camera, t.cameraFrom);
822
+ } else if (!inside && t.started && now < t.start) {
823
+ t.started = false;
824
+ t.cameraTo = undefined;
825
+ t.cameraAnimation?.cancel();
826
+ t.cameraAnimation = undefined;
827
+ if (t.cameraFrom) {
828
+ this.camera = {
829
+ ...t.cameraFrom
830
+ };
831
+ this.cameraAim = t.aimFrom ?? null;
832
+ t.cameraP = 0;
833
+ this.applyCamera(t);
834
+ }
835
+ }
836
+ if (t.started && (now < t.end || !t.passed)) {
837
+ // the SHADOW state: interpolated every evaluate regardless of who
838
+ // draws, so interruption, onCamera and the next run always have a
839
+ // current value to read without decomposing a matrix. Past the
840
+ // end, progress is EXACTLY 1 — a spring sampled at its end time is
841
+ // 0.99998 of the way there, and a camera that lands beside its
842
+ // target instead of on it re-arms every subsequent pass: the
843
+ // landing renders, the replay chases the miss, and the equality
844
+ // guard downstream never sees the same state twice.
845
+ const p = now >= t.end ? 1 : this.cameraProgress(t, now - t.start);
846
+ const from = t.cameraFrom;
847
+ const to = t.cameraTo ?? ChoreoRun.resolveCameraTo(cue.camera, from);
848
+ t.cameraP = p;
849
+ this.camera = {
850
+ x: from.x + (to.x - from.x) * p,
851
+ y: from.y + (to.y - from.y) * p,
852
+ zoom: from.zoom + (to.zoom - from.zoom) * p
853
+ };
854
+ // the platform draws the frame while playing (§6.3 on WAAPI): the
855
+ // whole scene composites off the main thread. Stills stay inline.
856
+ if (this.playing && now < t.end) {
857
+ if (!t.cameraAnimation) {
858
+ this.startCameraAnimation(t, now);
859
+ } else if (t.cameraAnimation.playState === 'paused') {
860
+ t.cameraAnimation.play();
861
+ }
862
+ } else if (t.cameraAnimation) {
863
+ if (now >= t.end) {
864
+ // landing: the animation ends and the inline style takes over
865
+ this.dropCameraAnimation(t);
866
+ } else {
867
+ t.cameraAnimation.pause();
868
+ t.cameraAnimation.currentTime = Math.max(0, now - t.start);
869
+ }
870
+ }
871
+ this.applyCamera(t);
872
+ if (now >= t.end && this.playing && !t.passed) {
873
+ t.passed = true;
874
+ this.options.onCamera?.({
875
+ ...this.camera
876
+ });
877
+ }
878
+ }
879
+ continue;
880
+ }
881
+ if (cue.camera3d) {
882
+ const from = shot ?? {
883
+ ...this.initial3d
884
+ };
885
+ const through = cue.camera3d.through;
886
+ if (through?.length) {
887
+ /**
888
+ * A PATH: the pose in force and the waypoints are the control
889
+ * points of a Catmull-Rom spline, sampled at the cue's eased
890
+ * progress. One clock over the whole chain, so the camera
891
+ * CROSSES each waypoint with continuous velocity instead of
892
+ * parking at it — and it stays a pure function of the clock,
893
+ * which no per-frame integrator can claim.
894
+ */
895
+ if (now <= t.start) {
896
+ shot = from;
897
+ } else {
898
+ const p = now >= t.end ? 1 : this.cameraProgress(t, now - t.start);
899
+ const settle = cue.camera3d.settle;
900
+ const span = t.end - t.start;
901
+ shot = settle && span > 0 ? settleThrough(from, through, p, cue.camera3d.tension, settle * 1000 / span) : sampleThrough(from, through, p, cue.camera3d.tension);
902
+ }
903
+ continue;
904
+ }
905
+ const want = cue.camera3d.to;
906
+ const fromLook = from.look;
907
+ const to = cue.camera3d.by ? {
908
+ dolly: from.dolly * (want.dolly ?? 1),
909
+ look: want.look ? {
910
+ x: (fromLook?.x ?? 0) + want.look.x,
911
+ y: (fromLook?.y ?? 0) + want.look.y,
912
+ z: (fromLook?.z ?? 0) + want.look.z
913
+ } : fromLook,
914
+ pitch: from.pitch + (want.pitch ?? 0),
915
+ x: from.x + (want.x ?? 0),
916
+ y: from.y + (want.y ?? 0),
917
+ yaw: from.yaw + (want.yaw ?? 0)
918
+ } : {
919
+ dolly: want.dolly ?? from.dolly,
920
+ look: want.look ?? fromLook,
921
+ pitch: want.pitch ?? from.pitch,
922
+ x: want.x ?? from.x,
923
+ y: want.y ?? from.y,
924
+ yaw: want.yaw ?? from.yaw
925
+ };
926
+ if (now <= t.start) {
927
+ shot = from; // not yet: the walk passes straight through
928
+ } else {
929
+ const p = now >= t.end ? 1 : this.cameraProgress(t, now - t.start);
930
+ shot = {
931
+ dolly: from.dolly + (to.dolly - from.dolly) * p,
932
+ pitch: from.pitch + (to.pitch - from.pitch) * p,
933
+ x: from.x + (to.x - from.x) * p,
934
+ y: from.y + (to.y - from.y) * p,
935
+ yaw: from.yaw + (to.yaw - from.yaw) * p
936
+ };
937
+ // the aim tweens with the pose: one clock, no side-channel
938
+ if (to.look || fromLook) {
939
+ const a = fromLook ?? {
940
+ x: 0,
941
+ y: 0,
942
+ z: 0
943
+ };
944
+ const b = to.look ?? a;
945
+ shot.look = {
946
+ x: a.x + (b.x - a.x) * p,
947
+ y: a.y + (b.y - a.y) * p,
948
+ z: a.z + (b.z - a.z) * p
949
+ };
950
+ }
951
+ }
952
+ continue;
953
+ }
954
+ if (cue.tether) {
955
+ if (inside && !t.started) {
956
+ t.started = true;
957
+ const layer = this.options.tetherLayer;
958
+ if (layer) {
959
+ t.wire = document.createElementNS('http://www.w3.org/2000/svg', 'path');
960
+ t.wire.setAttribute('data-choreo-tether', '');
961
+ if (t.cue.tether?.name) {
962
+ t.wire.setAttribute('data-thread', t.cue.tether.name);
963
+ }
964
+ layer.appendChild(t.wire);
965
+ }
966
+ } else if (!inside && t.started) {
967
+ t.started = false;
968
+ t.wire?.remove();
969
+ t.wire = undefined;
970
+ }
971
+ if (t.started && t.wire) {
972
+ // after the engine's own render this frame, so the wire sits
973
+ // inside the boxes as they are painted, not as they were
974
+ this.scheduleTetherDraw();
975
+ }
976
+ continue;
977
+ }
978
+ if (cue.derive) {
979
+ if (inside && !t.started) {
980
+ t.started = true;
981
+ // the keys this cue drives are registered as MOVED, with the
982
+ // rest the author declared: releaseForMeasure then jumps them
983
+ // back before a measurement, and reassert() puts them again —
984
+ // the same contract every flight already lives under
985
+ const ve = cue.sprite.node.visualElement;
986
+ if (ve) {
987
+ for (const [key, rest] of Object.entries(cue.derive.rest)) {
988
+ this.movedValues.push({
989
+ key,
990
+ rest,
991
+ ve
992
+ });
993
+ }
994
+ }
995
+ } else if (!inside && t.started) {
996
+ t.started = false;
997
+ this.restDerived(t);
998
+ }
999
+ if (t.started) {
1000
+ this.drive(t, now);
1001
+ }
1002
+ continue;
1003
+ }
1004
+ if (cue.raise) {
1005
+ if (inside && !t.started) {
1006
+ t.started = true;
1007
+ this.promote(t);
1008
+ } else if (!inside && t.started) {
1009
+ t.started = false;
1010
+ this.restore(t);
1011
+ }
1012
+ continue;
1013
+ }
1014
+ if (cue.scroll) {
1015
+ if (inside && !t.started) {
1016
+ t.started = true;
1017
+ this.startScroll(t);
1018
+ } else if (!inside && t.started && now < t.start) {
1019
+ t.started = false;
1020
+ this.stopScroll(t, true);
1021
+ }
1022
+ if (t.started && t.scrolling) {
1023
+ // clock-driven in every mode: a scroll has no engine animation
1024
+ const p = this.flightlessProgress(t, now - t.start);
1025
+ const {
1026
+ container,
1027
+ from,
1028
+ to
1029
+ } = t.scrolling;
1030
+ container.scrollTop = from.top + (to.top - from.top) * p;
1031
+ container.scrollLeft = from.left + (to.left - from.left) * p;
1032
+ if (p >= 1 && this.playing) {
1033
+ this.stopScroll(t, false);
1034
+ }
1035
+ }
1036
+ continue;
1037
+ }
1038
+ if (cue.hold) {
1039
+ if (inside && !t.started) {
1040
+ t.started = true;
1041
+ const ve = cue.sprite.node.visualElement;
1042
+ if (ve) {
1043
+ t.held = applyHold(ve, cue.sprite.element, cue.hold.values);
1044
+ }
1045
+ } else if (!inside && t.started) {
1046
+ t.started = false;
1047
+ const ve = cue.sprite.node.visualElement;
1048
+ if (ve && t.held) {
1049
+ releaseHold(ve, cue.sprite.element, t.held);
1050
+ t.held = undefined;
1051
+ }
1052
+ }
1053
+ continue;
1054
+ }
1055
+ if (cue.kind === 'wait') {
1056
+ continue;
1057
+ }
1058
+ if (inside && !t.started) {
1059
+ this.startTrack(t);
1060
+ } else if (inside && t.started && this.playing && !t.controls && !t.delivery && !t.loopAnimation && (cue.target || cue.flight)) {
1061
+ // a scrub retired the animation; play re-enters the window, so a
1062
+ // fresh one carries on from wherever the scrub left the values
1063
+ this.startTrack(t);
1064
+ } else if (!inside && t.started && now < t.start) {
1065
+ // scrubbed back before its start: retire the animation; the
1066
+ // origin is restored with the other rewinds AFTER the sweep, in
1067
+ // an order that leaves the right one standing (see rewind())
1068
+ t.passed = false;
1069
+ t.started = false;
1070
+ flushKeyframeResolvers();
1071
+ this.stopTrack(t);
1072
+ t.delivery?.cancel();
1073
+ t.delivery = undefined;
1074
+ t.loopAnimation?.cancel();
1075
+ t.loopAnimation = undefined;
1076
+ if (t.origin) {
1077
+ rewinds.push(t);
1078
+ }
1079
+ } else if (t.started && now >= t.end && !cue.loop && !this.playing) {
1080
+ // scrubbed (or parked) past its end: hold the finals
1081
+ this.completeTrack(t);
1082
+ } else if (!t.started && !t.passed && now >= t.end && !cue.loop) {
1083
+ // Never played and the clock is past it: land finals directly.
1084
+ // NOT gated on a paused clock — while playing, a coarse tick (a
1085
+ // throttled tab, a long main-thread stall) can jump clean over a
1086
+ // track's whole window, and a track that never starts never lands:
1087
+ // its seeded first keyframe (an arrival's opacity 0) would stand
1088
+ // forever.
1089
+ this.jumpFinals(t);
1090
+ }
1091
+ if (t.started && !this.playing && now >= t.start) {
1092
+ if (t.delivery) {
1093
+ t.delivery.pause();
1094
+ t.delivery.seek(now - t.start);
1095
+ } else if (t.loopAnimation) {
1096
+ t.loopAnimation.pause();
1097
+ const cycle = t.end - t.start;
1098
+ t.loopAnimation.currentTime = cycle ? (now - t.start) % cycle : 0;
1099
+ } else if (cue.target || cue.flight) {
1100
+ // a still frame is COMPUTED, not driven: the live animation (if
1101
+ // any) is retired and every value jumped to its sampled position —
1102
+ // deterministic, and immune to animation-lifecycle races
1103
+ if (t.controls) {
1104
+ flushKeyframeResolvers();
1105
+ this.stopTrack(t);
1106
+ }
1107
+ this.sampleStill(t, now);
1108
+ }
1109
+ }
1110
+ }
1111
+ // the folded shot, published once the whole score has been walked and
1112
+ // only when it actually moved — a host that redraws on every call
1113
+ // would redraw on every evaluate, playing or not
1114
+ if (shot) {
1115
+ const was = this.camera3d;
1116
+ if (was.yaw !== shot.yaw || was.pitch !== shot.pitch || was.dolly !== shot.dolly || was.x !== shot.x || was.y !== shot.y || was.look?.x !== shot.look?.x || was.look?.y !== shot.look?.y || was.look?.z !== shot.look?.z) {
1117
+ this.camera3d = shot;
1118
+ this.options.onCamera3D?.({
1119
+ ...shot
1120
+ });
1121
+ }
1122
+ }
1123
+ this.rewind(rewinds, now);
1124
+ this.driveAttachments(now);
1125
+ this.foldPerforms(limit);
1126
+ }
1127
+
1128
+ /** perform tracks whose commands the host currently holds (see foldPerforms) */
1129
+ performed = new Set();
1130
+ /** dispatches queued for after the render pass, in fold order */
1131
+ pendingPerforms = [];
1132
+ performFlushBooked = false;
1133
+
1134
+ /**
1135
+ * The command fold (§C4): the set of Perform cues at or before the
1136
+ * clock IS the commanded state. Every evaluate — a play tick and a seek
1137
+ * alike — re-derives the eligible set; growth dispatches the new
1138
+ * commands in time order, and any shrink (a backward seek) resets the
1139
+ * host first and replays the whole remaining prefix. Commands are
1140
+ * idempotent statements of state by contract (`lamp.on`, never a
1141
+ * toggle), which is exactly what makes the replay a re-derivation.
1142
+ *
1143
+ * Dispatches are DEFERRED past the render pass: the constructor's first
1144
+ * evaluate runs while Glimmer may still be rendering, and a command
1145
+ * exists to mutate application state. A reset collapses the queue — the
1146
+ * dropped dispatches were never seen, and the replay that follows
1147
+ * restates everything still standing.
1148
+ */
1149
+ foldPerforms(limit) {
1150
+ if (!this.options.onPerform && !this.options.onPerformReset) {
1151
+ return;
1152
+ }
1153
+ const now = this.master;
1154
+ const eligible = this.tracks.filter(t => t.cue.perform && now >= t.start && t.start < limit).sort((a, b) => a.start - b.start);
1155
+ const held = new Set(eligible);
1156
+ if ([...this.performed].some(t => !held.has(t))) {
1157
+ this.performed.clear();
1158
+ this.pendingPerforms.length = 0;
1159
+ this.pendingPerforms.push('reset');
1160
+ }
1161
+ for (const t of eligible) {
1162
+ if (this.performed.has(t)) {
1163
+ continue;
1164
+ }
1165
+ this.performed.add(t);
1166
+ const p = t.cue.perform;
1167
+ this.pendingPerforms.push({
1168
+ action: p.action,
1169
+ payload: p.payload,
1170
+ target: p.target,
1171
+ time: t.start / this.scale / 1000
1172
+ });
1173
+ }
1174
+ if (this.pendingPerforms.length && !this.performFlushBooked) {
1175
+ this.performFlushBooked = true;
1176
+ frame.postRender(() => {
1177
+ this.performFlushBooked = false;
1178
+ const queue = this.pendingPerforms;
1179
+ this.pendingPerforms = [];
1180
+ for (const item of queue) {
1181
+ if (item === 'reset') {
1182
+ this.options.onPerformReset?.();
1183
+ } else {
1184
+ this.options.onPerform?.(item);
1185
+ }
1186
+ }
1187
+ });
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Stand rewound tracks back on their origins — the part of a backward
1193
+ * seek that must be ORDERED. A sprite with sequential cues has one
1194
+ * origin per cue, each equal to the previous cue's landing; restored
1195
+ * in track order they clobber forward and the sprite ends up standing
1196
+ * on its LAST future cue's origin (the Playhead demo's hand parked on
1197
+ * 'place' after a scrub to zero; its receipt refusing to dismiss). So:
1198
+ * latest-first, which leaves the EARLIEST future cue's origin — the
1199
+ * value the timeline actually holds at `now` — standing; and a key
1200
+ * that any track AT or BEFORE `now` writes is not touched at all,
1201
+ * because that track's own sample or landing is the answer.
1202
+ */
1203
+ rewind(rewinds, now) {
1204
+ rewinds.sort((a, b) => b.start - a.start);
1205
+ for (const t of rewinds) {
1206
+ const ve = t.cue.sprite.node.visualElement;
1207
+ if (!ve || !t.origin) {
1208
+ continue;
1209
+ }
1210
+ let wrote = false;
1211
+ for (const key in t.origin) {
1212
+ const owned = this.tracks.some(s => s !== t && s.start <= now && s.cue.target !== undefined && key in s.cue.target && s.cue.sprite.node.visualElement === ve);
1213
+ if (!owned) {
1214
+ ve.getValue(key, t.origin[key]).jump(t.origin[key]);
1215
+ wrote = true;
1216
+ }
1217
+ }
1218
+ if (wrote) {
1219
+ ve.render();
1220
+ }
1221
+ }
1222
+ }
1223
+
1224
+ /** the eased progress of a plain clock-driven track */
1225
+ flightlessProgress(t, ms) {
1226
+ const span = t.end - t.start;
1227
+ const raw = span > 0 ? Math.min(1, Math.max(0, ms / span)) : 1;
1228
+ return easingDefinitionToFunction('easeInOut')(raw);
1229
+ }
1230
+
1231
+ /* ---- c.Camera3D: the shot, for a scene Choreo does not draw ---- */
1232
+
1233
+ /**
1234
+ * The orbit pose in force. Public for the same reason `camera` is: it is
1235
+ * hand-off state, and a replacement run must continue from where this
1236
+ * one left the shot rather than snapping back to the framing.
1237
+ */
1238
+ camera3d;
1239
+ initial3d;
1240
+
1241
+ /* ---- c.Camera: the region's frame (§6.3) ---- */
1242
+
1243
+ camera;
1244
+ /**
1245
+ * The aim point P in force, local px. With transform-origin pinned at
1246
+ * 0 0, the applied transform is translate(x + (1−z)·P) scale(z) — the
1247
+ * frame's centre cancels out of the algebra entirely, so neither a
1248
+ * board that reflows mid-cue nor a stage that changes height between
1249
+ * cues can move the picture: the formula reads no live DOM at all.
1250
+ * z = 1 is the identity for any P. Public because it is hand-off state:
1251
+ * the next run lerps from the point THIS run left aimed, exactly as it
1252
+ * inherits the camera.
1253
+ */
1254
+ cameraAim = null;
1255
+
1256
+ /**
1257
+ * The absolute pose a camera cue lands on, resolved against the pose it
1258
+ * starts from. Absolute fields pass through; missing fields hold; a
1259
+ * relative cue (Pan / SlowZoom) offsets and multiplies the pose in
1260
+ * force — which is why resolution happens AT START (live) or during the
1261
+ * prefix fold (reconstruction), never at compile.
1262
+ */
1263
+ static resolveCameraTo(cue, from) {
1264
+ const {
1265
+ by,
1266
+ to
1267
+ } = cue;
1268
+ return {
1269
+ x: by?.x !== undefined ? from.x + by.x : to.x ?? from.x,
1270
+ y: by?.y !== undefined ? from.y + by.y : to.y ?? from.y,
1271
+ zoom: by?.zoom !== undefined ? from.zoom * by.zoom : to.zoom ?? from.zoom
1272
+ };
1273
+ }
1274
+ cameraProgress(t, ms) {
1275
+ const span = t.end - t.start;
1276
+ const raw = span > 0 ? Math.min(1, Math.max(0, ms / span)) : 1;
1277
+ const transition = t.cue.transition;
1278
+ if (isSpring(transition)) {
1279
+ const generator = spring({
1280
+ keyframes: [0, 100],
1281
+ ...(transition ?? {})
1282
+ });
1283
+ return generator.next(ms / this.scale).value / 100;
1284
+ }
1285
+ return easingDefinitionToFunction(transition?.ease ?? 'easeInOut')(raw);
1286
+ }
1287
+
1288
+ /**
1289
+ * The camera and aim this run inherited, frozen at construction — the
1290
+ * fold origin every reconstruction starts from. Public because a
1291
+ * replacement run that re-executes the SAME score must inherit this
1292
+ * origin, not the pose in force: the pose in force is what the score's
1293
+ * prefix already produced, and folding the prefix from it applies every
1294
+ * relative cue twice (the world-dock law).
1295
+ */
1296
+ initialCamera;
1297
+ initialAim;
1298
+
1299
+ /**
1300
+ * Rebuild the camera fold from the score prefix before a seek's still
1301
+ * (notes/external-clock-camera-seek-handoff.md). Forward playback folds
1302
+ * each camera cue's landing into the run's cumulative camera as the
1303
+ * clock crosses it — but a random-access seek can jump clean over a
1304
+ * window, and a cue that never starts never folds: the clock reads `t`
1305
+ * while the picture holds an earlier shot. So every camera track's
1306
+ * bookkeeping (started, cameraFrom, aimFrom/aimTo, cameraP) is
1307
+ * re-derived here in timeline order from the run's initial camera;
1308
+ * evaluate() then recomputes exactly the numbers forward playback would
1309
+ * have produced, and play-after-scrub resumes from them. Pure
1310
+ * arithmetic over frozen numbers, like the rest of the camera: no DOM
1311
+ * is read, so backward and repeated seeks agree to the pixel.
1312
+ */
1313
+ reconstructCameraAt(limit) {
1314
+ const cams = this.tracks.filter(t => t.cue.camera);
1315
+ if (!cams.length) {
1316
+ return;
1317
+ }
1318
+ const now = this.master;
1319
+ cams.sort((a, b) => a.start - b.start);
1320
+ const state = {
1321
+ ...this.initialCamera
1322
+ };
1323
+ let aim = this.initialAim ? {
1324
+ ...this.initialAim
1325
+ } : null;
1326
+ for (const t of cams) {
1327
+ if (!(now >= t.start && t.start < limit)) {
1328
+ // ahead of the playhead (or behind an unopened gate): this shot
1329
+ // contributes nothing, and whatever it acquired on an earlier
1330
+ // pass is stale — including a platform animation mid-flight
1331
+ t.started = false;
1332
+ t.passed = false;
1333
+ t.cameraP = 0;
1334
+ t.cameraTo = undefined;
1335
+ t.aimFrom = aim ?? undefined;
1336
+ t.aimTo = aim ?? undefined;
1337
+ t.cameraAnimation?.cancel();
1338
+ t.cameraAnimation = undefined;
1339
+ continue;
1340
+ }
1341
+ const cue = t.cue.camera;
1342
+ // the same aim algebra evaluate() uses at a live start: a cue with
1343
+ // no origin of its own holds the point in force
1344
+ const aimTo = cue.origin ?? aim ?? cue.centre ?? {
1345
+ x: 0,
1346
+ y: 0
1347
+ };
1348
+ t.started = true;
1349
+ // passed is cleared so evaluate() re-applies this shot's still —
1350
+ // a track that had already passed under live play paints nothing
1351
+ t.passed = false;
1352
+ t.cameraFrom = {
1353
+ ...state
1354
+ };
1355
+ t.cameraTo = ChoreoRun.resolveCameraTo(cue, t.cameraFrom);
1356
+ t.aimFrom = aim ?? aimTo;
1357
+ t.aimTo = aimTo;
1358
+ aim = aimTo;
1359
+ if (now >= t.end) {
1360
+ state.x = t.cameraTo.x;
1361
+ state.y = t.cameraTo.y;
1362
+ state.zoom = t.cameraTo.zoom;
1363
+ t.cameraP = 1;
1364
+ // its flight is over: the inline still owns the frame again
1365
+ t.cameraAnimation?.cancel();
1366
+ t.cameraAnimation = undefined;
1367
+ }
1368
+ }
1369
+ this.camera = state;
1370
+ this.cameraAim = aim;
1371
+ // evaluate() repaints through every started track; only a seek that
1372
+ // leaves NO shot behind the playhead must put the frame back itself,
1373
+ // or the last-painted transform would stand at a time before any cue
1374
+ if (!cams.some(t => t.started)) {
1375
+ this.applyCamera(cams[0]);
1376
+ }
1377
+ }
1378
+
1379
+ /**
1380
+ * The damped counter-scale (§6.4): steady sprites scale with the host but
1381
+ * not 1:1 — pow(z, .30) zoomed out so they stay readable, pow(z, .70)
1382
+ * zoomed in so they don't feel stuck, clamped to what the eye tolerates.
1383
+ */
1384
+ static damped(zoom) {
1385
+ const target = Math.min(1.8, Math.max(0.85, Math.pow(zoom, zoom < 1 ? 0.3 : 0.7)));
1386
+ return target / zoom;
1387
+ }
1388
+
1389
+ /**
1390
+ * The frame's APPLIED transform for a camera state at aim progress `p`.
1391
+ *
1392
+ * Transform-origin is pinned at 0 0, so the applied transform is
1393
+ * translate(x + (1−z)·P) scale(z) with P the lerped aim point — pure
1394
+ * arithmetic over frozen numbers. Nothing here reads the DOM, so a board
1395
+ * that reflows mid-cue cannot move the camera, and every boundary
1396
+ * (landing, re-aim, next run) agrees to the pixel. At z = 1 the aim term
1397
+ * vanishes: an unpanned camera is EXACTLY the identity.
1398
+ */
1399
+ appliedCamera(t, state, p) {
1400
+ const from = t.aimFrom ?? t.aimTo ?? {
1401
+ x: 0,
1402
+ y: 0
1403
+ };
1404
+ const to = t.aimTo ?? from;
1405
+ const aim = {
1406
+ x: from.x + (to.x - from.x) * p,
1407
+ y: from.y + (to.y - from.y) * p
1408
+ };
1409
+ return {
1410
+ x: state.x + (1 - state.zoom) * aim.x,
1411
+ y: state.y + (1 - state.zoom) * aim.y,
1412
+ zoom: state.zoom
1413
+ };
1414
+ }
1415
+ static cameraCss(a) {
1416
+ return a.zoom === 1 && a.x === 0 && a.y === 0 ? '' : `translate(${a.x}px, ${a.y}px) scale(${a.zoom})`;
1417
+ }
1418
+
1419
+ /**
1420
+ * Fly the region's frame on the platform: two transform keyframes and the
1421
+ * eased clock pre-sampled into a linear() easing. The biggest layer in the
1422
+ * region composites off the main thread for the whole move.
1423
+ */
1424
+ startCameraAnimation(t, now) {
1425
+ const host = this.options.cameraFrame;
1426
+ const from = t.cameraFrom;
1427
+ const cue = t.cue.camera;
1428
+ if (!host || !from || !cue) {
1429
+ return;
1430
+ }
1431
+ const final = t.cameraTo ?? ChoreoRun.resolveCameraTo(cue, from);
1432
+ const span = Math.max(1, t.end - t.start);
1433
+ const anim = host.animate([{
1434
+ transform: ChoreoRun.cameraCss(this.appliedCamera(t, from, 0)) || 'none'
1435
+ }, {
1436
+ transform: ChoreoRun.cameraCss(this.appliedCamera(t, final, 1)) || 'none'
1437
+ }], {
1438
+ duration: span,
1439
+ easing: generateLinearEasing(p => this.cameraProgress(t, p * span), span),
1440
+ fill: 'both'
1441
+ });
1442
+ anim.playbackRate = this.rate;
1443
+ anim.currentTime = Math.max(0, now - t.start);
1444
+ t.cameraAnimation = anim;
1445
+ }
1446
+
1447
+ /**
1448
+ * Retire the frame's platform animation and hand the picture back to
1449
+ * inline style, written from the shadow state — so a cancel mid-zoom
1450
+ * freezes exactly where the eye was, and a landing holds its final.
1451
+ */
1452
+ dropCameraAnimation(t) {
1453
+ if (!t.cameraAnimation) {
1454
+ return;
1455
+ }
1456
+ t.cameraAnimation.cancel();
1457
+ t.cameraAnimation = undefined;
1458
+ this.applyCamera(t);
1459
+ }
1460
+ applyCamera(t) {
1461
+ const frame = this.options.cameraFrame;
1462
+ if (!frame) {
1463
+ return;
1464
+ }
1465
+ const {
1466
+ zoom
1467
+ } = this.camera;
1468
+ // the origin is PINNED at 0 0 — a percentage origin is a live DOM
1469
+ // quantity, and a stage that changes height would move the picture
1470
+ frame.style.transformOrigin = '0px 0px';
1471
+ // the frame is the author's own region element: at identity, leave no
1472
+ // trace — a resting transform would quietly become a containing block.
1473
+ // While a platform animation owns the frame, the inline write is
1474
+ // skipped: the animation overrides it anyway, and the stale value would
1475
+ // flash when the animation is cancelled.
1476
+ if (!t.cameraAnimation) {
1477
+ frame.style.transform = ChoreoRun.cameraCss(this.appliedCamera(t, this.camera, t.cameraP ?? 1));
1478
+ }
1479
+ for (const sprite of t.cue.camera?.steady ?? []) {
1480
+ const ve = sprite.node.visualElement;
1481
+ if (ve) {
1482
+ const counter = ChoreoRun.damped(zoom);
1483
+ ve.getValue('scale', counter).set(counter);
1484
+ }
1485
+ }
1486
+ }
1487
+
1488
+ /* ---- c.Tether: the wire that follows (§6.1) ---- */
1489
+
1490
+ tetherDrawScheduled = false;
1491
+ scheduleTetherDraw() {
1492
+ if (this.tetherDrawScheduled) {
1493
+ return;
1494
+ }
1495
+ this.tetherDrawScheduled = true;
1496
+ frame.postRender(() => {
1497
+ this.tetherDrawScheduled = false;
1498
+ for (const t of this.tracks) {
1499
+ if (t.started && t.wire) {
1500
+ this.drawTether(t);
1501
+ }
1502
+ }
1503
+ });
1504
+ }
1505
+
1506
+ /**
1507
+ * One frame of a derived value (§4.10): compose the context from the
1508
+ * pass's measurements, hand it to the author's function, write what
1509
+ * comes back.
1510
+ *
1511
+ * Nothing here touches the DOM — that is the design, not an
1512
+ * optimisation (docs/postmortem-follow.md). The resting boxes were
1513
+ * measured by the pass with every moved value released, and a source's
1514
+ * `now` is its resting box composed with the motion values driving it
1515
+ * this frame: JS-side numbers, not a measurement. So a follower is pure
1516
+ * BY CONSTRUCTION — there is no live box in reach, it cannot read back
1517
+ * its own output, and it cannot force a style recalculation in the
1518
+ * middle of a move. It is also correct under INTERRUPTION for free: a
1519
+ * replacement run's pass re-measures, so a run born mid-flight computes
1520
+ * from rests that are right immediately, with no "last frame" to
1521
+ * disagree with.
1522
+ *
1523
+ * `set`, not an animation: a derived value IS the frame, so there is
1524
+ * nothing to interpolate toward and nothing the compositor could be
1525
+ * given — this is the main-thread cost the step charges, and the reason
1526
+ * it is the same cost `c.Tether` already pays.
1527
+ */
1528
+ drive(t, now) {
1529
+ const derive = t.cue.derive;
1530
+ const ve = t.cue.sprite.node.visualElement;
1531
+ if (!derive || !ve) {
1532
+ return;
1533
+ }
1534
+ const sources = derive.sources.map(({
1535
+ from,
1536
+ sprite,
1537
+ to
1538
+ }) => {
1539
+ const sv = sprite.node.visualElement;
1540
+ const num = key => {
1541
+ const value = sv?.getValue(key)?.get();
1542
+ return typeof value === 'number' ? value : undefined;
1543
+ };
1544
+ // composed about the centre (motion's default origin): a scaling
1545
+ // source reports the box it paints, not the box it laid out
1546
+ const sx = num('scaleX') ?? num('scale') ?? 1;
1547
+ const sy = num('scaleY') ?? num('scale') ?? 1;
1548
+ const width = to.width * sx;
1549
+ const height = to.height * sy;
1550
+ return {
1551
+ from,
1552
+ now: {
1553
+ height,
1554
+ width,
1555
+ x: to.x + (num('x') ?? 0) + (to.width - width) / 2,
1556
+ y: to.y + (num('y') ?? 0) + (to.height - height) / 2
1557
+ },
1558
+ to
1559
+ };
1560
+ });
1561
+ const span = t.end - t.start;
1562
+ const values = derive.read({
1563
+ camera: {
1564
+ ...this.camera
1565
+ },
1566
+ p: span > 0 ? Math.max(0, Math.min(1, (now - t.start) / span)) : 1,
1567
+ rest: {
1568
+ ...derive.restBox
1569
+ },
1570
+ sources,
1571
+ t: now / 1000
1572
+ });
1573
+ for (const [key, value] of Object.entries(values)) {
1574
+ ve.getValue(key, value).set(value);
1575
+ }
1576
+ // rendered NOW, not scheduled: a scheduled render lands in motion's
1577
+ // own render step, a hop later than the synchronous pin the move it
1578
+ // follows just did, and that hop is a visible one-frame flash.
1579
+ ve.render();
1580
+ }
1581
+
1582
+ /** the window closed (or was scrubbed out of): put the declared rest back */
1583
+ restDerived(t) {
1584
+ const rest = t.cue.derive?.rest;
1585
+ const ve = t.cue.sprite.node.visualElement;
1586
+ if (!rest || !ve) {
1587
+ return;
1588
+ }
1589
+ for (const [key, value] of Object.entries(rest)) {
1590
+ ve.getValue(key, value).jump(value);
1591
+ }
1592
+ ve.render();
1593
+ }
1594
+ drawTether(t) {
1595
+ const layer = this.options.tetherLayer;
1596
+ const tether = t.cue.tether;
1597
+ if (!layer || !tether || !t.wire) {
1598
+ return;
1599
+ }
1600
+ // the svg rides the camera with the scene, so client coordinates are
1601
+ // mapped through its screen CTM — a wire drawn in user units lands on
1602
+ // its sprites at any zoom, not only at identity
1603
+ const inverse = layer.getScreenCTM()?.inverse();
1604
+ const box = layer.getBoundingClientRect();
1605
+ const toLocal = (clientX, clientY) => {
1606
+ if (!inverse) {
1607
+ return {
1608
+ x: clientX - box.left,
1609
+ y: clientY - box.top
1610
+ };
1611
+ }
1612
+ const p = new DOMPoint(clientX, clientY).matrixTransform(inverse);
1613
+ return {
1614
+ x: p.x,
1615
+ y: p.y
1616
+ };
1617
+ };
1618
+ const rectOf = sprite => {
1619
+ const el = sprite?.element;
1620
+ if (!el?.isConnected) {
1621
+ return null;
1622
+ }
1623
+ const r = el.getBoundingClientRect();
1624
+ const tl = toLocal(r.left, r.top);
1625
+ const br = toLocal(r.right, r.bottom);
1626
+ return {
1627
+ height: br.y - tl.y,
1628
+ width: br.x - tl.x,
1629
+ x: tl.x,
1630
+ y: tl.y
1631
+ };
1632
+ };
1633
+ const a = rectOf(tether.from);
1634
+ const b = rectOf(tether.to);
1635
+ if (a && b) {
1636
+ t.wire.setAttribute('d', tether.path(a, b));
1637
+ }
1638
+ }
1639
+
1640
+ /* ---- c.Raise: the elevated layer (§6.3) ---- */
1641
+
1642
+ /**
1643
+ * The scale between the layer's own pixels and the screen's, per axis.
1644
+ *
1645
+ * A promotion reads client rects and writes LOCAL pixels, and every
1646
+ * transform above the region — a camera zoom, a page crossing carrying the
1647
+ * whole scene, any ancestor with a scale on it — sits between the two. Read
1648
+ * off the layer itself: its rect is what the screen shows, its offset size
1649
+ * is what its own coordinate system calls that. Dividing by the camera zoom
1650
+ * alone would catch the region's own transform and miss everything outside
1651
+ * it, which is the double-scale the tether draw already dodges by mapping
1652
+ * through the screen CTM.
1653
+ */
1654
+ static layerScale(el, box) {
1655
+ return {
1656
+ x: el.offsetWidth ? box.width / el.offsetWidth : 1,
1657
+ y: el.offsetHeight ? box.height / el.offsetHeight : 1
1658
+ };
1659
+ }
1660
+ promote(t) {
1661
+ const layer = this.options.raisedLayer;
1662
+ const el = t.cue.sprite.element;
1663
+ if (!layer || !el.isConnected || t.perch) {
1664
+ return;
1665
+ }
1666
+ const box = el.getBoundingClientRect();
1667
+ const layerBox = layer.getBoundingClientRect();
1668
+ const s = ChoreoRun.layerScale(layer, layerBox);
1669
+ const width = box.width / s.x;
1670
+ const height = box.height / s.y;
1671
+ // the slot holds: siblings must not reflow under a lifted sprite
1672
+ const placeholder = document.createElement(el.tagName);
1673
+ placeholder.setAttribute('aria-hidden', 'true');
1674
+ placeholder.style.cssText = `visibility:hidden;width:${width}px;height:${height}px;margin:0;flex:none`;
1675
+ const prior = el.style.cssText;
1676
+ el.parentNode?.insertBefore(placeholder, el);
1677
+ layer.appendChild(el);
1678
+ el.style.position = 'absolute';
1679
+ el.style.left = `${(box.left - layerBox.left) / s.x}px`;
1680
+ el.style.top = `${(box.top - layerBox.top) / s.y}px`;
1681
+ el.style.width = `${width}px`;
1682
+ el.style.height = `${height}px`;
1683
+ el.style.margin = '0';
1684
+ if (t.cue.raise?.shadow) {
1685
+ el.style.filter = 'drop-shadow(0 18px 24px rgba(0,0,0,0.35))';
1686
+ }
1687
+ t.perch = {
1688
+ placeholder,
1689
+ prior
1690
+ };
1691
+ }
1692
+ restore(t) {
1693
+ const el = t.cue.sprite.element;
1694
+ const perch = t.perch;
1695
+ if (!perch) {
1696
+ return;
1697
+ }
1698
+ t.perch = undefined;
1699
+ perch.placeholder.parentNode?.replaceChild(el, perch.placeholder);
1700
+ el.style.cssText = perch.prior;
1701
+ t.cue.sprite.node.visualElement?.scheduleRender();
1702
+ }
1703
+
1704
+ /* ---- c.Scroll: the container as a step (§6.1) ---- */
1705
+
1706
+ startScroll(t) {
1707
+ const el = t.cue.sprite.element;
1708
+ let container = el.parentElement;
1709
+ while (container) {
1710
+ const cs = getComputedStyle(container);
1711
+ if (/(auto|scroll)/.test(cs.overflowY + cs.overflowX)) {
1712
+ break;
1713
+ }
1714
+ container = container.parentElement;
1715
+ }
1716
+ if (!container) {
1717
+ return;
1718
+ }
1719
+ const box = el.getBoundingClientRect();
1720
+ const cBox = container.getBoundingClientRect();
1721
+ // scrollTop/scrollLeft are LAYOUT pixels; the rect deltas carry every
1722
+ // transform above the container (the region's camera, a crossing
1723
+ // scaling the whole stage) and must have it divided back out — the
1724
+ // raise and the orphan lock live by the same rule
1725
+ const s = ChoreoRun.layerScale(container, cBox);
1726
+ const align = t.cue.scroll.align;
1727
+ const offsetY = (box.top - cBox.top) / s.y + container.scrollTop;
1728
+ const offsetX = (box.left - cBox.left) / s.x + container.scrollLeft;
1729
+ const factor = align === 'center' ? 0.5 : align === 'end' ? 1 : 0;
1730
+ const to = {
1731
+ left: Math.max(0, offsetX - (container.clientWidth - box.width / s.x) * factor),
1732
+ top: Math.max(0, offsetY - (container.clientHeight - box.height / s.y) * factor)
1733
+ };
1734
+ // the user's own wheel takes the container back: the step cancels, the
1735
+ // run survives (§8.2)
1736
+ const onWheel = () => this.stopScroll(t, false);
1737
+ container.addEventListener('wheel', onWheel, {
1738
+ passive: true
1739
+ });
1740
+ container.addEventListener('touchmove', onWheel, {
1741
+ passive: true
1742
+ });
1743
+ t.scrolling = {
1744
+ cancelListener: () => {
1745
+ container.removeEventListener('wheel', onWheel);
1746
+ container.removeEventListener('touchmove', onWheel);
1747
+ },
1748
+ container,
1749
+ from: {
1750
+ left: container.scrollLeft,
1751
+ top: container.scrollTop
1752
+ },
1753
+ to
1754
+ };
1755
+ }
1756
+ stopScroll(t, rewind) {
1757
+ const s = t.scrolling;
1758
+ if (!s) {
1759
+ return;
1760
+ }
1761
+ t.scrolling = undefined;
1762
+ s.cancelListener();
1763
+ if (rewind) {
1764
+ s.container.scrollTop = s.from.top;
1765
+ s.container.scrollLeft = s.from.left;
1766
+ }
1767
+ }
1768
+
1769
+ /** stop a track's animations at the value level — an async animation
1770
+ * cancelled before it resolves stays 'idle' forever if only the controls
1771
+ * are stopped, and its value then reads as animating for good */
1772
+ stopTrack(t) {
1773
+ t.controls?.forEach(c => c.stop());
1774
+ t.controls = undefined;
1775
+ t.platform?.forEach(a => a.cancel());
1776
+ t.platform = undefined;
1777
+ const ve = t.cue.sprite.node.visualElement;
1778
+ if (ve && t.cue.target) {
1779
+ for (const key in t.cue.target) {
1780
+ ve.getValue(key)?.stop();
1781
+ }
1782
+ }
1783
+ }
1784
+
1785
+ /** land a cue's end values without ever having played it */
1786
+ jumpFinals(t) {
1787
+ const {
1788
+ cue
1789
+ } = t;
1790
+ const ve = cue.sprite.node.visualElement;
1791
+ if (!ve || !cue.target && !cue.flight) {
1792
+ return;
1793
+ }
1794
+ if (cue.flight) {
1795
+ // the platform owned the journey; the landing is written inline
1796
+ this.applyFlight(t, 1);
1797
+ ve.render();
1798
+ if (!cue.target) {
1799
+ t.passed = true;
1800
+ return;
1801
+ }
1802
+ }
1803
+ if (!t.origin) {
1804
+ // capture where things stood first, so a scrub back can undo this
1805
+ const origin = {};
1806
+ for (const key in cue.target) {
1807
+ const value = cue.target[key];
1808
+ if (Array.isArray(value)) {
1809
+ origin[key] = value[0];
1810
+ } else {
1811
+ const held = ve.getValue(key)?.get();
1812
+ origin[key] = held !== undefined ? held : getComputedStyle(cue.sprite.element).getPropertyValue(dash(key)) || 0;
1813
+ }
1814
+ }
1815
+ t.origin = origin;
1816
+ }
1817
+ t.passed = true;
1818
+ for (const key in cue.target) {
1819
+ const raw = cue.target[key];
1820
+ const end = Array.isArray(raw) ? raw[raw.length - 1] : raw;
1821
+ ve.getValue(key, end).jump(end);
1822
+ }
1823
+ ve.render();
1824
+ }
1825
+ startTrack(t) {
1826
+ const {
1827
+ cue
1828
+ } = t;
1829
+ const ve = cue.sprite.node.visualElement;
1830
+ if (!ve) {
1831
+ return;
1832
+ }
1833
+ t.started = true;
1834
+ t.passed = false;
1835
+ if (cue.target && !t.origin) {
1836
+ const origin = {};
1837
+ for (const key in cue.target) {
1838
+ const value = cue.target[key];
1839
+ if (Array.isArray(value)) {
1840
+ origin[key] = value[0];
1841
+ } else {
1842
+ const held = ve.getValue(key)?.get();
1843
+ const latest = ve.latestValues[key];
1844
+ origin[key] = held !== undefined ? held : latest !== undefined ? latest : getComputedStyle(cue.sprite.element).getPropertyValue(dash(key)) || 0;
1845
+ }
1846
+ }
1847
+ t.origin = origin;
1848
+ }
1849
+ if (cue.delivery) {
1850
+ // The sprite has stood pinned at its FIRST keyframes since the run
1851
+ // began (pinStarts) so a build-in sits hidden until its window. From
1852
+ // here the split's slots carry those keyframes each in their own
1853
+ // window, and they are the sprite's children — so the container must
1854
+ // step aside to its END values now, or its pinned opacity would
1855
+ // multiply every slot to nothing. land() leaves it in the same place.
1856
+ for (const key in cue.target) {
1857
+ const raw = cue.target[key];
1858
+ if (Array.isArray(raw)) {
1859
+ const end = raw[raw.length - 1];
1860
+ ve.getValue(key, end).jump(end);
1861
+ }
1862
+ }
1863
+ ve.render();
1864
+ t.delivery = deliver(cue, this.scale);
1865
+ if (!this.playing) {
1866
+ t.delivery.pause();
1867
+ }
1868
+ return;
1869
+ }
1870
+ if (cue.loop) {
1871
+ // an ambient loop rides the platform: the engine's probes stay quiet,
1872
+ // and its phase is the run clock's remainder (§4.5)
1873
+ const el = cue.sprite.element;
1874
+ const cycle = t.end - t.start;
1875
+ const times = cue.transition?.times;
1876
+ const segmentEase = cssEasing(cue.transition?.ease, cycle);
1877
+ t.loopAnimation = el.animate(keyframesOf(cue.target ?? {}, times, segmentEase), {
1878
+ duration: Math.max(1, cycle),
1879
+ easing: times ? 'linear' : segmentEase,
1880
+ iterations: Infinity
1881
+ });
1882
+ t.loopAnimation.playbackRate = this.rate;
1883
+ if (!this.playing) {
1884
+ t.loopAnimation.pause();
1885
+ }
1886
+ return;
1887
+ }
1888
+ if (cue.flight) {
1889
+ this.startFlight(t);
1890
+ return;
1891
+ }
1892
+ if (!cue.target) {
1893
+ return;
1894
+ }
1895
+ if (!this.playing) {
1896
+ // born inside a still: no animation at all — stop() before the async
1897
+ // keyframe resolution is dropped, and the resolver revives the
1898
+ // animation a frame later. A still is computed, never driven.
1899
+ this.sampleStill(t, this.master);
1900
+ return;
1901
+ }
1902
+ const transition = carryVelocity(scaleTransition({
1903
+ ...cue.transition
1904
+ }, this.scale), cue, this.options.inherit);
1905
+ // Playback rides the platform: plain tweens and springs are left free
1906
+ // to accelerate onto WAAPI, off the main thread — a gallery of live
1907
+ // demos cannot afford a JS driver for every value. The cost is one
1908
+ // race: a finished accelerated animation commits its finals
1909
+ // asynchronously, which can land AFTER a scrub's jump and overwrite
1910
+ // it. Stills pay for the acceleration by re-asserting themselves on
1911
+ // the frames after they are entered (scheduleRestill).
1912
+ t.controls = animateTarget(ve, {
1913
+ ...cue.target,
1914
+ transition
1915
+ });
1916
+ for (const c of t.controls) {
1917
+ c.speed = this.rate;
1918
+ }
1919
+ }
1920
+
1921
+ /** put the sprite at progress `p` of its sampled flight path (§4.4) */
1922
+ applyFlight(t, p) {
1923
+ const {
1924
+ cue
1925
+ } = t;
1926
+ const ve = cue.sprite.node.visualElement;
1927
+ const flight = cue.flight;
1928
+ if (!ve || !flight) {
1929
+ return;
1930
+ }
1931
+ const pts = flight.points;
1932
+ const at = Math.min(pts.length - 1 - 1e-9, Math.max(0, p) * (pts.length - 1));
1933
+ const index = Math.floor(at);
1934
+ const local = at - index;
1935
+ const a = pts[index];
1936
+ const b = pts[Math.min(index + 1, pts.length - 1)];
1937
+ const x = a.x + (b.x - a.x) * local - flight.rest.x;
1938
+ const y = a.y + (b.y - a.y) * local - flight.rest.y;
1939
+ ve.getValue('x', x).set(x);
1940
+ ve.getValue('y', y).set(y);
1941
+ if (flight.rotate !== undefined) {
1942
+ const angle = Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI + (typeof flight.rotate === 'number' ? flight.rotate : 0);
1943
+ ve.getValue('rotate', angle).set(angle);
1944
+ }
1945
+ }
1946
+
1947
+ /** the eased progress a flight sits at, `ms` into its window */
1948
+ flightProgress(t, ms) {
1949
+ const span = t.end - t.start;
1950
+ const raw = span > 0 ? Math.min(1, Math.max(0, ms / span)) : 1;
1951
+ const transition = t.cue.transition;
1952
+ if (isSpring(transition)) {
1953
+ const pts = t.cue.flight.points;
1954
+ const end = pts[pts.length - 1];
1955
+ const distance = Math.hypot(end.x, end.y) || 1;
1956
+ const generator = spring({
1957
+ keyframes: [0, distance],
1958
+ ...(transition ?? {})
1959
+ });
1960
+ return generator.next(ms / this.scale).value / distance;
1961
+ }
1962
+ return easingDefinitionToFunction(transition?.ease ?? 'easeInOut')(raw);
1963
+ }
1964
+
1965
+ /**
1966
+ * The sampled path as platform keyframes: each point becomes a transform,
1967
+ * uniformly spaced (the points were sampled uniformly in progress), and
1968
+ * the eased clock rides the animation's easing instead of per-frame JS.
1969
+ */
1970
+ flightKeyframes(t) {
1971
+ const flight = t.cue.flight;
1972
+ const pts = flight.points;
1973
+ const frames = [];
1974
+ for (let i = 0; i < pts.length; i++) {
1975
+ const p = pts[i];
1976
+ let transform = `translate(${p.x - flight.rest.x}px, ${p.y - flight.rest.y}px)`;
1977
+ if (flight.rotate !== undefined) {
1978
+ const a = pts[Math.max(0, i - 1)];
1979
+ const b = pts[Math.min(pts.length - 1, i + 1)];
1980
+ const angle = Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI + (typeof flight.rotate === 'number' ? flight.rotate : 0);
1981
+ transform += ` rotate(${angle}deg)`;
1982
+ }
1983
+ frames.push({
1984
+ transform
1985
+ });
1986
+ }
1987
+ return frames;
1988
+ }
1989
+ startFlight(t) {
1990
+ const {
1991
+ cue
1992
+ } = t;
1993
+ const ve = cue.sprite.node.visualElement;
1994
+ if (!ve) {
1995
+ return;
1996
+ }
1997
+ if (!this.playing) {
1998
+ // born inside a still: computed, never driven, like every other cue
1999
+ this.sampleStill(t, this.master);
2000
+ return;
2001
+ }
2002
+ const span = Math.max(1, t.end - t.start);
2003
+ const controls = [];
2004
+ // The journey rides the platform (§6.2's move, made early): the
2005
+ // pre-sampled path IS a keyframe list, and the eased clock — spring or
2006
+ // tween — pre-samples into a linear() easing, so the compositor flies
2007
+ // the box while the main thread stays free.
2008
+ const anim = cue.sprite.element.animate(this.flightKeyframes(t), {
2009
+ duration: span,
2010
+ easing: generateLinearEasing(p => this.flightProgress(t, p * span), span),
2011
+ fill: 'both'
2012
+ });
2013
+ anim.playbackRate = this.rate;
2014
+ anim.currentTime = Math.min(span, Math.max(0, this.master - t.start));
2015
+ (t.platform ??= []).push(anim);
2016
+ controls.push(platformControls(anim));
2017
+ // size pairs still ride the engine; position rides the sampled path
2018
+ if (cue.target && Object.keys(cue.target).length) {
2019
+ const transition = scaleTransition({
2020
+ ...cue.transition,
2021
+ onUpdate: noop
2022
+ }, this.scale);
2023
+ controls.push(...animateTarget(ve, {
2024
+ ...cue.target,
2025
+ transition
2026
+ }));
2027
+ }
2028
+ for (const c of controls) {
2029
+ c.speed = this.rate;
2030
+ }
2031
+ t.controls = controls;
2032
+ }
2033
+
2034
+ /** compute one still frame of a cue and jump the values onto the element */
2035
+ sampleStill(t, now) {
2036
+ const {
2037
+ cue
2038
+ } = t;
2039
+ const ve = cue.sprite.node.visualElement;
2040
+ if (!ve || !cue.target && !cue.flight) {
2041
+ return;
2042
+ }
2043
+ if (cue.flight) {
2044
+ this.applyFlight(t, this.flightProgress(t, now - t.start));
2045
+ ve.render();
2046
+ if (!cue.target) {
2047
+ return;
2048
+ }
2049
+ }
2050
+ const span = t.end - t.start;
2051
+ const p = span > 0 ? Math.min(1, Math.max(0, (now - t.start) / span)) : 1;
2052
+ const transition = cue.transition;
2053
+ const sprung = isSpring(transition);
2054
+ const ease = sprung ? null : easingDefinitionToFunction(transition?.ease ?? 'easeInOut');
2055
+ for (const key in cue.target) {
2056
+ const raw = cue.target[key];
2057
+ const frames = Array.isArray(raw) ? raw : [t.origin?.[key] ?? 0, raw];
2058
+ const numeric = frames.map(f => parseFloat(String(f)));
2059
+ if (numeric.some(n => Number.isNaN(n))) {
2060
+ // non-numeric values snap to the nearest keyframe
2061
+ const index = Math.min(frames.length - 1, Math.floor(p * frames.length));
2062
+ ve.getValue(key, frames[index]).jump(frames[index]);
2063
+ continue;
2064
+ }
2065
+ let value;
2066
+ if (sprung) {
2067
+ const generator = spring({
2068
+ keyframes: [numeric[0], numeric[numeric.length - 1]],
2069
+ ...(transition ?? {})
2070
+ });
2071
+ value = generator.next(now - t.start).value;
2072
+ } else {
2073
+ const times = Array.isArray(raw) ? transition?.times : undefined;
2074
+ const eased = ease ? ease(p) : p;
2075
+ const segments = numeric.length - 1;
2076
+ const at = Math.min(segments - 1e-9, eased * segments);
2077
+ const index = times ? p >= 1 ? segments - 1 : Math.max(0, times.findIndex(time => time > p) - 1) : Math.max(0, Math.floor(at));
2078
+ const progress = times ? p >= 1 ? 1 : (p - times[index]) / (times[index + 1] - times[index]) : at - index;
2079
+ const local = times && ease ? ease(progress) : progress;
2080
+ value = numeric[index] + (numeric[index + 1] - numeric[index]) * local;
2081
+ }
2082
+ ve.getValue(key, value).jump(value);
2083
+ }
2084
+ ve.render();
2085
+ }
2086
+ completeTrack(t) {
2087
+ const {
2088
+ cue
2089
+ } = t;
2090
+ if (t.passed) {
2091
+ return;
2092
+ }
2093
+ t.passed = true;
2094
+ // the animation survives, parked on its last frame — a scrub back only
2095
+ // has to move its time
2096
+ // setting a paused animation's time to its duration finishes and resets
2097
+ // it — so a completed track is stopped outright and its finals jumped;
2098
+ // a scrub back re-creates it from the recorded origin
2099
+ t.started = false;
2100
+ flushKeyframeResolvers();
2101
+ this.stopTrack(t);
2102
+ if (t.delivery) {
2103
+ // a delivery mid-split when the scrub crossed its end: finish it —
2104
+ // end values land and the sprite's own text nodes come back
2105
+ t.delivery.complete();
2106
+ t.delivery = undefined;
2107
+ }
2108
+ const ve = cue.sprite.node.visualElement;
2109
+ if (ve && cue.flight) {
2110
+ // the platform animation is cancelled above; land the journey inline
2111
+ this.applyFlight(t, 1);
2112
+ ve.render();
2113
+ }
2114
+ if (ve && cue.target) {
2115
+ for (const key in cue.target) {
2116
+ const raw = cue.target[key];
2117
+ const end = Array.isArray(raw) ? raw[raw.length - 1] : raw;
2118
+ ve.getValue(key, end).jump(end);
2119
+ }
2120
+ ve.render();
2121
+ }
2122
+ }
2123
+
2124
+ /* ---- starting values, velocities, and giving the layout back ---- */
2125
+
2126
+ pinStarts() {
2127
+ const pinned = new Set();
2128
+ const rendered = new Set();
2129
+ for (const t of [...this.tracks].sort((a, b) => a.start - b.start)) {
2130
+ const cue = t.cue;
2131
+ const ve = cue.sprite.node.visualElement;
2132
+ if (!ve || cue.loop) {
2133
+ continue;
2134
+ }
2135
+ if (cue.flight) {
2136
+ this.movedValues.push({
2137
+ key: 'x',
2138
+ ve
2139
+ }, {
2140
+ key: 'y',
2141
+ ve
2142
+ });
2143
+ this.owned.push({
2144
+ el: cue.sprite.element,
2145
+ key: 'x',
2146
+ ve
2147
+ }, {
2148
+ el: cue.sprite.element,
2149
+ key: 'y',
2150
+ ve
2151
+ });
2152
+ if (cue.flight.rotate !== undefined) {
2153
+ this.movedValues.push({
2154
+ key: 'rotate',
2155
+ ve
2156
+ });
2157
+ }
2158
+ // stand the sprite on the path's first point before first paint
2159
+ this.applyFlight({
2160
+ ...t
2161
+ }, 0);
2162
+ rendered.add(ve);
2163
+ }
2164
+ if (!cue.target) {
2165
+ continue;
2166
+ }
2167
+ for (const key in cue.target) {
2168
+ const value = cue.target[key];
2169
+ if (!Array.isArray(value) || value[0] === undefined) {
2170
+ continue;
2171
+ }
2172
+ const seen = `${cue.sprite.node.layoutKey}:${key}`;
2173
+ if (pinned.has(seen)) {
2174
+ continue;
2175
+ }
2176
+ pinned.add(seen);
2177
+ this.owned.push({
2178
+ el: cue.sprite.element,
2179
+ key,
2180
+ ve
2181
+ });
2182
+ if (cue.kind === 'move') {
2183
+ if (key in SIZES) {
2184
+ if (!ve.hasValue(key)) {
2185
+ this.borrowedValues.push({
2186
+ el: cue.sprite.element,
2187
+ key,
2188
+ prior: cue.sprite.element.style.getPropertyValue(dash(key)),
2189
+ ve
2190
+ });
2191
+ }
2192
+ } else {
2193
+ this.movedValues.push({
2194
+ key,
2195
+ rest: key.startsWith('scale') ? 1 : key === 'clipPath' ? 'none' : 0,
2196
+ ve
2197
+ });
2198
+ }
2199
+ } else if (cue.borrow && !ve.hasValue(key)) {
2200
+ // the cue only borrows this value (the crossfade's color-carry):
2201
+ // returned — inline style removed — when the run releases, so the
2202
+ // stylesheet's own declaration stands again
2203
+ this.borrowedValues.push({
2204
+ el: cue.sprite.element,
2205
+ key,
2206
+ prior: cue.sprite.element.style.getPropertyValue(dash(key)),
2207
+ ve
2208
+ });
2209
+ }
2210
+ ve.getValue(key, value[0]).jump(value[0]);
2211
+ rendered.add(ve);
2212
+ }
2213
+ }
2214
+ rendered.forEach(ve => ve.render());
2215
+ }
2216
+ sampleVelocities() {
2217
+ for (const {
2218
+ ve,
2219
+ el,
2220
+ key
2221
+ } of this.owned) {
2222
+ let byKey = this.velocities.get(el);
2223
+ if (byKey?.has(key)) {
2224
+ continue;
2225
+ }
2226
+ const velocity = ve.getValue(key)?.getVelocity();
2227
+ if (!velocity) {
2228
+ continue;
2229
+ }
2230
+ if (!byKey) {
2231
+ this.velocities.set(el, byKey = new Map());
2232
+ }
2233
+ byKey.set(key, velocity);
2234
+ }
2235
+ }
2236
+ releaseForMeasure() {
2237
+ this.sampleVelocities();
2238
+ // platform animations override inline style, and a measurement must see
2239
+ // the resting layout — every native is cancelled before anything reads
2240
+ for (const t of this.tracks) {
2241
+ t.platform?.forEach(a => a.cancel());
2242
+ t.platform = undefined;
2243
+ this.dropCameraAnimation(t);
2244
+ }
2245
+ const touched = new Set();
2246
+ this.retired = {
2247
+ borrowed: [],
2248
+ moved: []
2249
+ };
2250
+ for (const {
2251
+ ve,
2252
+ el,
2253
+ key,
2254
+ prior
2255
+ } of this.borrowedValues.splice(0)) {
2256
+ this.retired.borrowed.push({
2257
+ el,
2258
+ key,
2259
+ prior,
2260
+ value: ve.getValue(key)?.get(),
2261
+ ve
2262
+ });
2263
+ ve.removeValue(key);
2264
+ if (prior) {
2265
+ el.style.setProperty(dash(key), prior);
2266
+ } else {
2267
+ el.style.removeProperty(dash(key));
2268
+ }
2269
+ touched.add(ve);
2270
+ }
2271
+ for (const {
2272
+ ve,
2273
+ key,
2274
+ rest
2275
+ } of this.movedValues.splice(0)) {
2276
+ this.retired.moved.push({
2277
+ key,
2278
+ rest,
2279
+ value: ve.getValue(key)?.get(),
2280
+ ve
2281
+ });
2282
+ ve.getValue(key)?.jump(rest ?? 0);
2283
+ touched.add(ve);
2284
+ }
2285
+ touched.forEach(ve => ve.render());
2286
+ }
2287
+
2288
+ /**
2289
+ * A pass measured this run's world at rest and then decided to KEEP the
2290
+ * run — but the measurement itself was destructive: releaseForMeasure
2291
+ * cancels platform animations, returns borrowed values, and jumps every
2292
+ * moved value to rest, and a MotionValue.jump() STOPS the animation
2293
+ * driving it. Left like that, a kept run keeps its clock and its landing
2294
+ * but loses its picture: the flight freezes at rest until the finals
2295
+ * land — which on a busy page (the gallery: neighbours re-pass the
2296
+ * region every render) is every crossing, every time. So the keep path
2297
+ * calls this: moved and borrowed values are stood back where they were,
2298
+ * their bookkeeping is re-registered, every in-flight track's animation
2299
+ * is retired so evaluate re-enters it — the machinery a scrub-then-play
2300
+ * already uses — and the re-entered animations are seeked back onto the
2301
+ * run's own clock, so a keep is invisible rather than a restart.
2302
+ */
2303
+ reassert() {
2304
+ const {
2305
+ borrowed,
2306
+ moved
2307
+ } = this.retired;
2308
+ this.retired = {
2309
+ borrowed: [],
2310
+ moved: []
2311
+ };
2312
+ for (const {
2313
+ el,
2314
+ key,
2315
+ prior,
2316
+ value,
2317
+ ve
2318
+ } of borrowed) {
2319
+ if (value !== undefined) {
2320
+ ve.getValue(key, value).jump(value);
2321
+ }
2322
+ this.borrowedValues.push({
2323
+ el,
2324
+ key,
2325
+ prior,
2326
+ ve
2327
+ });
2328
+ }
2329
+ for (const {
2330
+ key,
2331
+ rest,
2332
+ value,
2333
+ ve
2334
+ } of moved) {
2335
+ if (value !== undefined) {
2336
+ ve.getValue(key)?.jump(value);
2337
+ }
2338
+ this.movedValues.push({
2339
+ key,
2340
+ rest,
2341
+ ve
2342
+ });
2343
+ }
2344
+ if (!this.playing) {
2345
+ // a paused run is a still, and stills re-assert themselves (restill)
2346
+ return;
2347
+ }
2348
+ const now = this.master;
2349
+ const restarted = [];
2350
+ for (const t of this.tracks) {
2351
+ if (t.started && !t.passed && now >= t.start && (now < t.end || t.cue.loop) && (t.cue.target || t.cue.flight)) {
2352
+ flushKeyframeResolvers();
2353
+ this.stopTrack(t);
2354
+ restarted.push(t);
2355
+ }
2356
+ }
2357
+ this.evaluate();
2358
+ for (const t of restarted) {
2359
+ // startFlight seeks its platform animation itself; engine tweens and
2360
+ // springs are stood at the window's elapsed time here
2361
+ if (!t.cue.flight) {
2362
+ const at = Math.max(0, now - t.start) / 1000;
2363
+ t.controls?.forEach(c => {
2364
+ c.time = at;
2365
+ });
2366
+ }
2367
+ }
2368
+ }
2369
+ cancel(keep) {
2370
+ if (this.cancelled) {
2371
+ return;
2372
+ }
2373
+ this.cancelled = true;
2374
+ activeRuns.delete(this);
2375
+ this.stopTicking();
2376
+ clearTimeout(this.autoTimer);
2377
+ // a cancelled run's queued commands die with it: the replacement run
2378
+ // re-derives the commanded state from its own clock
2379
+ this.pendingPerforms.length = 0;
2380
+ this.options.onCamera?.({
2381
+ ...this.camera
2382
+ });
2383
+ // resolve everything first: a stop() issued before the async keyframe
2384
+ // resolution is dropped, and the revived animation leaves its value
2385
+ // reading as animating forever — the probe never settles
2386
+ flushKeyframeResolvers();
2387
+ this.sampleVelocities();
2388
+ for (const t of this.tracks) {
2389
+ this.stopTrack(t);
2390
+ t.delivery?.cancel();
2391
+ t.loopAnimation?.cancel();
2392
+ this.dropCameraAnimation(t);
2393
+ this.restore(t);
2394
+ this.stopScroll(t, false);
2395
+ t.wire?.remove();
2396
+ t.wire = undefined;
2397
+ const ve = t.cue.sprite.node.visualElement;
2398
+ if (t.held && ve) {
2399
+ releaseHold(ve, t.cue.sprite.element, t.held);
2400
+ t.held = undefined;
2401
+ }
2402
+ }
2403
+ this.releaseForMeasure();
2404
+ for (const sprite of [...this.pending]) {
2405
+ if (keep?.has(sprite.node)) {
2406
+ this.pending.delete(sprite);
2407
+ } else {
2408
+ this.pending.delete(sprite);
2409
+ this.options.onSpriteDone(sprite);
2410
+ }
2411
+ }
2412
+ this.resolveFinished();
2413
+ }
2414
+ }
2415
+
2416
+ /** every live run, for the test helpers that drive gates and clocks */
2417
+ const activeRuns = new Set();
2418
+ function execute(compiled, options) {
2419
+ return new ChoreoRun(compiled, options);
2420
+ }
2421
+
2422
+ export { ChoreoRun, activeRuns, execute };
2423
+ //# sourceMappingURL=run.js.map