@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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +55 -6
- package/addon-main.cjs +4 -0
- package/declarations/anchors.d.ts +26 -0
- package/declarations/anchors.d.ts.map +1 -0
- package/declarations/arming.d.ts +33 -0
- package/declarations/arming.d.ts.map +1 -0
- package/declarations/beacon.d.ts +9 -0
- package/declarations/beacon.d.ts.map +1 -0
- package/declarations/beacons.d.ts +23 -0
- package/declarations/beacons.d.ts.map +1 -0
- package/declarations/changeset.d.ts +46 -0
- package/declarations/changeset.d.ts.map +1 -0
- package/declarations/choreo.d.ts +261 -0
- package/declarations/choreo.d.ts.map +1 -0
- package/declarations/compile.d.ts +45 -0
- package/declarations/compile.d.ts.map +1 -0
- package/declarations/deliver.d.ts +44 -0
- package/declarations/deliver.d.ts.map +1 -0
- package/declarations/easings.d.ts +20 -0
- package/declarations/easings.d.ts.map +1 -0
- package/declarations/far.d.ts +33 -0
- package/declarations/far.d.ts.map +1 -0
- package/declarations/film/clip.d.ts +51 -0
- package/declarations/film/clip.d.ts.map +1 -0
- package/declarations/film/clips.d.ts +155 -0
- package/declarations/film/clips.d.ts.map +1 -0
- package/declarations/film/film.d.ts +1208 -0
- package/declarations/film/film.d.ts.map +1 -0
- package/declarations/film/graph/adjust.d.ts +112 -0
- package/declarations/film/graph/adjust.d.ts.map +1 -0
- package/declarations/film/graph/compile.d.ts +140 -0
- package/declarations/film/graph/compile.d.ts.map +1 -0
- package/declarations/film/graph/host.d.ts +67 -0
- package/declarations/film/graph/host.d.ts.map +1 -0
- package/declarations/film/graph/nodes.d.ts +287 -0
- package/declarations/film/graph/nodes.d.ts.map +1 -0
- package/declarations/film/index.d.ts +24 -0
- package/declarations/film/index.d.ts.map +1 -0
- package/declarations/film/joins.d.ts +143 -0
- package/declarations/film/joins.d.ts.map +1 -0
- package/declarations/film/math.d.ts +15 -0
- package/declarations/film/math.d.ts.map +1 -0
- package/declarations/film/overlays.d.ts +54 -0
- package/declarations/film/overlays.d.ts.map +1 -0
- package/declarations/film/picture.d.ts +104 -0
- package/declarations/film/picture.d.ts.map +1 -0
- package/declarations/film/plate.d.ts +37 -0
- package/declarations/film/plate.d.ts.map +1 -0
- package/declarations/film/player.d.ts +120 -0
- package/declarations/film/player.d.ts.map +1 -0
- package/declarations/film/rail.d.ts +40 -0
- package/declarations/film/rail.d.ts.map +1 -0
- package/declarations/film/schedule.d.ts +93 -0
- package/declarations/film/schedule.d.ts.map +1 -0
- package/declarations/film/seam.d.ts +38 -0
- package/declarations/film/seam.d.ts.map +1 -0
- package/declarations/film/titles.d.ts +37 -0
- package/declarations/film/titles.d.ts.map +1 -0
- package/declarations/film/types.d.ts +544 -0
- package/declarations/film/types.d.ts.map +1 -0
- package/declarations/film.d.ts +3 -0
- package/declarations/film.d.ts.map +1 -0
- package/declarations/gesture.d.ts +33 -0
- package/declarations/gesture.d.ts.map +1 -0
- package/declarations/index.d.ts +26 -0
- package/declarations/index.d.ts.map +1 -0
- package/declarations/measure.d.ts +29 -0
- package/declarations/measure.d.ts.map +1 -0
- package/declarations/path.d.ts +73 -0
- package/declarations/path.d.ts.map +1 -0
- package/declarations/registry.d.ts +46 -0
- package/declarations/registry.d.ts.map +1 -0
- package/declarations/run.d.ts +406 -0
- package/declarations/run.d.ts.map +1 -0
- package/declarations/space.d.ts +31 -0
- package/declarations/space.d.ts.map +1 -0
- package/declarations/steps.d.ts +479 -0
- package/declarations/steps.d.ts.map +1 -0
- package/declarations/test-support/index.d.ts +30 -0
- package/declarations/test-support/index.d.ts.map +1 -0
- package/declarations/types.d.ts +761 -0
- package/declarations/types.d.ts.map +1 -0
- package/dist/anchors.js +34 -0
- package/dist/anchors.js.map +1 -0
- package/dist/arming.js +120 -0
- package/dist/arming.js.map +1 -0
- package/dist/beacon.js +25 -0
- package/dist/beacon.js.map +1 -0
- package/dist/beacons.js +77 -0
- package/dist/beacons.js.map +1 -0
- package/dist/changeset.js +129 -0
- package/dist/changeset.js.map +1 -0
- package/dist/choreo.js +1026 -0
- package/dist/choreo.js.map +1 -0
- package/dist/compile.js +1403 -0
- package/dist/compile.js.map +1 -0
- package/dist/deliver.js +326 -0
- package/dist/deliver.js.map +1 -0
- package/dist/easings.js +39 -0
- package/dist/easings.js.map +1 -0
- package/dist/far.js +147 -0
- package/dist/far.js.map +1 -0
- package/dist/film/clip.js +100 -0
- package/dist/film/clip.js.map +1 -0
- package/dist/film/clips.js +147 -0
- package/dist/film/clips.js.map +1 -0
- package/dist/film/film.js +4424 -0
- package/dist/film/film.js.map +1 -0
- package/dist/film/graph/adjust.js +160 -0
- package/dist/film/graph/adjust.js.map +1 -0
- package/dist/film/graph/compile.js +225 -0
- package/dist/film/graph/compile.js.map +1 -0
- package/dist/film/graph/host.js +77 -0
- package/dist/film/graph/host.js.map +1 -0
- package/dist/film/graph/nodes.js +500 -0
- package/dist/film/graph/nodes.js.map +1 -0
- package/dist/film/index.js +18 -0
- package/dist/film/index.js.map +1 -0
- package/dist/film/joins.js +260 -0
- package/dist/film/joins.js.map +1 -0
- package/dist/film/math.js +49 -0
- package/dist/film/math.js.map +1 -0
- package/dist/film/overlays.js +66 -0
- package/dist/film/overlays.js.map +1 -0
- package/dist/film/picture.js +58 -0
- package/dist/film/picture.js.map +1 -0
- package/dist/film/plate.js +36 -0
- package/dist/film/plate.js.map +1 -0
- package/dist/film/player.js +79 -0
- package/dist/film/player.js.map +1 -0
- package/dist/film/rail.js +38 -0
- package/dist/film/rail.js.map +1 -0
- package/dist/film/schedule.js +238 -0
- package/dist/film/schedule.js.map +1 -0
- package/dist/film/seam.js +64 -0
- package/dist/film/seam.js.map +1 -0
- package/dist/film/titles.js +45 -0
- package/dist/film/titles.js.map +1 -0
- package/dist/film/types.js +2 -0
- package/dist/film/types.js.map +1 -0
- package/dist/film.js +18 -0
- package/dist/film.js.map +1 -0
- package/dist/gesture.js +111 -0
- package/dist/gesture.js.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/measure.js +99 -0
- package/dist/measure.js.map +1 -0
- package/dist/path.js +272 -0
- package/dist/path.js.map +1 -0
- package/dist/registry.js +55 -0
- package/dist/registry.js.map +1 -0
- package/dist/run.js +2423 -0
- package/dist/run.js.map +1 -0
- package/dist/space.js +57 -0
- package/dist/space.js.map +1 -0
- package/dist/steps.js +831 -0
- package/dist/steps.js.map +1 -0
- package/dist/test-support/index.js +150 -0
- package/dist/test-support/index.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- 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
|