@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/compile.js
ADDED
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
import { spring, calcGeneratorDuration, maxGeneratorDuration } from 'motion-dom';
|
|
2
|
+
import { isGestureRef, gestureBounds, gestureVelocity } from './gesture.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* boxel-motion's OrchestrationMatrix, as a cue list: the timeline tree becomes
|
|
6
|
+
* one (sprite, start, duration) per step per sprite. Sequences add, parallels
|
|
7
|
+
* overlay; a spring's length is computed with the engine's own generator so a
|
|
8
|
+
* sequence can follow one — the thing the legacy could not do.
|
|
9
|
+
*/
|
|
10
|
+
const isBlock = node => node.kind === 'sequence' || node.kind === 'parallel';
|
|
11
|
+
const isGate = node => node.kind === 'gate';
|
|
12
|
+
const DEFAULT_SPRING = {
|
|
13
|
+
damping: 30,
|
|
14
|
+
stiffness: 300
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What `c.Follow` is allowed to drive: paint, never layout. See the throw
|
|
19
|
+
* in the 'follow' case for why the line is drawn exactly here.
|
|
20
|
+
*/
|
|
21
|
+
const DERIVABLE = new Set(['filter', 'opacity', 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'scale', 'scaleX', 'scaleY', 'skewX', 'skewY', 'x', 'y', 'z']);
|
|
22
|
+
|
|
23
|
+
/** the shared offstage <svg> that path geometry is sampled inside */
|
|
24
|
+
let pathHost;
|
|
25
|
+
function samplePath(d, samples = 64) {
|
|
26
|
+
if (!pathHost) {
|
|
27
|
+
pathHost = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
28
|
+
pathHost.setAttribute('aria-hidden', 'true');
|
|
29
|
+
pathHost.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
|
|
30
|
+
document.body.appendChild(pathHost);
|
|
31
|
+
}
|
|
32
|
+
const el = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
33
|
+
el.setAttribute('d', d);
|
|
34
|
+
pathHost.appendChild(el);
|
|
35
|
+
try {
|
|
36
|
+
const length = el.getTotalLength();
|
|
37
|
+
return Array.from({
|
|
38
|
+
length: samples + 1
|
|
39
|
+
}, (_, i) => {
|
|
40
|
+
const pt = el.getPointAtLength(length * i / samples);
|
|
41
|
+
return {
|
|
42
|
+
x: pt.x,
|
|
43
|
+
y: pt.y
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
} finally {
|
|
47
|
+
el.remove();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Similarity-map a drawn path so its start is the sprite's start and its end
|
|
53
|
+
* is the measured landing (§4.4): the path bends the journey, never the
|
|
54
|
+
* destination. Complex multiplication: m = D / v rotates and scales the
|
|
55
|
+
* author's stroke onto the real displacement.
|
|
56
|
+
*/
|
|
57
|
+
function mapFlight(raw, dx, dy) {
|
|
58
|
+
const s0 = raw[0];
|
|
59
|
+
const e = raw[raw.length - 1];
|
|
60
|
+
const vx = e.x - s0.x;
|
|
61
|
+
const vy = e.y - s0.y;
|
|
62
|
+
const mag = vx * vx + vy * vy;
|
|
63
|
+
if (mag < 1e-6) {
|
|
64
|
+
// a degenerate stroke: fall back to the straight line
|
|
65
|
+
return raw.map((_, i) => ({
|
|
66
|
+
x: dx * i / (raw.length - 1),
|
|
67
|
+
y: dy * i / (raw.length - 1)
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
// m = (dx + i·dy) / (vx + i·vy)
|
|
71
|
+
const mr = (dx * vx + dy * vy) / mag;
|
|
72
|
+
const mi = (dy * vx - dx * vy) / mag;
|
|
73
|
+
return raw.map(pt => {
|
|
74
|
+
const px = pt.x - s0.x;
|
|
75
|
+
const py = pt.y - s0.y;
|
|
76
|
+
return {
|
|
77
|
+
x: px * mr - py * mi,
|
|
78
|
+
y: px * mi + py * mr
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const resolve = (v, sprite, cs) => typeof v === 'function' ? v(sprite, cs) : v;
|
|
83
|
+
const single = v => Array.isArray(v) ? v[0] : v;
|
|
84
|
+
const num = v => {
|
|
85
|
+
if (typeof v === 'number') {
|
|
86
|
+
return v;
|
|
87
|
+
}
|
|
88
|
+
const n = parseFloat(String(v));
|
|
89
|
+
return Number.isNaN(n) ? 0 : n;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** the value a spring starts from when the step does not say: the engine's, else the computed style's */
|
|
93
|
+
function currentValue(sprite, key) {
|
|
94
|
+
const ve = sprite.node.visualElement;
|
|
95
|
+
if (ve?.hasValue(key)) {
|
|
96
|
+
return num(ve.getValue(key).get());
|
|
97
|
+
}
|
|
98
|
+
const latest = ve?.latestValues?.[key];
|
|
99
|
+
if (latest !== undefined) {
|
|
100
|
+
return num(latest);
|
|
101
|
+
}
|
|
102
|
+
const computed = getComputedStyle(sprite.element).getPropertyValue(key.replace(/[A-Z]/g, m => '-' + m.toLowerCase()));
|
|
103
|
+
return num(computed);
|
|
104
|
+
}
|
|
105
|
+
function springDuration(from, to, spec) {
|
|
106
|
+
if (from === to) {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
const generator = spring({
|
|
110
|
+
keyframes: [from, to],
|
|
111
|
+
...spec
|
|
112
|
+
});
|
|
113
|
+
return Math.min(calcGeneratorDuration(generator), maxGeneratorDuration);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** a cue before it is placed on the run's clock; `offset` is its rung of the stagger ladder */
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The rung each index takes on the stagger ladder, under a delivery order.
|
|
120
|
+
* 'center' delivers from the middle outward; 'random' shuffles per run —
|
|
121
|
+
* seeded input is the native driver's requirement, not this one's.
|
|
122
|
+
*/
|
|
123
|
+
function ladder(n, order) {
|
|
124
|
+
const ranks = Array.from({
|
|
125
|
+
length: n
|
|
126
|
+
}, (_, i) => i);
|
|
127
|
+
switch (order) {
|
|
128
|
+
case 'reverse':
|
|
129
|
+
return ranks.map(i => n - 1 - i);
|
|
130
|
+
case 'center':
|
|
131
|
+
{
|
|
132
|
+
const mid = (n - 1) / 2;
|
|
133
|
+
const byDistance = [...ranks].sort((a, b) => Math.abs(a - mid) - Math.abs(b - mid));
|
|
134
|
+
const out = new Array(n);
|
|
135
|
+
byDistance.forEach((index, rank) => out[index] = rank);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
case 'random':
|
|
139
|
+
{
|
|
140
|
+
const shuffled = [...ranks];
|
|
141
|
+
for (let i = n - 1; i > 0; i--) {
|
|
142
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
143
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
144
|
+
}
|
|
145
|
+
const out = new Array(n);
|
|
146
|
+
shuffled.forEach((index, rank) => out[index] = rank);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
return ranks;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function resolveTarget(step, sprite, cs) {
|
|
154
|
+
const target = {};
|
|
155
|
+
let longest = 0;
|
|
156
|
+
for (const key in step.props) {
|
|
157
|
+
const to = resolve(step.props[key], sprite, cs);
|
|
158
|
+
// A keyframe array is the from-and-to (and any waypoints) in one value —
|
|
159
|
+
// `@opacity={{array 0 1}}` — which is why there is no property-hash @from.
|
|
160
|
+
if (Array.isArray(to)) {
|
|
161
|
+
if (step.kind === 'spring' && to.length > 2) {
|
|
162
|
+
throw new Error('choreo: a spring animates between two keyframes; ' + `\`${key}\` was given ${to.length}`);
|
|
163
|
+
}
|
|
164
|
+
target[key] = to;
|
|
165
|
+
if (step.kind === 'spring') {
|
|
166
|
+
longest = Math.max(longest, springDuration(num(to[0]), num(to[to.length - 1]), step.spring ?? DEFAULT_SPRING));
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
target[key] = to;
|
|
170
|
+
if (step.kind === 'spring') {
|
|
171
|
+
longest = Math.max(longest, springDuration(currentValue(sprite, key), num(to), step.spring ?? DEFAULT_SPRING));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
longest,
|
|
177
|
+
target
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The two boxes a shape-matched flight aligns: the declared subjects
|
|
183
|
+
* ([data-choreo-substance], or pack="content" ink) when either end has
|
|
184
|
+
* one — Keynote matches OBJECTS, not slide frames — with the undeclared
|
|
185
|
+
* end derived by fraction (content laid out proportionally keeps one
|
|
186
|
+
* fraction at both scales; the return trip's card, whose demo has not
|
|
187
|
+
* boarded yet, has nothing to measure). Neither end declared: the frames
|
|
188
|
+
* themselves.
|
|
189
|
+
*/
|
|
190
|
+
function matchBoxes(initial, final, from, to, z) {
|
|
191
|
+
const subI = initial.substance && descale(initial.substance, z);
|
|
192
|
+
const subF = final.substance && descale(final.substance, z);
|
|
193
|
+
if (!subI && !subF) {
|
|
194
|
+
return {
|
|
195
|
+
from,
|
|
196
|
+
to
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const map = (s, a, b) => ({
|
|
200
|
+
height: s.height / (a.height || 1) * b.height,
|
|
201
|
+
width: s.width / (a.width || 1) * b.width,
|
|
202
|
+
x: b.x + (s.x - a.x) / (a.width || 1) * b.width,
|
|
203
|
+
y: b.y + (s.y - a.y) / (a.height || 1) * b.height
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
from: subI ?? map(subF, to, from),
|
|
207
|
+
to: subF ?? map(subI, from, to)
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const centreOf = r => ({
|
|
211
|
+
x: r.x + r.width / 2,
|
|
212
|
+
y: r.y + r.height / 2
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/** a page-space box, divided back into local space by the measure-time zoom */
|
|
216
|
+
function descale(r, z) {
|
|
217
|
+
return z === 1 ? r : {
|
|
218
|
+
height: r.height / z,
|
|
219
|
+
width: r.width / z,
|
|
220
|
+
x: r.x / z,
|
|
221
|
+
y: r.y / z
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function resolveMove(step, sprite, cs) {
|
|
225
|
+
// A beacon — or the live gesture — rewrites one end of the flight. `@from`
|
|
226
|
+
// gives an inserted sprite a start it never had (out of the compose button,
|
|
227
|
+
// or from wherever the finger let go); `@to` gives a removed one an end it
|
|
228
|
+
// never reaches. Everything after this is the ordinary FLIP — rewriting the
|
|
229
|
+
// bounds IS the whole implementation. A name nothing claimed leaves that
|
|
230
|
+
// end alone.
|
|
231
|
+
const size = (sprite.final ?? sprite.initial)?.page ?? {
|
|
232
|
+
height: 0,
|
|
233
|
+
width: 0};
|
|
234
|
+
const hot = step.from && isGestureRef(step.from);
|
|
235
|
+
const borrowedFrom = step.from ? hot ? gestureBounds(size) : cs.beacon(step.from.beacon) : null;
|
|
236
|
+
const borrowedTo = step.to ? cs.beacon(step.to.beacon) : null;
|
|
237
|
+
const initial = borrowedFrom ?? sprite.initial;
|
|
238
|
+
const final = borrowedTo ?? sprite.final;
|
|
239
|
+
if (!initial || !final) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
// Page space by default: the one space two regions agree on, and the region
|
|
243
|
+
// itself can move in the very pass that moves its children. 'parent'
|
|
244
|
+
// resolves against the sprite's own container instead (§6.1). Either way,
|
|
245
|
+
// the measurement was taken through the camera's transform and the values
|
|
246
|
+
// will be written as local inline pixels — so both boxes are divided back
|
|
247
|
+
// by the zoom the world was measured under (§6.3).
|
|
248
|
+
const z = cs.measureZoom ?? 1;
|
|
249
|
+
const parentSpace = step.space === 'parent' && !borrowedFrom && !borrowedTo;
|
|
250
|
+
const from = descale(parentSpace ? initial.parent : initial.page, z);
|
|
251
|
+
const to = descale(parentSpace ? final.parent : final.page, z);
|
|
252
|
+
const target = {};
|
|
253
|
+
const pairs = [];
|
|
254
|
+
// 'scale' matches shape by transform (MoveStep.size): position runs
|
|
255
|
+
// centre-to-centre over the MATCH boxes — the declared subjects when
|
|
256
|
+
// either end has one, the frames otherwise. Layout modes run
|
|
257
|
+
// corner-to-corner with the real size animating alongside.
|
|
258
|
+
const scaleMode = step.size === 'scale' || step.size === 'crop';
|
|
259
|
+
const cropMode = step.size === 'crop';
|
|
260
|
+
const m = scaleMode ? matchBoxes(initial, final, from, to, z) : null;
|
|
261
|
+
const dx = m ? centreOf(m.to).x - centreOf(m.from).x : to.x - from.x;
|
|
262
|
+
// 'crop' pins the TOPS of the two subjects, not their centres — the
|
|
263
|
+
// matching-snapshot rule (§4.7): the entering subject stands with its
|
|
264
|
+
// top on the exiting subject's top, and overflow pays at the BOTTOM
|
|
265
|
+
const dy = m ? cropMode ? m.to.y - m.from.y : centreOf(m.to).y - centreOf(m.from).y : to.y - from.y;
|
|
266
|
+
/**
|
|
267
|
+
* Which end the element is actually sitting on decides the sign.
|
|
268
|
+
*
|
|
269
|
+
* The usual FLIP case is a kept sprite: the pass has already put it at its
|
|
270
|
+
* destination, so it is pulled back by the delta and animated to zero. A
|
|
271
|
+
* REMOVED sprite is the other way round — the region has locked it in the
|
|
272
|
+
* orphan layer at the place it was, so it starts at zero and travels the
|
|
273
|
+
* delta. That is the only case that can have a `final` at all, and it only
|
|
274
|
+
* has one because a beacon lent it one.
|
|
275
|
+
*/
|
|
276
|
+
const holdsStart = sprite.type === 'removed';
|
|
277
|
+
const leg = d => holdsStart ? [0, d] : [-d, 0];
|
|
278
|
+
let flight;
|
|
279
|
+
if (step.path && (dx !== 0 || dy !== 0)) {
|
|
280
|
+
// the path owns position; FLIP still owns size
|
|
281
|
+
flight = {
|
|
282
|
+
points: mapFlight(samplePath(step.path), dx, dy),
|
|
283
|
+
rest: holdsStart ? {
|
|
284
|
+
x: 0,
|
|
285
|
+
y: 0
|
|
286
|
+
} : {
|
|
287
|
+
x: dx,
|
|
288
|
+
y: dy
|
|
289
|
+
},
|
|
290
|
+
rotate: step.rotate
|
|
291
|
+
};
|
|
292
|
+
pairs.push(leg(Math.hypot(dx, dy)));
|
|
293
|
+
} else if (m) {
|
|
294
|
+
// The transform scales about the element's own centre, so an
|
|
295
|
+
// off-centre match box needs a correction on the translate: the
|
|
296
|
+
// flight pins the MATCH box — the subject — through the move, and
|
|
297
|
+
// the frame simply comes along. With frame-matching the correction
|
|
298
|
+
// is zero and this is the plain centre-to-centre leg.
|
|
299
|
+
//
|
|
300
|
+
// 'crop' (the matching-snapshot rule, §4.7): the scale is UNIFORM
|
|
301
|
+
// and derived from WIDTH alone — the entering subject is stretched
|
|
302
|
+
// or shrunk to the exiting subject's width, filling it, never
|
|
303
|
+
// matched by cover — with the tops pinned and any vertical overflow
|
|
304
|
+
// carried by an animated crop window that pays at the BOTTOM only.
|
|
305
|
+
// Nothing ever stretches; 'scale' stretches per axis instead, and
|
|
306
|
+
// the crossfade hides it.
|
|
307
|
+
const crop = cropMode;
|
|
308
|
+
const rx = m.from.width / (m.to.width || 1) || 1;
|
|
309
|
+
const ry = m.from.height / (m.to.height || 1) || 1;
|
|
310
|
+
const su = rx;
|
|
311
|
+
const sx = crop ? su : rx;
|
|
312
|
+
const sy = crop ? su : ry;
|
|
313
|
+
const fx = holdsStart ? 1 / sx : sx;
|
|
314
|
+
const fy = holdsStart ? 1 / sy : sy;
|
|
315
|
+
const anchor = centreOf(holdsStart ? from : to);
|
|
316
|
+
const matched = holdsStart ? m.from : m.to;
|
|
317
|
+
// the pinned point: top-centre under 'crop', centre under 'scale'
|
|
318
|
+
// (an exact per-axis map makes the two identical there)
|
|
319
|
+
const pinned = crop ? {
|
|
320
|
+
x: centreOf(matched).x,
|
|
321
|
+
y: matched.y
|
|
322
|
+
} : centreOf(matched);
|
|
323
|
+
const cx = (1 - fx) * (pinned.x - anchor.x);
|
|
324
|
+
const cy = (1 - fy) * (pinned.y - anchor.y);
|
|
325
|
+
const x = holdsStart ? dx + cx : -dx + cx;
|
|
326
|
+
const y = holdsStart ? dy + cy : -dy + cy;
|
|
327
|
+
if (x !== 0) {
|
|
328
|
+
target['x'] = holdsStart ? [0, x] : [x, 0];
|
|
329
|
+
pairs.push(holdsStart ? [0, x] : [x, 0]);
|
|
330
|
+
}
|
|
331
|
+
if (y !== 0) {
|
|
332
|
+
target['y'] = holdsStart ? [0, y] : [y, 0];
|
|
333
|
+
pairs.push(holdsStart ? [0, y] : [y, 0]);
|
|
334
|
+
}
|
|
335
|
+
if (crop) {
|
|
336
|
+
if (fx !== 1) {
|
|
337
|
+
target['scale'] = holdsStart ? [1, fx] : [fx, 1];
|
|
338
|
+
}
|
|
339
|
+
// the crop window at the FAR pose: the other end's SUBJECT sets
|
|
340
|
+
// the floor — anything of this element that runs past that
|
|
341
|
+
// subject's bottom is cropped, and nothing else is. Pulled back
|
|
342
|
+
// through the transform into this element's own space.
|
|
343
|
+
const frame = holdsStart ? from : to;
|
|
344
|
+
const window = holdsStart ? m.to : m.from;
|
|
345
|
+
const f = holdsStart ? fx : sx;
|
|
346
|
+
const d = {
|
|
347
|
+
y
|
|
348
|
+
};
|
|
349
|
+
const pre = edge => anchor.y + (edge - anchor.y - d.y) / f;
|
|
350
|
+
const clamp = v => Math.max(0, v);
|
|
351
|
+
const b = clamp(frame.y + frame.height - pre(window.y + window.height));
|
|
352
|
+
if (b > 0.5) {
|
|
353
|
+
const far = `inset(0px 0px ${b.toFixed(2)}px 0px)`;
|
|
354
|
+
const rest = 'inset(0px 0px 0px 0px)';
|
|
355
|
+
target['clipPath'] = holdsStart ? [rest, far] : [far, rest];
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
if (fx !== 1) {
|
|
359
|
+
target['scaleX'] = holdsStart ? [1, fx] : [fx, 1];
|
|
360
|
+
}
|
|
361
|
+
if (fy !== 1) {
|
|
362
|
+
target['scaleY'] = holdsStart ? [1, fy] : [fy, 1];
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
if (dx !== 0) {
|
|
367
|
+
target['x'] = leg(dx);
|
|
368
|
+
pairs.push(leg(dx));
|
|
369
|
+
}
|
|
370
|
+
if (dy !== 0) {
|
|
371
|
+
target['y'] = leg(dy);
|
|
372
|
+
pairs.push(leg(dy));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (scaleMode) ; else if (step.size !== false) {
|
|
376
|
+
if (from.width !== to.width) {
|
|
377
|
+
target['width'] = [from.width, to.width];
|
|
378
|
+
pairs.push([from.width, to.width]);
|
|
379
|
+
}
|
|
380
|
+
if (from.height !== to.height) {
|
|
381
|
+
target['height'] = [from.height, to.height];
|
|
382
|
+
pairs.push([from.height, to.height]);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!pairs.length) {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
let longest = step.ms ?? 0;
|
|
389
|
+
if (step.ms === undefined) {
|
|
390
|
+
const spec = step.spring ?? DEFAULT_SPRING;
|
|
391
|
+
for (const [a, b] of pairs) {
|
|
392
|
+
longest = Math.max(longest, springDuration(a, b, spec));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
flight,
|
|
397
|
+
longest,
|
|
398
|
+
target,
|
|
399
|
+
// a hot start leaves at the speed it was thrown (§6.1)
|
|
400
|
+
velocity: hot ? gestureVelocity() : undefined
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** parse a computed rgb()/rgba() color into channels, or null */
|
|
405
|
+
function rgbOf(css) {
|
|
406
|
+
if (!css) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
const m = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/.exec(css);
|
|
410
|
+
if (!m) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
a: m[4] === undefined ? 1 : parseFloat(m[4]),
|
|
415
|
+
b: parseFloat(m[3]),
|
|
416
|
+
g: parseFloat(m[2]),
|
|
417
|
+
r: parseFloat(m[1])
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* A skin's EFFECTIVE color: its background alpha-blended against the ground
|
|
423
|
+
* it stood on — opacity turned into an actual color (§4.7). Null when the
|
|
424
|
+
* skin has no visible background (type over transparency crossfades as
|
|
425
|
+
* opacity, there is nothing to solidify) or wears a color this parser does
|
|
426
|
+
* not speak, in which case the crossfade stays a plain dissolve.
|
|
427
|
+
*/
|
|
428
|
+
function effective(paint, ground) {
|
|
429
|
+
const src = rgbOf(paint);
|
|
430
|
+
if (!src || src.a === 0) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
if (src.a === 1) {
|
|
434
|
+
return `rgb(${src.r}, ${src.g}, ${src.b})`;
|
|
435
|
+
}
|
|
436
|
+
const dst = rgbOf(ground);
|
|
437
|
+
if (!dst) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
const mix = (s, d) => Math.round(s * src.a + d * (1 - src.a));
|
|
441
|
+
return `rgb(${mix(src.r, dst.r)}, ${mix(src.g, dst.g)}, ${mix(src.b, dst.b)})`;
|
|
442
|
+
}
|
|
443
|
+
const tweenTransition = (step, ms) => ({
|
|
444
|
+
duration: ms / 1000,
|
|
445
|
+
ease: step.ease ?? 'easeInOut',
|
|
446
|
+
type: 'tween',
|
|
447
|
+
...('times' in step && step.times ? {
|
|
448
|
+
times: step.times
|
|
449
|
+
} : {})
|
|
450
|
+
});
|
|
451
|
+
const springTransition = spec => ({
|
|
452
|
+
...(spec ?? DEFAULT_SPRING),
|
|
453
|
+
type: 'spring'
|
|
454
|
+
});
|
|
455
|
+
function resolveStep(step, cs, exclude) {
|
|
456
|
+
// a step with no `of` (a wait, a tether) still needs A sprite to hang its
|
|
457
|
+
// cue on — the run keys its bookkeeping by sprite — so it takes the
|
|
458
|
+
// first the changeset offers and produces exactly one cue
|
|
459
|
+
const sprites = cs.sprites(step.of ?? {}).filter(s => !exclude || !exclude.has(s));
|
|
460
|
+
const delay = step.delay ?? 0;
|
|
461
|
+
const cues = [];
|
|
462
|
+
const stagger = step.stagger ?? 0;
|
|
463
|
+
/** text delivery: the sprite is split and the ladder happens INSIDE the
|
|
464
|
+
* step's span, so the sprites themselves are not laddered */
|
|
465
|
+
const splitting = (step.kind === 'tween' || step.kind === 'spring') && step.by !== undefined && step.by !== 'item';
|
|
466
|
+
const order = step.kind === 'tween' || step.kind === 'spring' ? step.order ?? 'forward' : 'forward';
|
|
467
|
+
const ranks = splitting ? [] : ladder(sprites.length, order);
|
|
468
|
+
let longest = 0;
|
|
469
|
+
let open = false;
|
|
470
|
+
for (const [index, sprite] of sprites.entries()) {
|
|
471
|
+
// a camera or tether is a statement about the scene, not a sprite:
|
|
472
|
+
// one cue regardless of what `of` matched — as is any step that named
|
|
473
|
+
// no subject at all
|
|
474
|
+
if ((step.kind === 'camera' || step.kind === 'camera3d' || step.kind === 'tether' || step.of === undefined) && index > 0) {
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
// each sprite starts one rung later than the one before it — in the
|
|
478
|
+
// order the query returned them (document order), reordered by @order
|
|
479
|
+
const offset = splitting ? 0 : stagger * (ranks[index] ?? index);
|
|
480
|
+
switch (step.kind) {
|
|
481
|
+
case 'tween':
|
|
482
|
+
{
|
|
483
|
+
const {
|
|
484
|
+
target
|
|
485
|
+
} = resolveTarget(step, sprite, cs);
|
|
486
|
+
if (step.times !== undefined) {
|
|
487
|
+
const times = step.times;
|
|
488
|
+
if (times.length < 2 || times[0] !== 0 || times[times.length - 1] !== 1 || times.some((time, i) => !Number.isFinite(time) || time < 0 || time > 1 || i > 0 && time <= times[i - 1]) || Object.values(target).some(value => Array.isArray(value) && value.length !== times.length)) {
|
|
489
|
+
throw new Error('choreo: Tween times must increase from 0 to 1 and match every keyframe array');
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (Object.keys(target).length) {
|
|
493
|
+
// an infinite repeat is an ambient loop: it occupies one cycle of
|
|
494
|
+
// the schedule and keeps playing past the run's end
|
|
495
|
+
const loop = step.repeat === Infinity;
|
|
496
|
+
const plays = loop ? 1 : 1 + (step.repeat ?? 0);
|
|
497
|
+
const length = step.ms * plays;
|
|
498
|
+
const transition = tweenTransition(step, step.ms);
|
|
499
|
+
if (step.repeat) {
|
|
500
|
+
transition['repeat'] = step.repeat;
|
|
501
|
+
transition['repeatType'] = step.repeatType ?? 'loop';
|
|
502
|
+
}
|
|
503
|
+
cues.push({
|
|
504
|
+
delivery: splitting ? {
|
|
505
|
+
by: step.by,
|
|
506
|
+
order,
|
|
507
|
+
stagger
|
|
508
|
+
} : undefined,
|
|
509
|
+
duration: length,
|
|
510
|
+
kind: 'tween',
|
|
511
|
+
loop: loop || undefined,
|
|
512
|
+
offset,
|
|
513
|
+
sprite,
|
|
514
|
+
target,
|
|
515
|
+
transition
|
|
516
|
+
});
|
|
517
|
+
longest = Math.max(longest, offset + (loop ? step.ms : length));
|
|
518
|
+
}
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
case 'spring':
|
|
522
|
+
{
|
|
523
|
+
const {
|
|
524
|
+
target,
|
|
525
|
+
longest: d
|
|
526
|
+
} = resolveTarget(step, sprite, cs);
|
|
527
|
+
if (Object.keys(target).length) {
|
|
528
|
+
cues.push({
|
|
529
|
+
delivery: splitting ? {
|
|
530
|
+
by: step.by,
|
|
531
|
+
order,
|
|
532
|
+
stagger
|
|
533
|
+
} : undefined,
|
|
534
|
+
duration: d,
|
|
535
|
+
kind: 'spring',
|
|
536
|
+
offset,
|
|
537
|
+
sprite,
|
|
538
|
+
target,
|
|
539
|
+
transition: springTransition(step.spring)
|
|
540
|
+
});
|
|
541
|
+
longest = Math.max(longest, offset + d);
|
|
542
|
+
}
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
case 'move':
|
|
546
|
+
{
|
|
547
|
+
const r = resolveMove(step, sprite, cs);
|
|
548
|
+
if (r) {
|
|
549
|
+
const transition = step.ms === undefined ? springTransition(step.spring) : tweenTransition(step, step.ms);
|
|
550
|
+
if (r.velocity && step.ms === undefined) {
|
|
551
|
+
// the engine reads transition[key] in preference to the whole
|
|
552
|
+
if (r.target['x']) {
|
|
553
|
+
transition['x'] = {
|
|
554
|
+
...transition,
|
|
555
|
+
velocity: r.velocity.x
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
if (r.target['y']) {
|
|
559
|
+
transition['y'] = {
|
|
560
|
+
...transition,
|
|
561
|
+
velocity: r.velocity.y
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
cues.push({
|
|
566
|
+
duration: r.longest,
|
|
567
|
+
flight: r.flight,
|
|
568
|
+
kind: 'move',
|
|
569
|
+
offset,
|
|
570
|
+
sprite,
|
|
571
|
+
target: r.target,
|
|
572
|
+
transition
|
|
573
|
+
});
|
|
574
|
+
longest = Math.max(longest, offset + r.longest);
|
|
575
|
+
// the counterpart-skin policy (§6.3): the claimed leaver rides the
|
|
576
|
+
// same flight, and the two skins swap during it or at its landing
|
|
577
|
+
const cp = sprite.counterpart;
|
|
578
|
+
const swap = step.swap ?? 'during';
|
|
579
|
+
// a far match's sender is released to its own region, not carried
|
|
580
|
+
// here — there is no second skin to fly (§3.2)
|
|
581
|
+
if (cp && !cp.sent && swap !== 'none' && sprite.final && cp.initial) {
|
|
582
|
+
const cpz = cs.measureZoom ?? 1;
|
|
583
|
+
const from = descale(cp.initial.page, cpz);
|
|
584
|
+
const to = descale(sprite.final.page, cpz);
|
|
585
|
+
const cpTarget = {};
|
|
586
|
+
if (step.size === 'scale' || step.size === 'crop') {
|
|
587
|
+
// the old skin rides the same match-box flight as its
|
|
588
|
+
// receiver, mirrored: identity at its own seat, landing with
|
|
589
|
+
// its SUBJECT on the receiver's — transform only, layout
|
|
590
|
+
// untouched, and the same off-centre correction (see the
|
|
591
|
+
// receiver's match block). Under 'crop' the scale is
|
|
592
|
+
// uniform and its own crop window closes to the receiver's
|
|
593
|
+
// frame as it lands.
|
|
594
|
+
const crop = step.size === 'crop';
|
|
595
|
+
const cpm = matchBoxes(cp.initial, sprite.final, from, to, cpz);
|
|
596
|
+
const mf = centreOf(cpm.from);
|
|
597
|
+
const mt = centreOf(cpm.to);
|
|
598
|
+
const anchor = centreOf(from);
|
|
599
|
+
const rx = cpm.to.width / (cpm.from.width || 1) || 1;
|
|
600
|
+
const ry = cpm.to.height / (cpm.from.height || 1) || 1;
|
|
601
|
+
// width-derived and top-pinned under 'crop', as on the
|
|
602
|
+
// receiver: the exiting subject lands at the ENTERING
|
|
603
|
+
// subject's width, tops together (the rule is symmetric)
|
|
604
|
+
const eu = rx;
|
|
605
|
+
const ex = crop ? eu : rx;
|
|
606
|
+
const ey = crop ? eu : ry;
|
|
607
|
+
const dxe = mt.x - anchor.x - ex * (mf.x - anchor.x);
|
|
608
|
+
const dye = crop ? cpm.to.y - anchor.y - ey * (cpm.from.y - anchor.y) : mt.y - anchor.y - ey * (mf.y - anchor.y);
|
|
609
|
+
if (dxe !== 0) {
|
|
610
|
+
cpTarget['x'] = [0, dxe];
|
|
611
|
+
}
|
|
612
|
+
if (dye !== 0) {
|
|
613
|
+
cpTarget['y'] = [0, dye];
|
|
614
|
+
}
|
|
615
|
+
if (crop) {
|
|
616
|
+
if (ex !== 1) {
|
|
617
|
+
cpTarget['scale'] = [1, ex];
|
|
618
|
+
}
|
|
619
|
+
// the receiver's SUBJECT sets the floor here too
|
|
620
|
+
const pre = edge => anchor.y + (edge - anchor.y - dye) / ex;
|
|
621
|
+
const clamp = v => Math.max(0, v);
|
|
622
|
+
const cb = clamp(from.y + from.height - pre(cpm.to.y + cpm.to.height));
|
|
623
|
+
if (cb > 0.5) {
|
|
624
|
+
cpTarget['clipPath'] = ['inset(0px 0px 0px 0px)', `inset(0px 0px ${cb.toFixed(2)}px 0px)`];
|
|
625
|
+
}
|
|
626
|
+
} else {
|
|
627
|
+
if (ex !== 1) {
|
|
628
|
+
cpTarget['scaleX'] = [1, ex];
|
|
629
|
+
}
|
|
630
|
+
if (ey !== 1) {
|
|
631
|
+
cpTarget['scaleY'] = [1, ey];
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
if (to.x !== from.x) {
|
|
636
|
+
cpTarget['x'] = [0, to.x - from.x];
|
|
637
|
+
}
|
|
638
|
+
if (to.y !== from.y) {
|
|
639
|
+
cpTarget['y'] = [0, to.y - from.y];
|
|
640
|
+
}
|
|
641
|
+
if (step.size !== false) {
|
|
642
|
+
if (from.width !== to.width) {
|
|
643
|
+
cpTarget['width'] = [from.width, to.width];
|
|
644
|
+
}
|
|
645
|
+
if (from.height !== to.height) {
|
|
646
|
+
cpTarget['height'] = [from.height, to.height];
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (Object.keys(cpTarget).length) {
|
|
651
|
+
cues.push({
|
|
652
|
+
duration: r.longest,
|
|
653
|
+
kind: 'move',
|
|
654
|
+
offset,
|
|
655
|
+
sprite: cp,
|
|
656
|
+
target: cpTarget,
|
|
657
|
+
transition
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
if (swap === 'during') {
|
|
661
|
+
// Both skins cross over the one flying box. A symmetric
|
|
662
|
+
// opacity crossfade composites each against whatever stands
|
|
663
|
+
// behind it, and mid-fade the pair sums short of solid — the
|
|
664
|
+
// ground leaks through and the box visibly dips. So when both
|
|
665
|
+
// skins wore a real background, alpha is turned into an
|
|
666
|
+
// actual color (§4.7): the receiver holds opacity 1 wearing a
|
|
667
|
+
// SOLID that tweens between the two skins' effective colors,
|
|
668
|
+
// the old skin dissolves ABOVE it, and on landing the solid
|
|
669
|
+
// is handed back to the stylesheet's own alpha (Cue.borrow).
|
|
670
|
+
const oldSkin = effective(cp.initial?.paint, cs.ground);
|
|
671
|
+
const newSkin = effective(sprite.final?.paint, cs.ground);
|
|
672
|
+
const fade = {
|
|
673
|
+
duration: r.longest / 1000,
|
|
674
|
+
ease: 'easeInOut'
|
|
675
|
+
};
|
|
676
|
+
if (oldSkin && newSkin) {
|
|
677
|
+
cues.push({
|
|
678
|
+
borrow: true,
|
|
679
|
+
duration: r.longest,
|
|
680
|
+
kind: 'tween',
|
|
681
|
+
offset,
|
|
682
|
+
sprite,
|
|
683
|
+
target: {
|
|
684
|
+
backgroundColor: [oldSkin, newSkin]
|
|
685
|
+
},
|
|
686
|
+
transition: fade
|
|
687
|
+
});
|
|
688
|
+
cues.push({
|
|
689
|
+
duration: r.longest,
|
|
690
|
+
hold: {
|
|
691
|
+
fill: false,
|
|
692
|
+
values: {
|
|
693
|
+
zIndex: 3
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
kind: 'hold',
|
|
697
|
+
offset,
|
|
698
|
+
sprite: cp
|
|
699
|
+
});
|
|
700
|
+
} else {
|
|
701
|
+
cues.push({
|
|
702
|
+
duration: r.longest,
|
|
703
|
+
kind: 'tween',
|
|
704
|
+
offset,
|
|
705
|
+
sprite,
|
|
706
|
+
target: {
|
|
707
|
+
opacity: [0, 1]
|
|
708
|
+
},
|
|
709
|
+
transition: fade
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
cues.push({
|
|
713
|
+
duration: r.longest,
|
|
714
|
+
kind: 'tween',
|
|
715
|
+
offset,
|
|
716
|
+
sprite: cp,
|
|
717
|
+
target: {
|
|
718
|
+
opacity: [1, 0]
|
|
719
|
+
},
|
|
720
|
+
transition: fade
|
|
721
|
+
});
|
|
722
|
+
} else {
|
|
723
|
+
// 'settle': the old rendering is carried whole; the swap is
|
|
724
|
+
// the landing itself — the receiver hides for the flight and
|
|
725
|
+
// the released hold reveals it as the leaver is dropped
|
|
726
|
+
cues.push({
|
|
727
|
+
duration: r.longest,
|
|
728
|
+
hold: {
|
|
729
|
+
fill: false,
|
|
730
|
+
values: {
|
|
731
|
+
opacity: 0
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
kind: 'hold',
|
|
735
|
+
offset,
|
|
736
|
+
sprite
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
case 'hold':
|
|
744
|
+
{
|
|
745
|
+
const values = {};
|
|
746
|
+
for (const key in step.props) {
|
|
747
|
+
values[key] = single(resolve(step.props[key], sprite, cs));
|
|
748
|
+
}
|
|
749
|
+
cues.push({
|
|
750
|
+
duration: step.ms ?? 0,
|
|
751
|
+
hold: {
|
|
752
|
+
fill: step.fill ?? false,
|
|
753
|
+
values
|
|
754
|
+
},
|
|
755
|
+
kind: 'hold',
|
|
756
|
+
offset,
|
|
757
|
+
sprite
|
|
758
|
+
});
|
|
759
|
+
if (step.ms === undefined) {
|
|
760
|
+
open = true;
|
|
761
|
+
} else {
|
|
762
|
+
longest = Math.max(longest, offset + step.ms);
|
|
763
|
+
}
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
case 'camera':
|
|
767
|
+
{
|
|
768
|
+
const ms = step.ms ?? 600;
|
|
769
|
+
// fit mode is DECLARED, not inferred: `fit: null` (fit nothing)
|
|
770
|
+
// still means the step owns the whole pose and returns it to rest
|
|
771
|
+
const fitting = step.fit !== undefined;
|
|
772
|
+
const aiming = step.aim !== undefined;
|
|
773
|
+
const aimQuery = fitting ? step.fit : step.aim ?? step.origin;
|
|
774
|
+
const originSprite = aimQuery ? cs.sprite(aimQuery) : null;
|
|
775
|
+
const box = originSprite?.final?.context ?? originSprite?.initial?.context;
|
|
776
|
+
// context space carries the frame's transform (context = zoom ×
|
|
777
|
+
// local), and transform-origin is written in LOCAL pixels — divide
|
|
778
|
+
// back by the zoom the world was measured under (§6.3)
|
|
779
|
+
const oz = cs.measureZoom ?? 1;
|
|
780
|
+
const origin = box ? {
|
|
781
|
+
x: (box.x + box.width / 2) / oz,
|
|
782
|
+
y: (box.y + box.height / 2) / oz
|
|
783
|
+
} : undefined;
|
|
784
|
+
let to = {
|
|
785
|
+
x: step.x,
|
|
786
|
+
y: step.y,
|
|
787
|
+
zoom: step.zoom
|
|
788
|
+
};
|
|
789
|
+
// relative direction (Pan / SlowZoom): nothing absolute compiles —
|
|
790
|
+
// the run resolves the target against the pose in force at start
|
|
791
|
+
const by = step.panBy || step.zoomBy !== undefined ? {
|
|
792
|
+
x: step.panBy?.x,
|
|
793
|
+
y: step.panBy?.y,
|
|
794
|
+
zoom: step.zoomBy
|
|
795
|
+
} : undefined;
|
|
796
|
+
if (aiming && !fitting) {
|
|
797
|
+
// Aim: the fit centring WITHOUT the fit zoom — x/y land the
|
|
798
|
+
// target on the frame's centre, zoom stays whatever it was
|
|
799
|
+
to = box && origin && cs.frame ? {
|
|
800
|
+
x: cs.frame.width / 2 - origin.x,
|
|
801
|
+
y: cs.frame.height / 2 - origin.y,
|
|
802
|
+
zoom: undefined
|
|
803
|
+
} : {
|
|
804
|
+
x: 0,
|
|
805
|
+
y: 0,
|
|
806
|
+
zoom: 1
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
if (fitting) {
|
|
810
|
+
if (box && origin && cs.frame) {
|
|
811
|
+
// Fit-and-centre, from the same rest-layout measurement FLIP
|
|
812
|
+
// uses — never the painted box, so a click that lands mid-zoom
|
|
813
|
+
// on a DIFFERENT tile still computes against rest geometry.
|
|
814
|
+
// The margin is the sprite's share of the frame on whichever
|
|
815
|
+
// axis fits first; the pan solves x + P = centre, since the
|
|
816
|
+
// applied transform holds the aim point P at x + P (§6.3).
|
|
817
|
+
const zoom = step.zoom ?? (step.margin ?? 0.72) * Math.min(cs.frame.width * oz / box.width, cs.frame.height * oz / box.height);
|
|
818
|
+
to = {
|
|
819
|
+
x: cs.frame.width / 2 - origin.x,
|
|
820
|
+
y: cs.frame.height / 2 - origin.y,
|
|
821
|
+
zoom
|
|
822
|
+
};
|
|
823
|
+
} else {
|
|
824
|
+
// fit nothing (null, or the sprite has left): the resting frame
|
|
825
|
+
to = {
|
|
826
|
+
x: 0,
|
|
827
|
+
y: 0,
|
|
828
|
+
zoom: 1
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
cues.push({
|
|
833
|
+
camera: {
|
|
834
|
+
by,
|
|
835
|
+
// centre and origin from the SAME final layout: the aim term
|
|
836
|
+
// (origin − centre) is frozen numbers the run lerps — a board
|
|
837
|
+
// that reflows mid-cue cannot move the camera
|
|
838
|
+
centre: cs.frame ? {
|
|
839
|
+
x: cs.frame.width / 2,
|
|
840
|
+
y: cs.frame.height / 2
|
|
841
|
+
} : undefined,
|
|
842
|
+
origin,
|
|
843
|
+
steady: step.steady ? cs.sprites(step.steady) : [],
|
|
844
|
+
to
|
|
845
|
+
},
|
|
846
|
+
duration: ms,
|
|
847
|
+
kind: 'camera',
|
|
848
|
+
offset,
|
|
849
|
+
sprite,
|
|
850
|
+
transition: step.spring ? springTransition(step.spring) : {
|
|
851
|
+
duration: ms / 1000,
|
|
852
|
+
ease: step.ease ?? 'easeInOut'
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
longest = Math.max(longest, offset + ms);
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
case 'camera3d':
|
|
859
|
+
{
|
|
860
|
+
const ms = step.ms ?? 600;
|
|
861
|
+
cues.push({
|
|
862
|
+
camera3d: {
|
|
863
|
+
by: step.by,
|
|
864
|
+
settle: step.settle,
|
|
865
|
+
tension: step.tension,
|
|
866
|
+
through: step.through,
|
|
867
|
+
to: {
|
|
868
|
+
dolly: step.dolly,
|
|
869
|
+
look: step.look,
|
|
870
|
+
pitch: step.pitch,
|
|
871
|
+
x: step.x,
|
|
872
|
+
y: step.y,
|
|
873
|
+
yaw: step.yaw
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
duration: ms,
|
|
877
|
+
kind: 'camera3d',
|
|
878
|
+
offset,
|
|
879
|
+
sprite,
|
|
880
|
+
transition: step.spring ? springTransition(step.spring) : {
|
|
881
|
+
duration: ms / 1000,
|
|
882
|
+
ease: step.ease ?? 'easeInOut'
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
longest = Math.max(longest, offset + ms);
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
case 'tether':
|
|
889
|
+
{
|
|
890
|
+
cues.push({
|
|
891
|
+
duration: step.ms ?? 0,
|
|
892
|
+
kind: 'tether',
|
|
893
|
+
offset,
|
|
894
|
+
sprite,
|
|
895
|
+
tether: {
|
|
896
|
+
from: cs.sprites(step.from)[0] ?? null,
|
|
897
|
+
name: step.name,
|
|
898
|
+
path: step.path,
|
|
899
|
+
to: cs.sprites(step.to)[0] ?? null
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
if (step.ms === undefined) {
|
|
903
|
+
open = true;
|
|
904
|
+
} else {
|
|
905
|
+
longest = Math.max(longest, offset + step.ms);
|
|
906
|
+
}
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
case 'follow':
|
|
910
|
+
{
|
|
911
|
+
// A derived write may only touch what the compositor treats as
|
|
912
|
+
// paint, never layout: the region's fast keep declines a pass by
|
|
913
|
+
// fingerprinting every participant's offsetLeft/Top/Width/Height,
|
|
914
|
+
// so a follower writing real width would fail that fingerprint on
|
|
915
|
+
// every frame and put the whole region back into release-and-
|
|
916
|
+
// reassert once per frame — the jitter the fast keep exists to
|
|
917
|
+
// prevent. Caught here, where the author can still read the name.
|
|
918
|
+
for (const key of Object.keys(step.rest)) {
|
|
919
|
+
if (!DERIVABLE.has(key)) {
|
|
920
|
+
throw new Error(`choreo: c.Follow cannot drive '${key}' — a derived value is ` + 'computed every frame, so it may only write transform, ' + 'opacity and filter properties, never layout');
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
// The geometry a follower computes from is the PASS's, not the
|
|
924
|
+
// page's: resting boxes measured while every moved value stood
|
|
925
|
+
// released (releaseForMeasure), taken here into region space. The
|
|
926
|
+
// run never measures for a follower again — see drive(). A sprite
|
|
927
|
+
// the pass could not measure simply compiles no follow.
|
|
928
|
+
const fz = cs.measureZoom ?? 1;
|
|
929
|
+
const inZ = r => ({
|
|
930
|
+
height: r.height / fz,
|
|
931
|
+
width: r.width / fz,
|
|
932
|
+
x: r.x / fz,
|
|
933
|
+
y: r.y / fz
|
|
934
|
+
});
|
|
935
|
+
const restBounds = sprite.final ?? sprite.initial;
|
|
936
|
+
if (!restBounds) {
|
|
937
|
+
break;
|
|
938
|
+
}
|
|
939
|
+
const followed = [];
|
|
940
|
+
for (const source of cs.sprites(step.to)) {
|
|
941
|
+
const toB = source.final ?? source.initial;
|
|
942
|
+
const fromB = source.initial ?? source.final;
|
|
943
|
+
if (toB && fromB) {
|
|
944
|
+
followed.push({
|
|
945
|
+
from: inZ(fromB.context),
|
|
946
|
+
sprite: source,
|
|
947
|
+
to: inZ(toB.context)
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const ms = step.ms;
|
|
952
|
+
cues.push({
|
|
953
|
+
derive: {
|
|
954
|
+
read: step.read,
|
|
955
|
+
rest: step.rest,
|
|
956
|
+
restBox: inZ(restBounds.context),
|
|
957
|
+
sources: followed
|
|
958
|
+
},
|
|
959
|
+
duration: ms ?? 0,
|
|
960
|
+
kind: 'follow',
|
|
961
|
+
offset,
|
|
962
|
+
sprite
|
|
963
|
+
});
|
|
964
|
+
if (ms === undefined) {
|
|
965
|
+
open = true;
|
|
966
|
+
} else {
|
|
967
|
+
longest = Math.max(longest, offset + ms);
|
|
968
|
+
}
|
|
969
|
+
break;
|
|
970
|
+
}
|
|
971
|
+
case 'scroll':
|
|
972
|
+
{
|
|
973
|
+
const ms = step.ms ?? 420;
|
|
974
|
+
cues.push({
|
|
975
|
+
duration: ms,
|
|
976
|
+
kind: 'scroll',
|
|
977
|
+
offset,
|
|
978
|
+
scroll: {
|
|
979
|
+
align: step.align ?? 'center'
|
|
980
|
+
},
|
|
981
|
+
sprite
|
|
982
|
+
});
|
|
983
|
+
longest = Math.max(longest, offset + ms);
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
case 'raise':
|
|
987
|
+
{
|
|
988
|
+
cues.push({
|
|
989
|
+
duration: step.ms ?? 0,
|
|
990
|
+
kind: 'raise',
|
|
991
|
+
offset,
|
|
992
|
+
raise: {
|
|
993
|
+
shadow: step.shadow ?? false
|
|
994
|
+
},
|
|
995
|
+
sprite
|
|
996
|
+
});
|
|
997
|
+
if (step.ms === undefined) {
|
|
998
|
+
open = true;
|
|
999
|
+
} else {
|
|
1000
|
+
longest = Math.max(longest, offset + step.ms);
|
|
1001
|
+
}
|
|
1002
|
+
break;
|
|
1003
|
+
}
|
|
1004
|
+
case 'attach':
|
|
1005
|
+
cues.push({
|
|
1006
|
+
attach: {
|
|
1007
|
+
end: step.end ?? 'hold',
|
|
1008
|
+
exact: step.exact ?? false,
|
|
1009
|
+
in: step.in ?? 0,
|
|
1010
|
+
rate: step.rate ?? 1,
|
|
1011
|
+
region: step.region
|
|
1012
|
+
},
|
|
1013
|
+
duration: step.ms,
|
|
1014
|
+
kind: 'attach',
|
|
1015
|
+
offset,
|
|
1016
|
+
sprite
|
|
1017
|
+
});
|
|
1018
|
+
longest = Math.max(longest, offset + step.ms);
|
|
1019
|
+
break;
|
|
1020
|
+
case 'wait':
|
|
1021
|
+
cues.push({
|
|
1022
|
+
duration: step.ms,
|
|
1023
|
+
kind: 'wait',
|
|
1024
|
+
offset,
|
|
1025
|
+
sprite
|
|
1026
|
+
});
|
|
1027
|
+
longest = Math.max(longest, offset + step.ms);
|
|
1028
|
+
break;
|
|
1029
|
+
case 'perform':
|
|
1030
|
+
cues.push({
|
|
1031
|
+
duration: 0,
|
|
1032
|
+
kind: 'perform',
|
|
1033
|
+
offset,
|
|
1034
|
+
perform: {
|
|
1035
|
+
action: step.action,
|
|
1036
|
+
payload: step.payload,
|
|
1037
|
+
target: step.target
|
|
1038
|
+
},
|
|
1039
|
+
sprite
|
|
1040
|
+
});
|
|
1041
|
+
longest = Math.max(longest, offset);
|
|
1042
|
+
break;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
cues,
|
|
1047
|
+
duration: cues.length ? delay + longest : 0,
|
|
1048
|
+
open
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
function compile(tree, cs) {
|
|
1052
|
+
// The yield rule (§4.7): a sprite a SPECIFIC step names belongs to that
|
|
1053
|
+
// step — the canned crossing's generic children surrender it, so "a
|
|
1054
|
+
// special exit that is not just a dissolve" is one sibling step, not a
|
|
1055
|
+
// query-exclusion syntax. Only property steps with a selective query
|
|
1056
|
+
// claim: a camera or a scroll is a statement about the scene, and an
|
|
1057
|
+
// unqualified `of` (match everything) is not a choice of sprite.
|
|
1058
|
+
const explicit = new Set();
|
|
1059
|
+
const claim = node => {
|
|
1060
|
+
if (isGate(node)) {
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (isBlock(node)) {
|
|
1064
|
+
node.children.forEach(claim);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (node.generic || node.kind !== 'tween' && node.kind !== 'spring' && node.kind !== 'move' && node.kind !== 'hold') {
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const q = node.of;
|
|
1071
|
+
const selective = Array.isArray(q) ? q.length > 0 : q.id !== undefined || q.role !== undefined || q.type !== undefined;
|
|
1072
|
+
if (!selective) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
for (const s of cs.sprites(q)) {
|
|
1076
|
+
explicit.add(s);
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
tree.forEach(claim);
|
|
1080
|
+
const resolved = new Map();
|
|
1081
|
+
const of = step => {
|
|
1082
|
+
let r = resolved.get(step);
|
|
1083
|
+
if (!r) {
|
|
1084
|
+
r = resolveStep(step, cs, step.generic ? explicit : undefined);
|
|
1085
|
+
resolved.set(step, r);
|
|
1086
|
+
}
|
|
1087
|
+
return r;
|
|
1088
|
+
};
|
|
1089
|
+
/**
|
|
1090
|
+
* A node's own length, anchoring aside — for a block, the span its
|
|
1091
|
+
* children occupy, which is what a `@name` on it promises to anyone who
|
|
1092
|
+
* anchors against it.
|
|
1093
|
+
*/
|
|
1094
|
+
const extent = node => {
|
|
1095
|
+
if (isGate(node)) {
|
|
1096
|
+
return 0;
|
|
1097
|
+
}
|
|
1098
|
+
if (!isBlock(node)) {
|
|
1099
|
+
return of(node).duration;
|
|
1100
|
+
}
|
|
1101
|
+
return node.kind === 'sequence' ? node.children.reduce((sum, c) => sum + measure(c), 0) : node.children.reduce((max, c) => Math.max(max, measure(c)), 0);
|
|
1102
|
+
};
|
|
1103
|
+
/** what a node contributes to its parent's flow */
|
|
1104
|
+
const measure = node => {
|
|
1105
|
+
if (isGate(node)) {
|
|
1106
|
+
return 0;
|
|
1107
|
+
}
|
|
1108
|
+
// an anchored step or BLOCK is lifted out of its parent's flow: it
|
|
1109
|
+
// neither pushes a sequence forward nor stretches a block's span (§4.2)
|
|
1110
|
+
if (node.at) {
|
|
1111
|
+
return 0;
|
|
1112
|
+
}
|
|
1113
|
+
// a step's own duration already counts its delay; a block's does not,
|
|
1114
|
+
// because the delay is spent before its children begin
|
|
1115
|
+
return isBlock(node) ? (node.delay ?? 0) + extent(node) : of(node).duration;
|
|
1116
|
+
};
|
|
1117
|
+
const out = [];
|
|
1118
|
+
// duplicate names are an authoring error, caught before anything is placed
|
|
1119
|
+
const seen = new Set();
|
|
1120
|
+
const checkNames = node => {
|
|
1121
|
+
if (isGate(node)) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
// one namespace: a block and a step cannot share a name, or `@at` would
|
|
1125
|
+
// have two answers
|
|
1126
|
+
if (node.name) {
|
|
1127
|
+
if (seen.has(node.name)) {
|
|
1128
|
+
throw new Error(`choreo: two steps named '${node.name}'`);
|
|
1129
|
+
}
|
|
1130
|
+
seen.add(node.name);
|
|
1131
|
+
}
|
|
1132
|
+
if (isBlock(node)) {
|
|
1133
|
+
node.children.forEach(checkNames);
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
tree.forEach(checkNames);
|
|
1137
|
+
/** where each named step landed: when it starts (delay spent) and how long it plays */
|
|
1138
|
+
const names = new Map();
|
|
1139
|
+
const gates = [];
|
|
1140
|
+
/**
|
|
1141
|
+
* A pause is a total order (§4.1): a gate may only stand in a sequence
|
|
1142
|
+
* that no parallel contains — including the implicit parallel that is the
|
|
1143
|
+
* region's root when it has more than one top-level node.
|
|
1144
|
+
*/
|
|
1145
|
+
const forbidGates = node => {
|
|
1146
|
+
if (isGate(node)) {
|
|
1147
|
+
throw new Error('choreo: a gate inside a parallel has no meaning — a pause is a ' + 'total order; only an uncontained sequence can hold one');
|
|
1148
|
+
}
|
|
1149
|
+
if (isBlock(node)) {
|
|
1150
|
+
node.children.forEach(forbidGates);
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
/**
|
|
1154
|
+
* The whole score's span — computed BEFORE anything is placed, because an
|
|
1155
|
+
* open step's window depends on it.
|
|
1156
|
+
*
|
|
1157
|
+
* A score of nothing but open steps measures zero, and an open step's
|
|
1158
|
+
* window is "the enclosing block's span" — so the annotation the author
|
|
1159
|
+
* asked to be simply ON compiled to a zero-length run that ended on the
|
|
1160
|
+
* frame it was born. That is not a short wire, it is no wire; the app
|
|
1161
|
+
* that hit it wrote `ms: 3_600_000` to get an hour of clock out of a
|
|
1162
|
+
* step whose real lifetime is the scene's. So a score with no length of
|
|
1163
|
+
* its own does not borrow zero: its open cues STAND (§4.6), and the run
|
|
1164
|
+
* holds them until it is cancelled or replaced.
|
|
1165
|
+
*/
|
|
1166
|
+
const root = measure({
|
|
1167
|
+
children: tree,
|
|
1168
|
+
kind: 'parallel'
|
|
1169
|
+
});
|
|
1170
|
+
const standingScore = root === 0;
|
|
1171
|
+
/** did any cue actually take the standing window? */
|
|
1172
|
+
let stood = false;
|
|
1173
|
+
/** span: the time a hold without @duration is allowed to last from `start` */
|
|
1174
|
+
const place = (node, start, span) => {
|
|
1175
|
+
if (isGate(node)) {
|
|
1176
|
+
gates.push({
|
|
1177
|
+
at: start,
|
|
1178
|
+
auto: node.ms
|
|
1179
|
+
});
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
/** where an anchored node begins — the same arithmetic for a step and a block */
|
|
1183
|
+
const anchored = ref => {
|
|
1184
|
+
const target = names.get(ref.anchor);
|
|
1185
|
+
if (!target) {
|
|
1186
|
+
throw new Error(`choreo: @at names '${ref.anchor}', which is not a step ` + 'above this one — anchors point up the score');
|
|
1187
|
+
}
|
|
1188
|
+
return ref.edge === 'end' ? target.start + target.duration + (ref.delay ?? 0) * 1000 : target.start + (ref.progress ?? 0) * target.duration;
|
|
1189
|
+
};
|
|
1190
|
+
if (!isBlock(node)) {
|
|
1191
|
+
const r = of(node);
|
|
1192
|
+
const delay = node.delay ?? 0;
|
|
1193
|
+
let base = start;
|
|
1194
|
+
if (node.at) {
|
|
1195
|
+
if (r.open) {
|
|
1196
|
+
throw new Error('choreo: an anchored hold needs its own @duration — lifted out ' + 'of its block, it has no span to borrow');
|
|
1197
|
+
}
|
|
1198
|
+
base = anchored(node.at);
|
|
1199
|
+
}
|
|
1200
|
+
if (node.name) {
|
|
1201
|
+
names.set(node.name, {
|
|
1202
|
+
duration: Math.max(0, r.duration - delay),
|
|
1203
|
+
start: base + delay
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
for (const cue of r.cues) {
|
|
1207
|
+
const {
|
|
1208
|
+
offset,
|
|
1209
|
+
...rest
|
|
1210
|
+
} = cue;
|
|
1211
|
+
// `follow` belongs in this list for the same reason the other three
|
|
1212
|
+
// do — FollowStep.ms is documented as "the window; without it, the
|
|
1213
|
+
// enclosing block's span" — and was simply missed.
|
|
1214
|
+
const borrows = r.open && (cue.kind === 'hold' || cue.kind === 'raise' || cue.kind === 'tether' || cue.kind === 'follow');
|
|
1215
|
+
if (borrows && standingScore) {
|
|
1216
|
+
stood = true;
|
|
1217
|
+
}
|
|
1218
|
+
out.push({
|
|
1219
|
+
...rest,
|
|
1220
|
+
duration: borrows ? standingScore ? Infinity : Math.max(0, span - delay - offset) : cue.duration,
|
|
1221
|
+
standing: borrows && standingScore ? true : undefined,
|
|
1222
|
+
start: base + delay + offset
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
// a block answers to the anchor system on a step's terms: it can be
|
|
1228
|
+
// pointed at (`@name`), it can point (`@at`), and its delay is spent
|
|
1229
|
+
// inside its own slot before its children begin
|
|
1230
|
+
const total = extent(node);
|
|
1231
|
+
const from = (node.at ? anchored(node.at) : start) + (node.delay ?? 0);
|
|
1232
|
+
if (node.name) {
|
|
1233
|
+
// recorded BEFORE the children are placed, so a child may anchor
|
|
1234
|
+
// against the block it is in — `@at={{at 'intro' 0.5}}` reads the
|
|
1235
|
+
// same from inside as from outside
|
|
1236
|
+
names.set(node.name, {
|
|
1237
|
+
duration: total,
|
|
1238
|
+
start: from
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
if (node.kind === 'sequence') {
|
|
1242
|
+
let offset = 0;
|
|
1243
|
+
for (const child of node.children) {
|
|
1244
|
+
place(child, from + offset, total - offset);
|
|
1245
|
+
offset += measure(child);
|
|
1246
|
+
}
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
// a parallel: every child at once — and no gate anywhere under it
|
|
1250
|
+
node.children.forEach(forbidGates);
|
|
1251
|
+
for (const child of node.children) {
|
|
1252
|
+
place(child, from, total);
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
if (tree.length > 1) {
|
|
1256
|
+
tree.forEach(forbidGates);
|
|
1257
|
+
}
|
|
1258
|
+
for (const node of tree) {
|
|
1259
|
+
place(node, 0, root);
|
|
1260
|
+
}
|
|
1261
|
+
gates.sort((a, b) => a.at - b.at);
|
|
1262
|
+
const derived = out.filter(cue => cue.derive);
|
|
1263
|
+
if (!derived.length) {
|
|
1264
|
+
return {
|
|
1265
|
+
cues: out,
|
|
1266
|
+
gates,
|
|
1267
|
+
open: stood
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
// A follower's `now` is composed from whatever is driving its source
|
|
1271
|
+
// THIS frame, so it has to be evaluated after that driver has written
|
|
1272
|
+
// its frame. Cues are walked in order, so derived ones sort last —
|
|
1273
|
+
// stably, which keeps two followers in the order they were written.
|
|
1274
|
+
const nameOf = sprite => sprite.id ?? sprite.role ?? 'a sprite';
|
|
1275
|
+
const drivenBy = new Map();
|
|
1276
|
+
for (const cue of derived) {
|
|
1277
|
+
drivenBy.set(cue.sprite, cue);
|
|
1278
|
+
}
|
|
1279
|
+
for (const cue of derived) {
|
|
1280
|
+
for (const source of cue.derive.sources) {
|
|
1281
|
+
if (drivenBy.has(source.sprite)) {
|
|
1282
|
+
// Chains are resolvable in principle — a topological sort would do
|
|
1283
|
+
// it — but they are refused for now rather than half-supported: a
|
|
1284
|
+
// cycle inside one would be a frame loop with no honest answer,
|
|
1285
|
+
// and nothing yet needs the depth.
|
|
1286
|
+
throw new Error(`choreo: c.Follow on '${nameOf(cue.sprite)}' reads ` + `'${nameOf(source.sprite)}', which is itself derived — a follower may ` + 'not follow a follower');
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return {
|
|
1291
|
+
cues: [...out.filter(cue => !cue.derive), ...derived],
|
|
1292
|
+
gates,
|
|
1293
|
+
open: stood
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* A replacement pass's continuity cue (§3.1): the prior run was driving
|
|
1299
|
+
* this sprite through space and the new score does not name it. Unnamed
|
|
1300
|
+
* it would be released to its rest in one frame — the whole-bay snap —
|
|
1301
|
+
* so the region completes the score: one shape-matched move from the
|
|
1302
|
+
* painted box (which is what a mid-flight sprite's `initial` IS, by
|
|
1303
|
+
* design) to its rest. Transform-only, so a continuation can never
|
|
1304
|
+
* reflow the scene it is tidying; on the interrupted cue's own spring
|
|
1305
|
+
* when it had one, so the carry-on keeps the flight's character.
|
|
1306
|
+
*/
|
|
1307
|
+
function continuation(sprite, cs, inherited) {
|
|
1308
|
+
const step = {
|
|
1309
|
+
size: 'scale'
|
|
1310
|
+
};
|
|
1311
|
+
const r = resolveMove(step, sprite, cs);
|
|
1312
|
+
if (!r) {
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
// inherit only a spring's character: a tween's fixed duration was cut
|
|
1316
|
+
// for a different distance and would land wrong on this one
|
|
1317
|
+
const springy = inherited && (inherited['type'] === 'spring' || 'stiffness' in inherited || 'visualDuration' in inherited || 'bounce' in inherited);
|
|
1318
|
+
return {
|
|
1319
|
+
duration: r.longest,
|
|
1320
|
+
flight: r.flight,
|
|
1321
|
+
kind: 'move',
|
|
1322
|
+
sprite,
|
|
1323
|
+
start: 0,
|
|
1324
|
+
target: r.target,
|
|
1325
|
+
transition: springy ? inherited : springTransition(undefined)
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/* ---- score identity: is this compile the one already in flight? ---- */
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Whether two compiled scores are the SAME statement — used by the region
|
|
1333
|
+
* to keep an in-flight run when an unrelated render replays the pass (the
|
|
1334
|
+
* render detector is volatile by design, so on a busy page every region
|
|
1335
|
+
* re-passes on every app render; a neighbouring demo writing tracked state
|
|
1336
|
+
* per frame must not restart this region's clock).
|
|
1337
|
+
*
|
|
1338
|
+
* The comparison is deliberately conservative. A FLIGHT is measured off the
|
|
1339
|
+
* page and is not comparable by value: any cue carrying one makes the scores
|
|
1340
|
+
* different, and the pass replays exactly as it always did. Values that ARE
|
|
1341
|
+
* comparable (targets, transitions, delivery plans, holds, camera aims)
|
|
1342
|
+
* compare structurally, so an edit that retimes or re-aims anything replays.
|
|
1343
|
+
*
|
|
1344
|
+
* Tethers, raises and scrolls used to be lumped in with flights, and that was
|
|
1345
|
+
* wrong in a way that only showed up on standing annotations: a wire that is
|
|
1346
|
+
* simply ON was recompiled to the identical statement on every pass and, being
|
|
1347
|
+
* declared different every time, had its run torn down and rebuilt — the path
|
|
1348
|
+
* element removed from the layer and a new one appended, sixty times a second
|
|
1349
|
+
* on a busy page. None of the three carries measured geometry in its cue: a
|
|
1350
|
+
* tether is two sprites and a function, a raise is a sprite and a flag, a
|
|
1351
|
+
* scroll is a sprite and an alignment. They compare by IDENTITY, which is
|
|
1352
|
+
* exactly what "the same statement" means for them — and it matters most for
|
|
1353
|
+
* the scroll, where replacing the cue is a visible restart of the scroll.
|
|
1354
|
+
*/
|
|
1355
|
+
function sameScore(a, b) {
|
|
1356
|
+
return a.length === b.length && a.every((cue, i) => sameCue(cue, b[i]));
|
|
1357
|
+
}
|
|
1358
|
+
function sameCue(a, b) {
|
|
1359
|
+
if (a.kind !== b.kind || a.sprite.node !== b.sprite.node || a.start !== b.start || a.duration !== b.duration || (a.loop ?? false) !== (b.loop ?? false)) {
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
if (a.flight || b.flight) {
|
|
1363
|
+
return false;
|
|
1364
|
+
}
|
|
1365
|
+
if (a.tether || b.tether) {
|
|
1366
|
+
// the same two ends, drawn by the same function, under the same name
|
|
1367
|
+
if (!a.tether || !b.tether || a.tether.from?.node !== b.tether.from?.node || a.tether.to?.node !== b.tether.to?.node || a.tether.path !== b.tether.path || a.tether.name !== b.tether.name) {
|
|
1368
|
+
return false;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
if (a.raise || b.raise) {
|
|
1372
|
+
if (!a.raise || !b.raise || a.raise.shadow !== b.raise.shadow) {
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (a.attach || b.attach) {
|
|
1377
|
+
if (!a.attach || !b.attach || a.attach.region !== b.attach.region || a.attach.in !== b.attach.in || a.attach.rate !== b.attach.rate || a.attach.end !== b.attach.end) {
|
|
1378
|
+
return false;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (a.scroll || b.scroll) {
|
|
1382
|
+
if (!a.scroll || !b.scroll || a.scroll.align !== b.scroll.align) {
|
|
1383
|
+
return false;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
const plain = v => JSON.stringify(v ?? null);
|
|
1387
|
+
// steady rides Sprite objects (cyclic); its cast size stands in for it
|
|
1388
|
+
const cam = c => c ? {
|
|
1389
|
+
centre: c.centre ?? null,
|
|
1390
|
+
origin: c.origin ?? null,
|
|
1391
|
+
steady: c.steady.length,
|
|
1392
|
+
to: c.to
|
|
1393
|
+
} : null;
|
|
1394
|
+
return plain(a.target) === plain(b.target) && plain(a.transition) === plain(b.transition) && plain(a.delivery) === plain(b.delivery) && plain(a.hold) === plain(b.hold) && plain(cam(a.camera)) === plain(cam(b.camera)) &&
|
|
1395
|
+
// the 3D shot is part of the score, not decoration on it: two cues
|
|
1396
|
+
// that agree on everything else and disagree about where the lens
|
|
1397
|
+
// goes are different cues, and a run kept across that difference
|
|
1398
|
+
// plays the old path under the new film
|
|
1399
|
+
plain(a.camera3d) === plain(b.camera3d);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
export { continuation, compile as default, ladder, sameScore };
|
|
1403
|
+
//# sourceMappingURL=compile.js.map
|