@cardstack/choreo 0.0.0 → 0.1.0-unstable.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -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 +201 -6
|
@@ -0,0 +1,4424 @@
|
|
|
1
|
+
import { on } from '@ember/modifier';
|
|
2
|
+
import Component from '@glimmer/component';
|
|
3
|
+
import { tracked } from '@glimmer/tracking';
|
|
4
|
+
import { modifier } from 'ember-modifier';
|
|
5
|
+
import { motion } from 'glimmer-motion';
|
|
6
|
+
import { at } from '../anchors.js';
|
|
7
|
+
import { Choreo } from '../choreo.js';
|
|
8
|
+
import { Clip } from './clip.js';
|
|
9
|
+
import { resolveClips, clipWindow } from './clips.js';
|
|
10
|
+
import { VOCABULARY, FilmGraph } from './graph/host.js';
|
|
11
|
+
import { inGlass, PRESENTATIONS, seamShape, Joins } from './joins.js';
|
|
12
|
+
import { RAD, lerp, smooth, wrapAngle, luminance, rgba } from './math.js';
|
|
13
|
+
import { Captions, Insert, Stamp, Track } from './overlays.js';
|
|
14
|
+
import { Plate } from './plate.js';
|
|
15
|
+
import { Player, Burst, Menu } from './player.js';
|
|
16
|
+
import { Rail } from './rail.js';
|
|
17
|
+
import { TICK, beatStart, totalSecs, secsBefore, chapterHeads, tailFor, waypoints, cues, contents } from './schedule.js';
|
|
18
|
+
import { Seam } from './seam.js';
|
|
19
|
+
import { EndCard, Gate } from './titles.js';
|
|
20
|
+
import { precompileTemplate } from '@ember/template-compilation';
|
|
21
|
+
import { setComponentTemplate } from '@ember/component';
|
|
22
|
+
import { g, i } from 'decorator-transforms/runtime';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `?awake` keeps the loops running in a document that reports itself
|
|
26
|
+
* hidden — an embedded preview pane, a headless runner — where the idle
|
|
27
|
+
* gate would otherwise (correctly) stop the film dead. Tooling only.
|
|
28
|
+
*/
|
|
29
|
+
const AWAKE = new URLSearchParams(window.location.search).has('awake');
|
|
30
|
+
/** how much each mood brightens the frame, for judging the ink against it */
|
|
31
|
+
const GRADE_LUM = {
|
|
32
|
+
amber: 1.08,
|
|
33
|
+
chalk: 1.27,
|
|
34
|
+
ink: 1.0,
|
|
35
|
+
iron: 0.9,
|
|
36
|
+
plate: 1.09
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** what the blocks are given: the handle, and the vocabulary a film's score is written in */
|
|
40
|
+
/** the row a film stands on before it has any: the door, with nothing behind it */const EMPTY_BEAT = {
|
|
41
|
+
cam: {
|
|
42
|
+
dolly: 1,
|
|
43
|
+
lookY: 0,
|
|
44
|
+
pitch: 0,
|
|
45
|
+
yaw: 0
|
|
46
|
+
},
|
|
47
|
+
ch: 0,
|
|
48
|
+
id: '',
|
|
49
|
+
mode: 'clear',
|
|
50
|
+
ticks: 1
|
|
51
|
+
};
|
|
52
|
+
const EMPTY_CHAPTER = {
|
|
53
|
+
n: '',
|
|
54
|
+
title: ''
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* How long a render may wait for the scene to arrive and for the score's
|
|
58
|
+
* second pass to land (`openTheDoor`). Frames rather than milliseconds,
|
|
59
|
+
* because what it is waiting for is a render.
|
|
60
|
+
*/
|
|
61
|
+
const BOOT_FRAMES = 240;
|
|
62
|
+
/** one lane's clip, as the template needs it */
|
|
63
|
+
|
|
64
|
+
/** which lane a resolved clip sits on: its own, or its position in the beat */function laneOfClip(beat, clip) {
|
|
65
|
+
const list = beat.clips ?? [];
|
|
66
|
+
const at = list.indexOf(clip.spec);
|
|
67
|
+
return clip.spec.lane ?? (at >= 0 ? at : 0);
|
|
68
|
+
}
|
|
69
|
+
/** the rows are rebuilt every frame; only a real change should re-render */
|
|
70
|
+
function sameRows(a, b) {
|
|
71
|
+
if (a.length !== b.length) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return a.every((row, i) => {
|
|
75
|
+
const other = b[i];
|
|
76
|
+
return row.key === other.key && row.state === other.state && row.freeze === other.freeze && row.src === other.src;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
class Film extends Component {
|
|
80
|
+
static {
|
|
81
|
+
g(this.prototype, "graph", [tracked], function () {
|
|
82
|
+
return null;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
#graph = (i(this, "graph"), void 0);
|
|
86
|
+
/* ---- what the film was handed ------------------------------------ */
|
|
87
|
+
/**
|
|
88
|
+
* THE GRAPH, compiled. A film written as `f.Spine` / `f.Chapter` /
|
|
89
|
+
* `f.Shot` in the default block compiles to the same rows a table would
|
|
90
|
+
* hand in (`graph/compile.ts`); the table arguments, when given, win.
|
|
91
|
+
*/
|
|
92
|
+
takeGraph = graph => {
|
|
93
|
+
this.graph = graph;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* THE PICTURE, as it registered itself from the `<:picture>` block: the
|
|
97
|
+
* page, its files, how it is seated, its rig, its moods. Nothing here
|
|
98
|
+
* is about the edit, which is why none of it is an argument any more.
|
|
99
|
+
*/
|
|
100
|
+
static {
|
|
101
|
+
g(this.prototype, "picture", [tracked], function () {
|
|
102
|
+
return null;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
#picture = (i(this, "picture"), void 0);
|
|
106
|
+
takePicture = spec => {
|
|
107
|
+
this.picture = spec;
|
|
108
|
+
};
|
|
109
|
+
get assets() {
|
|
110
|
+
return this.picture?.assets ?? '';
|
|
111
|
+
}
|
|
112
|
+
get standing() {
|
|
113
|
+
return this.picture?.standing ?? 0;
|
|
114
|
+
}
|
|
115
|
+
get frameTitle() {
|
|
116
|
+
return this.picture?.title ?? this.args.name;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* THE WHOLE SHOT LIST, and there is only one place it comes from: the
|
|
120
|
+
* score in the default block, compiled. The film used to take a
|
|
121
|
+
* `Beat[]` as an argument as well, which is how both films were fed
|
|
122
|
+
* until Phase 2 migrated them; the table path is gone now that nothing
|
|
123
|
+
* hands one in, and the graph is the only way to write a film.
|
|
124
|
+
*/
|
|
125
|
+
get all() {
|
|
126
|
+
return this.graph?.beats ?? [];
|
|
127
|
+
}
|
|
128
|
+
get chapters() {
|
|
129
|
+
return this.graph?.chapters ?? [];
|
|
130
|
+
}
|
|
131
|
+
get voSecs() {
|
|
132
|
+
return this.graph?.voSecs ?? {};
|
|
133
|
+
}
|
|
134
|
+
get voGain() {
|
|
135
|
+
return this.args.voGain ?? this.graph?.voGain ?? {};
|
|
136
|
+
}
|
|
137
|
+
get grades() {
|
|
138
|
+
return this.picture?.grades ?? {};
|
|
139
|
+
}
|
|
140
|
+
get lookFx() {
|
|
141
|
+
return this.picture?.lookFx ?? {};
|
|
142
|
+
}
|
|
143
|
+
get lutAmount() {
|
|
144
|
+
return this.picture?.lutAmount ?? 0.52;
|
|
145
|
+
}
|
|
146
|
+
get defaultJoin() {
|
|
147
|
+
return this.args.join ?? this.graph?.defaultJoin ?? 'dip';
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* WHERE THIS SEAM IS PLAYED. A seam the PICTURE declares by name is
|
|
151
|
+
* played in its glass with that name; the three the film knows how to
|
|
152
|
+
* mix are played there under the film's own law; everything else is an
|
|
153
|
+
* overlay in this document. The name is the only thing that decides,
|
|
154
|
+
* which is what makes the vocabulary open: a picture that declares
|
|
155
|
+
* `ridged-burn` gets `<f.Join @presentation="ridged-burn" />` for free,
|
|
156
|
+
* and the film learns nothing about it but its length.
|
|
157
|
+
*/
|
|
158
|
+
glassFor(join) {
|
|
159
|
+
if (!this.film?.seam || !this.film.freeze) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (this.picture?.seams?.includes(join)) {
|
|
163
|
+
return {
|
|
164
|
+
name: join
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return inGlass(join) ? {} : null;
|
|
168
|
+
}
|
|
169
|
+
/** how deep a seam goes when nothing says: the film's own default */
|
|
170
|
+
get defaultOver() {
|
|
171
|
+
return this.args.over ?? this.graph?.defaultOver ?? 'picture';
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* HOW DEEP THIS BEAT'S SEAM GOES. The row says, or the presentation
|
|
175
|
+
* says, or the film does. A seam that covers the furniture is always a
|
|
176
|
+
* seam with a still in the DOM: the picture's own glass is inside the
|
|
177
|
+
* canvas and cannot cover anything outside it.
|
|
178
|
+
*/
|
|
179
|
+
overOf(beat) {
|
|
180
|
+
const join = beat.join ?? this.defaultJoin;
|
|
181
|
+
return beat.over ?? this.presentations[join]?.over ?? this.defaultOver;
|
|
182
|
+
}
|
|
183
|
+
/** every seam the film can play: its own twelve, and any the score brought */
|
|
184
|
+
get presentations() {
|
|
185
|
+
const brought = this.graph?.presentations ?? {};
|
|
186
|
+
const out = {
|
|
187
|
+
...PRESENTATIONS
|
|
188
|
+
};
|
|
189
|
+
for (const [name, p] of Object.entries(brought)) {
|
|
190
|
+
/* a seam the score merely RETIMED, or one the picture runs, brings
|
|
191
|
+
no component — it keeps whatever the film already knew about it */
|
|
192
|
+
out[name] = {
|
|
193
|
+
...out[name],
|
|
194
|
+
component: p.component ?? out[name]?.component,
|
|
195
|
+
over: p.over ?? out[name]?.over,
|
|
196
|
+
secs: p.secs,
|
|
197
|
+
still: p.component ? true : out[name]?.still ?? false
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
/** seconds a seam holds the picture; nothing, for one the film does not know */
|
|
203
|
+
secsOf(join) {
|
|
204
|
+
return join ? this.presentations[join]?.secs ?? 0 : 0;
|
|
205
|
+
}
|
|
206
|
+
/** does the seam paint a still of the outgoing frame over the incoming one */
|
|
207
|
+
holdsStill(join) {
|
|
208
|
+
return !!join && !!this.presentations[join]?.still;
|
|
209
|
+
}
|
|
210
|
+
/** how much of a frosted city a film frame can carry */
|
|
211
|
+
get cityGlass() {
|
|
212
|
+
return this.picture?.cityGlass ?? 0.16;
|
|
213
|
+
}
|
|
214
|
+
/** the rig's mid-height: `lookY` is world height minus this */
|
|
215
|
+
get rigMid() {
|
|
216
|
+
return this.picture?.rigMid ?? 6.6;
|
|
217
|
+
}
|
|
218
|
+
/** how much the passing cloud thickens the air */
|
|
219
|
+
get cloudHaze() {
|
|
220
|
+
return this.picture?.cloudHaze ?? 0.09;
|
|
221
|
+
}
|
|
222
|
+
/** the face type in the world is set in; a film that names none of it
|
|
223
|
+
* leaves the page's own */
|
|
224
|
+
get worldFamily() {
|
|
225
|
+
const w = this.picture?.worldType;
|
|
226
|
+
return w ? w.family : 'Archivo, "Helvetica Neue", Helvetica, Arial, sans-serif';
|
|
227
|
+
}
|
|
228
|
+
get worldWeight() {
|
|
229
|
+
const w = this.picture?.worldType;
|
|
230
|
+
return w ? w.weight : 800;
|
|
231
|
+
}
|
|
232
|
+
get worldTrack() {
|
|
233
|
+
return this.picture?.worldType?.track ?? 0.02;
|
|
234
|
+
}
|
|
235
|
+
/** the editorial accent: the film's own, or the scene's palette by day */
|
|
236
|
+
accentFor(dark, scene) {
|
|
237
|
+
const a = this.picture?.accent ?? {
|
|
238
|
+
dark: '#d9b44a',
|
|
239
|
+
light: '#8f6b1f'
|
|
240
|
+
};
|
|
241
|
+
return dark ? a.dark : a.light ?? scene ?? '#8f6b1f';
|
|
242
|
+
}
|
|
243
|
+
/** the film's handles, as the door and the card are given them */
|
|
244
|
+
get handle() {
|
|
245
|
+
return {
|
|
246
|
+
...VOCABULARY,
|
|
247
|
+
picture: this.picture?.adjustments ?? VOCABULARY.picture,
|
|
248
|
+
beat: this.beat,
|
|
249
|
+
begin: this.begin,
|
|
250
|
+
chapter: this.chapter,
|
|
251
|
+
cutTo: this.pick,
|
|
252
|
+
preview: this.preview,
|
|
253
|
+
ready: this.ready,
|
|
254
|
+
renderAt: this.renderAt,
|
|
255
|
+
restart: this.restart,
|
|
256
|
+
runtime: this.runtime,
|
|
257
|
+
seek: this.seek,
|
|
258
|
+
toc: this.toc
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/* ---- THE CLOCK, when the film is exact ---------------------------- *
|
|
262
|
+
* One number, seconds into the whole film. The frame loop advances it
|
|
263
|
+
* while playing; a scrub sets it; everything else is derived from it —
|
|
264
|
+
* the beat in force, the seam in progress, the score's and the type's
|
|
265
|
+
* run times, the pose. Nothing below accumulates.
|
|
266
|
+
* ------------------------------------------------------------------ */
|
|
267
|
+
get exact() {
|
|
268
|
+
return this.args.seek === 'exact';
|
|
269
|
+
}
|
|
270
|
+
/** the master clock: seconds into the whole film (exact mode) */
|
|
271
|
+
t = 0;
|
|
272
|
+
/** a seek happened since the last fold: it lands instantly, seam re-made */
|
|
273
|
+
jumped = false;
|
|
274
|
+
/** the score region's context, read every frame for its current run */
|
|
275
|
+
scoreCtx = null;
|
|
276
|
+
scoreRun = null;
|
|
277
|
+
/** the element the seam overlays live in, so their animations can be seeked */
|
|
278
|
+
joinsEl;
|
|
279
|
+
/** where the seam on screen began, film seconds, and how long it plays */
|
|
280
|
+
seamAt = -1;
|
|
281
|
+
seamLen = 0;
|
|
282
|
+
/** the lead whose air has been applied ahead of its beat */
|
|
283
|
+
airedFor = -1;
|
|
284
|
+
/** where the sun was before this beat asked, for a deterministic walk */
|
|
285
|
+
sunFrom = null;
|
|
286
|
+
/* ---- CLIPS --------------------------------------------------------- *
|
|
287
|
+
* A video, a still or a freeze of the picture over the frame, for a
|
|
288
|
+
* window on the film's clock (clips.ts). The state is re-derived every
|
|
289
|
+
* frame; the DOM changes only when the state does; the media element
|
|
290
|
+
* is driven to its source time, never left to its own clock.
|
|
291
|
+
* ------------------------------------------------------------------ */ /**
|
|
292
|
+
* THE CLIPS ON SCREEN, one row per lane, in lane order — which is also
|
|
293
|
+
* paint order. There used to be one of these and a beat that declared
|
|
294
|
+
* two clips kept the second one only.
|
|
295
|
+
*/
|
|
296
|
+
static {
|
|
297
|
+
g(this.prototype, "clipRows", [tracked], function () {
|
|
298
|
+
return [];
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
#clipRows = (i(this, "clipRows"), void 0);
|
|
302
|
+
/** the picture read back, per lane, for a freeze */clipFreezes = new Map();
|
|
303
|
+
clipNow = new Map();
|
|
304
|
+
clipMedia = new Map();
|
|
305
|
+
clipEl = modifier((el, [lane]) => {
|
|
306
|
+
this.clipMedia.set(lane, el);
|
|
307
|
+
return () => {
|
|
308
|
+
if (this.clipMedia.get(lane) === el) {
|
|
309
|
+
this.clipMedia.delete(lane);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
/**
|
|
314
|
+
* THE CLIP, EVERY FRAME. Resolve what should be on screen at this film
|
|
315
|
+
* time, land the DOM when that changes (a new window is a new element,
|
|
316
|
+
* so the entrance replays), and drive the media: a playing video is
|
|
317
|
+
* left to run and corrected when it drifts, a paused or scrubbed one
|
|
318
|
+
* is seeked, a held one stands at its out point, a frozen one is left
|
|
319
|
+
* alone. A freeze reads the picture back when its window opens — or,
|
|
320
|
+
* on a seek into it, after the page has been stood at that moment.
|
|
321
|
+
*/
|
|
322
|
+
clips(film, filmTime, jumped) {
|
|
323
|
+
const found = resolveClips(this.all, i => this.beatStart(i), filmTime, this.absoluteIndex, this.totalSecs);
|
|
324
|
+
const lanes = new Set();
|
|
325
|
+
const rows = [];
|
|
326
|
+
for (const clip of found) {
|
|
327
|
+
const lane = clip.spec.lane ?? laneOfClip(this.all[clip.index], clip);
|
|
328
|
+
lanes.add(lane);
|
|
329
|
+
const key = `${this.all[clip.index].id}:${lane}`;
|
|
330
|
+
const was = this.clipNow.get(lane);
|
|
331
|
+
this.clipNow.set(lane, clip);
|
|
332
|
+
const wasKey = was ? `${this.all[was.index].id}:${lane}` : '';
|
|
333
|
+
if (key !== wasKey) {
|
|
334
|
+
this.clipFreezes.delete(lane);
|
|
335
|
+
if (clip.spec.kind === 'freeze') {
|
|
336
|
+
/* the picture as it stands at the window's head. A seek into the
|
|
337
|
+
window lands after the head: stand the page there first */
|
|
338
|
+
if (jumped || clip.since > 0.1) {
|
|
339
|
+
this.freezeClipAt(clip, filmTime);
|
|
340
|
+
}
|
|
341
|
+
const shot = film.snapshot();
|
|
342
|
+
if (shot.length > 64) {
|
|
343
|
+
this.clipFreezes.set(lane, shot);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
rows.push({
|
|
348
|
+
freeze: this.clipFreezes.get(lane) ?? '',
|
|
349
|
+
key,
|
|
350
|
+
lane,
|
|
351
|
+
spec: clip.spec,
|
|
352
|
+
src: clip.spec.src ? `${this.assets}${clip.spec.src}` : '',
|
|
353
|
+
state: clip.state
|
|
354
|
+
});
|
|
355
|
+
this.driveClip(lane, clip, was, jumped);
|
|
356
|
+
}
|
|
357
|
+
/* a lane that emptied: fade its sound out and forget it */
|
|
358
|
+
for (const lane of [...this.clipNow.keys()]) {
|
|
359
|
+
if (lanes.has(lane)) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
this.clipNow.delete(lane);
|
|
363
|
+
this.clipFreezes.delete(lane);
|
|
364
|
+
const gone = this.clipMedia.get(lane);
|
|
365
|
+
if (gone instanceof HTMLVideoElement && gone.volume > 0.001) {
|
|
366
|
+
gone.volume = Math.max(0, gone.volume - 0.12);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (!sameRows(this.clipRows, rows)) {
|
|
370
|
+
this.clipRows = rows;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* ONE LANE'S MEDIA, every frame. A playing video is left to run and
|
|
375
|
+
* corrected when it drifts, a paused or scrubbed one is seeked, a held
|
|
376
|
+
* one stands at its out point, a frozen one is left alone.
|
|
377
|
+
*
|
|
378
|
+
* A CLIP'S SOUND NEVER STEPS. The element arrives at zero and is eased
|
|
379
|
+
* up to its level, and when its window closes it is eased back down
|
|
380
|
+
* before the exit fade takes the element away — a source that starts or
|
|
381
|
+
* stops at level is a click, whatever it is playing.
|
|
382
|
+
*/
|
|
383
|
+
driveClip(lane, found, was, jumped) {
|
|
384
|
+
const v = this.clipMedia.get(lane);
|
|
385
|
+
if (found.spec.kind !== 'video') {
|
|
386
|
+
if (v instanceof HTMLVideoElement && v.volume > 0.001) {
|
|
387
|
+
v.volume = Math.max(0, v.volume - 0.12);
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (!(v instanceof HTMLVideoElement) || found.source === null) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const level = Math.max(0, Math.min(1, found.spec.volume ?? 0));
|
|
395
|
+
if (was?.index !== found.index) {
|
|
396
|
+
v.volume = 0;
|
|
397
|
+
}
|
|
398
|
+
const closing = found.state !== 'active' ? 0 : level;
|
|
399
|
+
if (Math.abs(v.volume - closing) > 0.002) {
|
|
400
|
+
v.volume = v.volume + (closing - v.volume) * 0.28;
|
|
401
|
+
}
|
|
402
|
+
const rate = found.spec.rate ?? 1;
|
|
403
|
+
const live = found.state === 'active' && this.playing && this.scrubAt === null && !jumped;
|
|
404
|
+
if (live) {
|
|
405
|
+
if (v.paused) {
|
|
406
|
+
v.currentTime = found.source;
|
|
407
|
+
v.playbackRate = rate;
|
|
408
|
+
void v.play().catch(() => {});
|
|
409
|
+
} else if (Math.abs(v.currentTime - found.source) > 0.2) {
|
|
410
|
+
v.currentTime = found.source;
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
if (!v.paused) {
|
|
414
|
+
v.pause();
|
|
415
|
+
}
|
|
416
|
+
if (Math.abs(v.currentTime - found.source) > 0.04) {
|
|
417
|
+
v.currentTime = found.source;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* THE PICTURE AT A MOMENT THAT HAS PASSED. A freeze seeked into needs
|
|
423
|
+
* the frame from its window's head: the score is stood there for one
|
|
424
|
+
* evaluation (the camera step reports the pose), the page is posed and
|
|
425
|
+
* clocked to that moment, and the fold's own time is restored after
|
|
426
|
+
* the read-back.
|
|
427
|
+
*/
|
|
428
|
+
freezeClipAt(clip, filmTime) {
|
|
429
|
+
const film = this.film;
|
|
430
|
+
const run = this.scoreCtx?.run ?? null;
|
|
431
|
+
if (!film || !run) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const head = filmTime - clip.since;
|
|
435
|
+
const b = this.all[clip.index];
|
|
436
|
+
const local = Math.min(1, Math.max(0, (head - this.beatStart(clip.index)) / (b.ticks * TICK)));
|
|
437
|
+
run.pause();
|
|
438
|
+
run.time = head;
|
|
439
|
+
const g = this.goal;
|
|
440
|
+
film.pose({
|
|
441
|
+
az: g.yaw * RAD,
|
|
442
|
+
el: g.pitch * RAD,
|
|
443
|
+
fov: null,
|
|
444
|
+
fx: g.fx ?? 0,
|
|
445
|
+
fz: g.fz ?? 0,
|
|
446
|
+
lookY: g.lookY,
|
|
447
|
+
near: null,
|
|
448
|
+
ox: g.ox ?? 0,
|
|
449
|
+
snap: true,
|
|
450
|
+
zoom: g.dolly
|
|
451
|
+
});
|
|
452
|
+
if (Array.isArray(b.build)) {
|
|
453
|
+
film.time(lerp(b.build[0], b.build[1], smooth(Math.min(1, local / (b.buildBy ?? 1)))));
|
|
454
|
+
}
|
|
455
|
+
run.time = this.exact ? this.t : run.time;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* FILM SECONDS AT WHICH A BEAT'S OWN SHOT BEGINS. The cues fire one
|
|
459
|
+
* tick after the beat table says, because the pose-in-force seed
|
|
460
|
+
* occupies the spline's first slot — so the windows the exact clock
|
|
461
|
+
* derives carry the same offset, and the picture and the type agree
|
|
462
|
+
* with the cut film to the frame.
|
|
463
|
+
*/
|
|
464
|
+
beatStart(i) {
|
|
465
|
+
return beatStart(this.all, i);
|
|
466
|
+
}
|
|
467
|
+
/** the beat in force at a film time */
|
|
468
|
+
indexAt(t) {
|
|
469
|
+
let i = 0;
|
|
470
|
+
while (i + 1 < this.all.length && this.beatStart(i + 1) <= t) {
|
|
471
|
+
i += 1;
|
|
472
|
+
}
|
|
473
|
+
return i;
|
|
474
|
+
}
|
|
475
|
+
/** the score's region, held so its run can be driven by the clock */
|
|
476
|
+
grabScore = modifier((_el, [ctx]) => {
|
|
477
|
+
this.scoreCtx = ctx;
|
|
478
|
+
return () => {
|
|
479
|
+
if (this.scoreCtx === ctx) {
|
|
480
|
+
this.scoreCtx = null;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
joinsWrap = modifier(el => {
|
|
485
|
+
this.joinsEl = el;
|
|
486
|
+
return () => {
|
|
487
|
+
if (this.joinsEl === el) {
|
|
488
|
+
this.joinsEl = undefined;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
/** the score must stand even while paused: a paused exact film is a still */
|
|
493
|
+
get scoreOn() {
|
|
494
|
+
return this.playing || this.exact;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* SEEK. In exact mode the clock is set and the next fold lands there;
|
|
498
|
+
* in cut mode the nearest shot's head is re-cut to, which is the only
|
|
499
|
+
* honest seek a chased lens has.
|
|
500
|
+
*/
|
|
501
|
+
seek = seconds => {
|
|
502
|
+
if (!this.exact) {
|
|
503
|
+
let i = 0;
|
|
504
|
+
while (i + 1 < this.all.length && this.secsBefore(i + 1) <= seconds) {
|
|
505
|
+
i += 1;
|
|
506
|
+
}
|
|
507
|
+
this.cutTo(i);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const total = this.totalSecs;
|
|
511
|
+
this.t = Math.max(0, Math.min(total, seconds));
|
|
512
|
+
this.jumped = true;
|
|
513
|
+
this.menu = false;
|
|
514
|
+
if (this.ended && this.t < total) {
|
|
515
|
+
this.ended = false;
|
|
516
|
+
this.idled = false;
|
|
517
|
+
this.film?.idle(false);
|
|
518
|
+
}
|
|
519
|
+
if (this.booted && this.film) {
|
|
520
|
+
this.fold();
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
/**
|
|
524
|
+
* A STILL AT A TIME, for a capture worker: pause, seek, run one frame
|
|
525
|
+
* of the loop against a zero step, and give the page two frames to
|
|
526
|
+
* draw it. The same code path as playing there — that is the claim.
|
|
527
|
+
*/ /** a frame is being rendered rather than scrubbed to: seams play */
|
|
528
|
+
rendering = false;
|
|
529
|
+
/**
|
|
530
|
+
* A RENDER OPENS THE DOOR ITSELF.
|
|
531
|
+
*
|
|
532
|
+
* `?from=0` puts the title card up and leaves the film unbooted — and
|
|
533
|
+
* an unbooted film HAS NO SCORE, because the whole `<Choreo>` sits
|
|
534
|
+
* behind `{{#if this.booted}}` (the rig has to be an insertion before
|
|
535
|
+
* the camera step has a subject). So `fold`'s `run.time = t` wrote to
|
|
536
|
+
* a null run, `onCamera3D` never fired, `goal` stayed wherever the
|
|
537
|
+
* load path's `snap` seated it, and exact mode copied that one pose
|
|
538
|
+
* forward on every frame. Measured on towers, a stepped render swept
|
|
539
|
+
* half a degree of yaw across 273 seconds where the compiled path
|
|
540
|
+
* sweeps 380 — a whole render of a frozen lens, and `film-render.mjs`
|
|
541
|
+
* hit it every time, because its default `--from` is 0.
|
|
542
|
+
*
|
|
543
|
+
* A render is a request for the film, not for the lobby. So it opens
|
|
544
|
+
* the door itself, MUTED — nothing was clicked, and no browser hands
|
|
545
|
+
* out audio for a gesture nobody made.
|
|
546
|
+
*/
|
|
547
|
+
renderAt = async seconds => {
|
|
548
|
+
if (!this.exact) {
|
|
549
|
+
this.seek(seconds);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
await this.openTheDoor();
|
|
553
|
+
this.playing = false;
|
|
554
|
+
this.rendering = true;
|
|
555
|
+
try {
|
|
556
|
+
this.seek(seconds);
|
|
557
|
+
const now = performance.now();
|
|
558
|
+
this.lastTick = now;
|
|
559
|
+
this.tick(now);
|
|
560
|
+
await new Promise(r => requestAnimationFrame(() => r(undefined)));
|
|
561
|
+
await new Promise(r => requestAnimationFrame(() => r(undefined)));
|
|
562
|
+
} finally {
|
|
563
|
+
this.rendering = false;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
/**
|
|
567
|
+
* THE DOOR, OPENED BY A SCRIPT. Two waits, and neither is optional.
|
|
568
|
+
*
|
|
569
|
+
* The handle publishes itself from the mount modifier, a frame before
|
|
570
|
+
* the iframe's load seats the port — so a renderer that polls for
|
|
571
|
+
* `__choreo[name]` can call in before there is a picture to begin
|
|
572
|
+
* against, and `begin` would only pocket the choice. And `begin`
|
|
573
|
+
* itself DEFERS the lap bump that buys the score region its second
|
|
574
|
+
* render pass: until that pass lands the run does not exist, and a
|
|
575
|
+
* seek against a missing run is exactly the frozen lens this avoids.
|
|
576
|
+
*
|
|
577
|
+
* Both waits are bounded. A render against a film that never arrives
|
|
578
|
+
* degrades to the frame it would have drawn before, rather than
|
|
579
|
+
* hanging the worker on a promise that will not settle.
|
|
580
|
+
*/
|
|
581
|
+
async openTheDoor() {
|
|
582
|
+
if (this.booted) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const frame = () => new Promise(r => requestAnimationFrame(() => r(undefined)));
|
|
586
|
+
for (let i = 0; i < BOOT_FRAMES && !this.film; i += 1) {
|
|
587
|
+
await frame();
|
|
588
|
+
}
|
|
589
|
+
if (!this.film) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
this.begin(false);
|
|
593
|
+
for (let i = 0; i < BOOT_FRAMES && !this.scoreCtx?.run; i += 1) {
|
|
594
|
+
await frame();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* THE FOLD (exact mode): the beat in force is the one whose window
|
|
599
|
+
* holds the clock; entering it — forward by playing, or by a seek in
|
|
600
|
+
* either direction — applies it whole, because every beat asserts its
|
|
601
|
+
* complete state and never inherits. A seek that lands inside a seam
|
|
602
|
+
* re-makes the outgoing frame first, so the seam plays from the middle
|
|
603
|
+
* exactly as it would have played into it.
|
|
604
|
+
*/
|
|
605
|
+
fold() {
|
|
606
|
+
const film = this.film;
|
|
607
|
+
if (!film) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const t = this.t;
|
|
611
|
+
const i = this.indexAt(t);
|
|
612
|
+
const jumped = this.jumped;
|
|
613
|
+
this.jumped = false;
|
|
614
|
+
/* the air of a lead: the sky turns while the lens is still travelling */
|
|
615
|
+
const next = this.all[i + 1];
|
|
616
|
+
if (next && (next.lead ?? 0) > 0 && t >= this.secsBefore(i + 1) + TICK && this.airedFor !== i + 1) {
|
|
617
|
+
this.airedFor = i + 1;
|
|
618
|
+
this.applyAir(next, false);
|
|
619
|
+
}
|
|
620
|
+
if (i !== this.beatIndex || jumped) {
|
|
621
|
+
const beat = this.all[i];
|
|
622
|
+
const since = t - this.beatStart(i);
|
|
623
|
+
if (jumped) {
|
|
624
|
+
this.settleAir(i);
|
|
625
|
+
this.airedFor = -1;
|
|
626
|
+
this.freeze = '';
|
|
627
|
+
}
|
|
628
|
+
const join = beat.cut || beat.join ? beat.join ?? this.defaultJoin : null;
|
|
629
|
+
const len = this.secsOf(join ?? undefined);
|
|
630
|
+
this.seamAt = -1;
|
|
631
|
+
this.seamLen = 0;
|
|
632
|
+
/* A SEEK LANDS PAST THE SEAM. A seam is played forward, over a
|
|
633
|
+
still of the frame that was actually on screen; a seek has no
|
|
634
|
+
such frame, and re-making one (stand the picture at the previous
|
|
635
|
+
tail, read it back) bought a read-back per scrub for a transition
|
|
636
|
+
nobody asked to watch. So a jump into a seam's window stands at
|
|
637
|
+
the seam's END: the incoming shot, whole. */ /**
|
|
638
|
+
* A SCRUB IS NOT A RENDER.
|
|
639
|
+
*
|
|
640
|
+
* A jump into a seam's window stands at the seam's END: a seam is
|
|
641
|
+
* played forward over a still of the frame that was actually on
|
|
642
|
+
* screen, a scrub has no such frame, and re-making one bought a
|
|
643
|
+
* read-back per drag for a transition nobody asked to watch.
|
|
644
|
+
*
|
|
645
|
+
* But `renderAt` is a jump too, and a film rendered frame by frame
|
|
646
|
+
* is a jump on EVERY frame — so with that rule alone an exact film
|
|
647
|
+
* rendered deterministically had no seams in it at all. Which
|
|
648
|
+
* breaks the promise the mode is named for: the same frame whether
|
|
649
|
+
* you played there or rendered there. Measured on the reel, five of
|
|
650
|
+
* its twelve seams came out as hard cuts in the render and worked
|
|
651
|
+
* when played.
|
|
652
|
+
*
|
|
653
|
+
* So a render says so, and gets its seams.
|
|
654
|
+
*/
|
|
655
|
+
const inSeam = !!join && since < len && i > 0 && (!jumped || this.rendering);
|
|
656
|
+
const frozen = false;
|
|
657
|
+
if (inSeam) {
|
|
658
|
+
this.seamAt = this.beatStart(i);
|
|
659
|
+
this.seamLen = len;
|
|
660
|
+
}
|
|
661
|
+
this.beatIndex = i;
|
|
662
|
+
this.applyBeat(beat, jumped, {
|
|
663
|
+
frozen,
|
|
664
|
+
seam: inSeam,
|
|
665
|
+
since
|
|
666
|
+
});
|
|
667
|
+
if (jumped) {
|
|
668
|
+
this.speakAt(beat, since);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/* the seam on screen is a function of the clock: its animations are
|
|
672
|
+
paused and stood at the time the seam has been playing */
|
|
673
|
+
if (this.seamAt >= 0) {
|
|
674
|
+
const ms = Math.max(0, (t - this.seamAt) * 1000);
|
|
675
|
+
for (const a of this.joinsEl?.getAnimations({
|
|
676
|
+
subtree: true
|
|
677
|
+
}) ?? []) {
|
|
678
|
+
a.pause();
|
|
679
|
+
a.currentTime = ms;
|
|
680
|
+
}
|
|
681
|
+
if (t - this.seamAt > this.seamLen + 0.3 || t < this.seamAt) {
|
|
682
|
+
this.seamAt = -1;
|
|
683
|
+
this.joinKind = '';
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/* the score's run is driven, not played, and everything ATTACHED to
|
|
687
|
+
the score — the type, the insert, the clip — is driven by it in
|
|
688
|
+
turn (`c.Attach`): one write, and every window on the film's clock
|
|
689
|
+
stands where the clock says */
|
|
690
|
+
const run = this.scoreCtx?.run ?? null;
|
|
691
|
+
if (run !== this.scoreRun) {
|
|
692
|
+
this.scoreRun = run;
|
|
693
|
+
run?.pause();
|
|
694
|
+
}
|
|
695
|
+
if (run) {
|
|
696
|
+
run.time = t;
|
|
697
|
+
}
|
|
698
|
+
if (t >= this.totalSecs && !this.ended) {
|
|
699
|
+
this.end();
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/** a line, from the middle: what a seek owes the voice */
|
|
703
|
+
speakAt(beat, since) {
|
|
704
|
+
this.hush();
|
|
705
|
+
if (!beat.vo || !this.sound) {
|
|
706
|
+
this.film?.duck(1);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const read = this.voSecs[beat.id] ?? beat.ticks * TICK * 0.66;
|
|
710
|
+
if (since < read - 0.3) {
|
|
711
|
+
this.film?.voice(this.voSrc(beat), this.voGain[beat.id] ?? 1, since);
|
|
712
|
+
this.primeNext(beat);
|
|
713
|
+
} else {
|
|
714
|
+
this.film?.duck(1);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* THE SEAM'S GATE (`film/seam.ts`). A join's still is a JPEG the
|
|
719
|
+
* browser has not decoded yet on the frame it is handed over, and the
|
|
720
|
+
* lens moves on that frame — so every cut used to show three frames of
|
|
721
|
+
* the incoming shot before the outgoing one was on screen. The gate
|
|
722
|
+
* decodes first and holds the cut; a seam with no still runs at once.
|
|
723
|
+
*/
|
|
724
|
+
seam = new Seam();
|
|
725
|
+
/**
|
|
726
|
+
* THE SEAM, EXACT. Every join that carries the outgoing frame carries it
|
|
727
|
+
* as a still in the DOM — the page's own dissolves run on the page's
|
|
728
|
+
* clock and cannot be stood at a time — and the whip, which is the
|
|
729
|
+
* chaser's, becomes a cut. The overlay is then driven by the fold.
|
|
730
|
+
*/
|
|
731
|
+
seamExact(film, beat, join, seam) {
|
|
732
|
+
const wants = seam ? seam.seam : true;
|
|
733
|
+
/* the glass holds a mix seam here too, and an exact film gets the
|
|
734
|
+
better half of the bargain: the seam is a function of the clock, so
|
|
735
|
+
a scrub through it needs nothing stood at a time */
|
|
736
|
+
const glass = wants ? this.glassFor(join) : null;
|
|
737
|
+
if (glass) {
|
|
738
|
+
if (this.glassKind !== join) {
|
|
739
|
+
film.freeze?.(true);
|
|
740
|
+
this.glassKind = join;
|
|
741
|
+
this.glassName = glass.name ?? '';
|
|
742
|
+
this.glassLen = this.secsOf(join);
|
|
743
|
+
this.glassColor = beat.dipTo ?? '#0d0905';
|
|
744
|
+
this.freeze = '';
|
|
745
|
+
this.joinKind = '';
|
|
746
|
+
}
|
|
747
|
+
if (beat.cut) {
|
|
748
|
+
this.snap(beat.cam);
|
|
749
|
+
}
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const stills = this.holdsStill(join);
|
|
753
|
+
let held = this.freeze;
|
|
754
|
+
if (wants && stills && !seam?.frozen) {
|
|
755
|
+
const shot = film.snapshot();
|
|
756
|
+
held = shot.length > 64 ? shot : '';
|
|
757
|
+
}
|
|
758
|
+
if (!wants) {
|
|
759
|
+
held = '';
|
|
760
|
+
this.freeze = '';
|
|
761
|
+
}
|
|
762
|
+
/* the lens does not move until the still can be painted, and the
|
|
763
|
+
picture holds the outgoing frame while it waits */
|
|
764
|
+
if (held) {
|
|
765
|
+
film.freeze?.(true);
|
|
766
|
+
}
|
|
767
|
+
this.seam.hold(held, () => {
|
|
768
|
+
film.freeze?.(false);
|
|
769
|
+
this.seamPlay(film, beat, join, wants, held);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
/** the seam itself: the still on screen, the lens on the incoming shot */
|
|
773
|
+
seamPlay(film, beat, join, wants, held) {
|
|
774
|
+
this.freeze = held;
|
|
775
|
+
if (beat.cut || join === 'wipe' || join === 'whip') {
|
|
776
|
+
this.snap(beat.cam);
|
|
777
|
+
}
|
|
778
|
+
if (!wants) {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const deep = this.overOf(beat);
|
|
782
|
+
switch (join) {
|
|
783
|
+
case 'cut':
|
|
784
|
+
case 'whip':
|
|
785
|
+
break;
|
|
786
|
+
case 'dip':
|
|
787
|
+
this.dipColor = beat.dipTo ?? '#0d0905';
|
|
788
|
+
this.play('dip', deep);
|
|
789
|
+
break;
|
|
790
|
+
case 'flash':
|
|
791
|
+
this.play('flash', deep);
|
|
792
|
+
break;
|
|
793
|
+
case 'sweep':
|
|
794
|
+
this.lightSweep(film, beat);
|
|
795
|
+
break;
|
|
796
|
+
case 'defocus':
|
|
797
|
+
if (this.freeze) {
|
|
798
|
+
this.play('defocus', deep);
|
|
799
|
+
}
|
|
800
|
+
break;
|
|
801
|
+
case 'iris':
|
|
802
|
+
{
|
|
803
|
+
if (!this.freeze) {
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
const v = film.view();
|
|
807
|
+
const p = beat.to ? film.project(...beat.to) : undefined;
|
|
808
|
+
const cx = p ? Math.max(12, Math.min(88, p.x / v.w * 100)) : 50;
|
|
809
|
+
const cy = p ? Math.max(12, Math.min(88, p.y / v.h * 100)) : 50;
|
|
810
|
+
this.irisAt = `--ix:${cx.toFixed(1)}%;--iy:${cy.toFixed(1)}%`;
|
|
811
|
+
this.play('iris', deep);
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
default:
|
|
815
|
+
if (this.freeze) {
|
|
816
|
+
this.play(join, deep);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/** the last cue has run: hold the pose, settle the music, offer the card */
|
|
821
|
+
end() {
|
|
822
|
+
this.playing = false;
|
|
823
|
+
this.ended = true;
|
|
824
|
+
/* the voice goes out with the picture, not under it: a line still
|
|
825
|
+
running at the end is faded over a second, never cut */
|
|
826
|
+
this.film?.voiceStop(1000);
|
|
827
|
+
/* AND THE SCORE ENDS WITH IT. Ducking a step left the bed looping
|
|
828
|
+
under the end card for as long as the card was up — a film that
|
|
829
|
+
has finished playing over music that has not. It goes out over
|
|
830
|
+
four seconds, which is long enough to read as an ending and short
|
|
831
|
+
enough that the card is quiet before anybody clicks anything. */
|
|
832
|
+
if (this.film?.outro) {
|
|
833
|
+
this.film.outro(4200);
|
|
834
|
+
} else {
|
|
835
|
+
this.film?.duck(0.35);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/** the beat on screen; the score names it, the front layer renders it */
|
|
839
|
+
static {
|
|
840
|
+
g(this.prototype, "beatIndex", [tracked], function () {
|
|
841
|
+
return 0;
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
#beatIndex = (i(this, "beatIndex"), void 0);
|
|
845
|
+
static {
|
|
846
|
+
g(this.prototype, "playing", [tracked], function () {
|
|
847
|
+
return true;
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
#playing = (i(this, "playing"), void 0);
|
|
851
|
+
static {
|
|
852
|
+
g(this.prototype, "lap", [tracked], function () {
|
|
853
|
+
return 0;
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
#lap = (i(this, "lap"), void 0);
|
|
857
|
+
/** bumping this edits the score, which is how a finite film loops */
|
|
858
|
+
static {
|
|
859
|
+
g(this.prototype, "booted", [tracked], function () {
|
|
860
|
+
return false;
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
#booted = (i(this, "booted"), void 0);
|
|
864
|
+
static {
|
|
865
|
+
g(this.prototype, "joinKind", [tracked], function () {
|
|
866
|
+
return '';
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
#joinKind = (i(this, "joinKind"), void 0);
|
|
870
|
+
/** which seam is playing, and a key that replays it: bumped on every cut */
|
|
871
|
+
static {
|
|
872
|
+
g(this.prototype, "joinStamp", [tracked], function () {
|
|
873
|
+
return 0;
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
#joinStamp = (i(this, "joinStamp"), void 0);
|
|
877
|
+
static {
|
|
878
|
+
g(this.prototype, "joinOver", [tracked], function () {
|
|
879
|
+
return false;
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
#joinOver = (i(this, "joinOver"), void 0);
|
|
883
|
+
/** the seam on screen covers the furniture, not only the picture */
|
|
884
|
+
overTimer = 0;
|
|
885
|
+
/**
|
|
886
|
+
* THE SEAM IN THE GLASS. Its kind, how long it has and the colour it
|
|
887
|
+
* passes through — and `reveal`, how much of the incoming frame is NOT
|
|
888
|
+
* covered by it, which is the film's other half: the type, the stamp
|
|
889
|
+
* and the inserts wear it, so a caption is uncovered by the same
|
|
890
|
+
* gesture that uncovers the picture.
|
|
891
|
+
*/
|
|
892
|
+
glassKind = '';
|
|
893
|
+
/** the picture's own name for it, when the picture declared this seam */
|
|
894
|
+
glassName = '';
|
|
895
|
+
glassLen = 0;
|
|
896
|
+
glassColor = '';
|
|
897
|
+
reveal = 1;
|
|
898
|
+
/** the captured outgoing frame every freeze-based join plays with */
|
|
899
|
+
static {
|
|
900
|
+
g(this.prototype, "freeze", [tracked], function () {
|
|
901
|
+
return '';
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
#freeze = (i(this, "freeze"), void 0);
|
|
905
|
+
static {
|
|
906
|
+
g(this.prototype, "dipColor", [tracked], function () {
|
|
907
|
+
return '#0d0905';
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
#dipColor = (i(this, "dipColor"), void 0);
|
|
911
|
+
/** the 'dip' join's veil colour */
|
|
912
|
+
static {
|
|
913
|
+
g(this.prototype, "irisAt", [tracked], function () {
|
|
914
|
+
return '';
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
#irisAt = (i(this, "irisAt"), void 0);
|
|
918
|
+
/** where the iris closes to, as inline custom properties */
|
|
919
|
+
static {
|
|
920
|
+
g(this.prototype, "fault", [tracked], function () {
|
|
921
|
+
return '';
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
#fault = (i(this, "fault"), void 0);
|
|
925
|
+
/** dev beacon: the first uncaught error, worn on the sleeve */
|
|
926
|
+
/**
|
|
927
|
+
* THE SEAM, DRIVEN. Half of it is the picture, which is handed two
|
|
928
|
+
* numbers and composites them in light; half is the furniture, which
|
|
929
|
+
* wears what is left over. Both come from one progress, and that
|
|
930
|
+
* progress is a function of the clock — so a seek through a seam needs
|
|
931
|
+
* nothing stood at a time, which is what the DOM overlay always did.
|
|
932
|
+
*/ /** take a driven seam off: the picture back to live, the furniture whole */
|
|
933
|
+
endSeam() {
|
|
934
|
+
if (!this.glassKind) {
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
this.glassKind = '';
|
|
938
|
+
this.glassName = '';
|
|
939
|
+
this.reveal = 1;
|
|
940
|
+
this.film?.seam?.(null);
|
|
941
|
+
}
|
|
942
|
+
driveSeam(film, t) {
|
|
943
|
+
if (!this.glassKind) {
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const len = Math.max(0.001, this.glassLen);
|
|
947
|
+
const p = this.exact ? this.seamAt >= 0 ? (t - this.seamAt) / len : 1 : (performance.now() - this.beatAt) / 1000 / len;
|
|
948
|
+
if (p >= 1) {
|
|
949
|
+
this.endSeam();
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (this.glassName) {
|
|
953
|
+
/* a seam the picture declared: it owns the shape, so the film hands
|
|
954
|
+
it the progress and takes the progress back as the reveal — the
|
|
955
|
+
furniture arrives with the transition it cannot see inside */
|
|
956
|
+
film.seam?.({
|
|
957
|
+
mix: 0,
|
|
958
|
+
name: this.glassName,
|
|
959
|
+
p,
|
|
960
|
+
veil: 0
|
|
961
|
+
});
|
|
962
|
+
this.reveal = p;
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const {
|
|
966
|
+
mix,
|
|
967
|
+
veil
|
|
968
|
+
} = seamShape(this.glassKind, p);
|
|
969
|
+
film.seam?.({
|
|
970
|
+
color: this.glassColor,
|
|
971
|
+
mix,
|
|
972
|
+
p,
|
|
973
|
+
veil
|
|
974
|
+
});
|
|
975
|
+
this.reveal = 1 - Math.max(mix, veil);
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Run a seam's overlay: a new element per cut, from its own first
|
|
979
|
+
* frame. A seam played `everything` is lifted above the type, the
|
|
980
|
+
* insert and the clip for as long as it runs, and put back after — an
|
|
981
|
+
* empty layer left standing over the furniture would swallow the next
|
|
982
|
+
* shot's pointer events, and there is no event for a CSS animation the
|
|
983
|
+
* film did not start.
|
|
984
|
+
*/
|
|
985
|
+
play(kind, over = 'picture') {
|
|
986
|
+
this.joinKind = kind;
|
|
987
|
+
this.joinStamp += 1;
|
|
988
|
+
this.joinOver = over === 'everything';
|
|
989
|
+
window.clearTimeout(this.overTimer);
|
|
990
|
+
if (this.joinOver) {
|
|
991
|
+
const stamp = this.joinStamp;
|
|
992
|
+
this.overTimer = window.setTimeout(() => {
|
|
993
|
+
if (this.joinStamp === stamp) {
|
|
994
|
+
this.joinOver = false;
|
|
995
|
+
}
|
|
996
|
+
}, this.secsOf(kind) * 1000 + 80);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* THE ENDING. A film that laps back to its own first frame has no
|
|
1001
|
+
* ending, and the coda earns one — so when the last cue has run, the
|
|
1002
|
+
* run simply stops: the final pose holds, the music settles down a
|
|
1003
|
+
* step, and a card offers the way back in. Replay is a choice the
|
|
1004
|
+
* viewer makes, never something the clock does to them.
|
|
1005
|
+
*/
|
|
1006
|
+
static {
|
|
1007
|
+
g(this.prototype, "ended", [tracked], function () {
|
|
1008
|
+
return false;
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
#ended = (i(this, "ended"), void 0);
|
|
1012
|
+
static {
|
|
1013
|
+
g(this.prototype, "yearNow", [tracked], function () {
|
|
1014
|
+
return 1882;
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
#yearNow = (i(this, "yearNow"), void 0);
|
|
1018
|
+
/**
|
|
1019
|
+
* THE YEAR IS THE SUBJECT. A film whose whole argument is that one
|
|
1020
|
+
* building took a hundred and forty-four years cannot leave the audience
|
|
1021
|
+
* to infer the date from the state of the scaffolding: the year is on
|
|
1022
|
+
* screen, and it is on a line from the first stone to the present, so
|
|
1023
|
+
* 1954 is not a number but a POSITION — two thirds along a rule that
|
|
1024
|
+
* still has a third to run.
|
|
1025
|
+
*/
|
|
1026
|
+
yearSent = 0;
|
|
1027
|
+
/** whether the announced year is currently seated in the scene */
|
|
1028
|
+
stampOn = false;
|
|
1029
|
+
/** the page has been told the picture is static — see the frame loop */
|
|
1030
|
+
idled = false;
|
|
1031
|
+
gradeSent = '';
|
|
1032
|
+
rakeSent = NaN;
|
|
1033
|
+
rakeDeg = 35;
|
|
1034
|
+
/** which beat's lightning cue has fired (reset on every beat change) */
|
|
1035
|
+
struck = '';
|
|
1036
|
+
/** which of the beat's hours (Beat.hours) is in force; -1 between beats */
|
|
1037
|
+
hourStep = -1;
|
|
1038
|
+
/** the next grade goes to the glass without its ease (a still-join) */
|
|
1039
|
+
gradeSnap = false;
|
|
1040
|
+
/** the last stock sent to the glass, by name; undefined before the first */
|
|
1041
|
+
lutSent = undefined;
|
|
1042
|
+
/** the last hour a beat named, so the night can be known without naming it again */
|
|
1043
|
+
hourNow = 0;
|
|
1044
|
+
/** the scrub track's width in px, kept by a ResizeObserver */
|
|
1045
|
+
trackW = 0;
|
|
1046
|
+
trackRO;
|
|
1047
|
+
/** the chapter fractions the bar is drawn with, computed once */
|
|
1048
|
+
segFr;
|
|
1049
|
+
trackWrap = modifier(el => {
|
|
1050
|
+
this.trackW = el.clientWidth;
|
|
1051
|
+
this.trackRO = new ResizeObserver(() => {
|
|
1052
|
+
this.trackW = el.clientWidth;
|
|
1053
|
+
});
|
|
1054
|
+
this.trackRO.observe(el);
|
|
1055
|
+
return () => {
|
|
1056
|
+
this.trackRO?.disconnect();
|
|
1057
|
+
this.trackRO = undefined;
|
|
1058
|
+
};
|
|
1059
|
+
});
|
|
1060
|
+
/**
|
|
1061
|
+
* THE KNOB IS THE FILL'S END. The bar is chapters with a gap between
|
|
1062
|
+
* them, so the playhead's position is not linear in the playhead's
|
|
1063
|
+
* value — a second formula for the knob drifts from the fill inside
|
|
1064
|
+
* every segment. This is the one formula: the same fractions the fill
|
|
1065
|
+
* uses, over the measured track, with the gaps added back.
|
|
1066
|
+
*/
|
|
1067
|
+
headPx(prog) {
|
|
1068
|
+
const segs = this.segFr ??= this.playbar.map(c => {
|
|
1069
|
+
const m = /--s0:([\d.]+);--sl:([\d.]+)/.exec(c.style);
|
|
1070
|
+
return {
|
|
1071
|
+
s0: +m[1],
|
|
1072
|
+
sl: +m[2]
|
|
1073
|
+
};
|
|
1074
|
+
});
|
|
1075
|
+
const gap = 2;
|
|
1076
|
+
const usable = this.trackW - gap * (segs.length - 1);
|
|
1077
|
+
let i = segs.findIndex((s, n) => prog < s.s0 + s.sl || n === segs.length - 1);
|
|
1078
|
+
if (i < 0) {
|
|
1079
|
+
i = segs.length - 1;
|
|
1080
|
+
}
|
|
1081
|
+
const s = segs[i];
|
|
1082
|
+
const f = Math.max(0, Math.min(1, (prog - s.s0) / s.sl));
|
|
1083
|
+
return (s.s0 + f * s.sl) * usable + i * gap;
|
|
1084
|
+
}
|
|
1085
|
+
/** which tower the lineup's metronome is on; -1 between lineups */
|
|
1086
|
+
static {
|
|
1087
|
+
g(this.prototype, "cycleStep", [tracked], function () {
|
|
1088
|
+
return -1;
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
#cycleStep = (i(this, "cycleStep"), void 0);
|
|
1092
|
+
film;
|
|
1093
|
+
/**
|
|
1094
|
+
* THE FRAME'S DRESSING, WHERE THE PICTURE CAN TAKE IT.
|
|
1095
|
+
*
|
|
1096
|
+
* The wash and the cloud were CSS layers over the canvas, which meant a
|
|
1097
|
+
* seam could not touch them: the picture dissolved and the paper behind
|
|
1098
|
+
* the type snapped on the cut frame — two edits at once. A transition
|
|
1099
|
+
* applies to what its own container renders, so they belong in the
|
|
1100
|
+
* container, and the container is the picture's post pass (where the
|
|
1101
|
+
* vignette, the grain, the split tone and the grade already live). A
|
|
1102
|
+
* picture that offers `wash`/`cloud` gets them and the film stops
|
|
1103
|
+
* drawing its own; one that does not keeps the CSS layers.
|
|
1104
|
+
*/
|
|
1105
|
+
static {
|
|
1106
|
+
g(this.prototype, "washInGlass", [tracked], function () {
|
|
1107
|
+
return false;
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
#washInGlass = (i(this, "washInGlass"), void 0);
|
|
1111
|
+
static {
|
|
1112
|
+
g(this.prototype, "cloudInGlass", [tracked], function () {
|
|
1113
|
+
return false;
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
#cloudInGlass = (i(this, "cloudInGlass"), void 0);
|
|
1117
|
+
static {
|
|
1118
|
+
g(this.prototype, "dimInGlass", [tracked], function () {
|
|
1119
|
+
return false;
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
#dimInGlass = (i(this, "dimInGlass"), void 0);
|
|
1123
|
+
/** the palette's paper, as `wear` last read it, for the wash */paperNow = '';
|
|
1124
|
+
/** the last wash the picture was told about: mode and paper, told on change */
|
|
1125
|
+
washSent = '';
|
|
1126
|
+
frameEl;
|
|
1127
|
+
lineEl;
|
|
1128
|
+
dotEl;
|
|
1129
|
+
tetherEl;
|
|
1130
|
+
dim;
|
|
1131
|
+
/** the dim's own value, chased rather than cut so it never snaps on */
|
|
1132
|
+
dimNow = 0;
|
|
1133
|
+
plateEl;
|
|
1134
|
+
rowEls = [];
|
|
1135
|
+
raf = 0;
|
|
1136
|
+
lastTick = 0;
|
|
1137
|
+
/** when the current beat was entered, for the clocks it owns */
|
|
1138
|
+
beatAt = 0;
|
|
1139
|
+
/** where the beat's sky word was planted, in world azimuth */
|
|
1140
|
+
skyAz = 0;
|
|
1141
|
+
/** the azimuth the mark word is planted on, behind the building */
|
|
1142
|
+
markAz = 0;
|
|
1143
|
+
/** a sideways nudge in world units, for a word too short to centre */
|
|
1144
|
+
markOff = 0;
|
|
1145
|
+
/** the height the word rises FROM — set when it actually arrives */
|
|
1146
|
+
markFrom = 2.6;
|
|
1147
|
+
markSeated = false;
|
|
1148
|
+
/** the last pose actually pushed — the final smoothing stage */
|
|
1149
|
+
sent;
|
|
1150
|
+
/** the light in force, and the light the beat asked for */
|
|
1151
|
+
sunNow = null;
|
|
1152
|
+
sunGoal = null;
|
|
1153
|
+
/* ---- the player ------------------------------------------------- *
|
|
1154
|
+
* A film owes its viewer the controls every other film has: a
|
|
1155
|
+
* playhead you can put your hand on, chapters you can see coming, a
|
|
1156
|
+
* clock, and a way to make it the whole screen. It also owes them the
|
|
1157
|
+
* PICTURE — so the whole apparatus stands down when nobody is
|
|
1158
|
+
* touching it and comes back on the first movement.
|
|
1159
|
+
* ------------------------------------------------------------------ */
|
|
1160
|
+
static {
|
|
1161
|
+
g(this.prototype, "idle", [tracked], function () {
|
|
1162
|
+
return false;
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
#idle = (i(this, "idle"), void 0);
|
|
1166
|
+
static {
|
|
1167
|
+
g(this.prototype, "scrubAt", [tracked], function () {
|
|
1168
|
+
return null;
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
#scrubAt = (i(this, "scrubAt"), void 0);
|
|
1172
|
+
/** the fraction the hand is holding while scrubbing, else null */
|
|
1173
|
+
static {
|
|
1174
|
+
g(this.prototype, "hoverAt", [tracked], function () {
|
|
1175
|
+
return null;
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
#hoverAt = (i(this, "hoverAt"), void 0);
|
|
1179
|
+
/** the fraction under a resting pointer on the bar, else null */
|
|
1180
|
+
static {
|
|
1181
|
+
g(this.prototype, "vol", [tracked], function () {
|
|
1182
|
+
return Film.savedVol();
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
#vol = (i(this, "vol"), void 0);
|
|
1186
|
+
/** the master fader, 0..1, remembered across visits */
|
|
1187
|
+
static {
|
|
1188
|
+
g(this.prototype, "burst", [tracked], function () {
|
|
1189
|
+
return '';
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
#burst = (i(this, "burst"), void 0);
|
|
1193
|
+
/** the centre-screen glyph that confirms a play or a pause */
|
|
1194
|
+
static {
|
|
1195
|
+
g(this.prototype, "paused", [tracked], function () {
|
|
1196
|
+
return false;
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
#paused = (i(this, "paused"), void 0);
|
|
1200
|
+
/** the viewer's pause: the run, the page and the beat clock all hold */
|
|
1201
|
+
pausedAt = 0;
|
|
1202
|
+
/** the lap whose head beat the cut itself already applied */
|
|
1203
|
+
primedLap = -1;
|
|
1204
|
+
/** the bar's own element, for the slider's live value */
|
|
1205
|
+
scrubEl;
|
|
1206
|
+
shownPct = -1;
|
|
1207
|
+
static {
|
|
1208
|
+
g(this.prototype, "clock", [tracked], function () {
|
|
1209
|
+
return '0:00';
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
#clock = (i(this, "clock"), void 0);
|
|
1213
|
+
static {
|
|
1214
|
+
g(this.prototype, "full", [tracked], function () {
|
|
1215
|
+
return false;
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
#full = (i(this, "full"), void 0);
|
|
1219
|
+
idleTimer = 0;
|
|
1220
|
+
shownSec = -1;
|
|
1221
|
+
/** the whole film, in seconds, leads included */
|
|
1222
|
+
get totalSecs() {
|
|
1223
|
+
return totalSecs(this.all);
|
|
1224
|
+
}
|
|
1225
|
+
secsBefore(index) {
|
|
1226
|
+
return secsBefore(this.all, index);
|
|
1227
|
+
}
|
|
1228
|
+
/** one segment per chapter, sized and placed in whole-film fractions */
|
|
1229
|
+
get playbar() {
|
|
1230
|
+
const total = this.totalSecs;
|
|
1231
|
+
return this.chapterHeads.map((head, i) => {
|
|
1232
|
+
const end = this.chapterHeads[i + 1] ?? this.all.length;
|
|
1233
|
+
const ch = this.chapters[this.all[head].ch] ?? this.chapters[0];
|
|
1234
|
+
const s0 = this.secsBefore(head) / total;
|
|
1235
|
+
const sl = (this.secsBefore(end) - this.secsBefore(head)) / total;
|
|
1236
|
+
return {
|
|
1237
|
+
head,
|
|
1238
|
+
n: ch.n,
|
|
1239
|
+
style: `flex:${sl};--s0:${s0.toFixed(5)};--sl:${sl.toFixed(5)}`,
|
|
1240
|
+
title: ch.title
|
|
1241
|
+
};
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
static mmss(s) {
|
|
1245
|
+
const m = Math.floor(Math.max(0, s) / 60);
|
|
1246
|
+
return `${m}:${String(Math.floor(Math.max(0, s) % 60)).padStart(2, '0')}`;
|
|
1247
|
+
}
|
|
1248
|
+
get duration() {
|
|
1249
|
+
return Film.mmss(this.totalSecs);
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* What the hand is pointing at — resting on the bar or dragging it:
|
|
1253
|
+
* the chapter under it and the time, on two lines, the way every
|
|
1254
|
+
* player's hover bubble does. A drag wins over a hover.
|
|
1255
|
+
*/
|
|
1256
|
+
get tip() {
|
|
1257
|
+
const at = this.scrubAt ?? this.hoverAt;
|
|
1258
|
+
if (at === null) {
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
const want = at * this.totalSecs;
|
|
1262
|
+
let i = 0;
|
|
1263
|
+
while (i + 1 < this.all.length && this.secsBefore(i + 1) <= want) {
|
|
1264
|
+
i += 1;
|
|
1265
|
+
}
|
|
1266
|
+
const ch = this.chapters[this.all[i].ch] ?? this.chapters[0];
|
|
1267
|
+
return {
|
|
1268
|
+
ch: `${ch.n} ${ch.title}`,
|
|
1269
|
+
t: Film.mmss(want)
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
/** rolling AND not held by the viewer: what the play button shows */
|
|
1273
|
+
get live() {
|
|
1274
|
+
return this.playing && !this.paused;
|
|
1275
|
+
}
|
|
1276
|
+
/** the fader's setting, as it was left last time — one setting for
|
|
1277
|
+
* every film, the way a viewer's volume is theirs and not the film's */
|
|
1278
|
+
static savedVol() {
|
|
1279
|
+
try {
|
|
1280
|
+
const v = Number(window.localStorage.getItem('cf-vol'));
|
|
1281
|
+
return Number.isFinite(v) && v > 0 && v <= 1 ? v : 1;
|
|
1282
|
+
} catch {
|
|
1283
|
+
return 1;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
/** sound on, and the fader up: the speaker glyph's three states */
|
|
1287
|
+
get loud() {
|
|
1288
|
+
if (!this.sound || this.vol <= 0) {
|
|
1289
|
+
return 'off';
|
|
1290
|
+
}
|
|
1291
|
+
return this.vol < 0.5 ? 'low' : 'high';
|
|
1292
|
+
}
|
|
1293
|
+
get volStyle() {
|
|
1294
|
+
return `--cf-vol:${this.vol.toFixed(2)}`;
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* THE FADER. Dragging it up while muted unmutes, because that is what
|
|
1298
|
+
* the hand means; the value is the page's master gain, under the mix
|
|
1299
|
+
* and the mute, and it is remembered for the next visit.
|
|
1300
|
+
*/
|
|
1301
|
+
fade = e => {
|
|
1302
|
+
const v = Number(e.target.value);
|
|
1303
|
+
this.vol = v;
|
|
1304
|
+
this.film?.volume?.(v);
|
|
1305
|
+
try {
|
|
1306
|
+
window.localStorage.setItem('cf-vol', v.toFixed(2));
|
|
1307
|
+
} catch {
|
|
1308
|
+
/* private mode: the fader just does not remember */}
|
|
1309
|
+
if (v > 0 && !this.sound) {
|
|
1310
|
+
this.hear();
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
/** the glyph that flashes in the middle of the picture on play/pause */
|
|
1314
|
+
pop(kind) {
|
|
1315
|
+
if (this.gate || this.ended) {
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
this.burst = '';
|
|
1319
|
+
requestAnimationFrame(() => {
|
|
1320
|
+
this.burst = kind;
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
burstDone = () => {
|
|
1324
|
+
this.burst = '';
|
|
1325
|
+
};
|
|
1326
|
+
/**
|
|
1327
|
+
* A tap on the picture is play/pause and two taps are full screen —
|
|
1328
|
+
* the grammar of every player since the first one. The controls, the
|
|
1329
|
+
* door, the end card, the rail and the menu keep their own clicks.
|
|
1330
|
+
*/
|
|
1331
|
+
static onGlass(e) {
|
|
1332
|
+
const t = e.target;
|
|
1333
|
+
return !t?.closest('button, a, input, .cf-player, .cf-menu, .cf-gate, .cf-rail');
|
|
1334
|
+
}
|
|
1335
|
+
tap = e => {
|
|
1336
|
+
if (this.gate || this.ended || this.menu || !Film.onGlass(e)) {
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
this.toggle();
|
|
1340
|
+
};
|
|
1341
|
+
tapTwice = e => {
|
|
1342
|
+
if (this.gate || this.ended || this.menu || !Film.onGlass(e)) {
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
this.screen();
|
|
1346
|
+
};
|
|
1347
|
+
/** the bar's own element, for the slider's live value */
|
|
1348
|
+
slider = modifier(el => {
|
|
1349
|
+
this.scrubEl = el;
|
|
1350
|
+
return () => {
|
|
1351
|
+
this.scrubEl = undefined;
|
|
1352
|
+
};
|
|
1353
|
+
});
|
|
1354
|
+
wake = () => {
|
|
1355
|
+
if (this.idle) {
|
|
1356
|
+
this.idle = false;
|
|
1357
|
+
}
|
|
1358
|
+
window.clearTimeout(this.idleTimer);
|
|
1359
|
+
this.idleTimer = window.setTimeout(() => {
|
|
1360
|
+
/* never hide the controls out from under a hand that is using them */if (this.scrubAt === null && !this.menu && this.live) {
|
|
1361
|
+
this.idle = true;
|
|
1362
|
+
}
|
|
1363
|
+
}, 2600);
|
|
1364
|
+
};
|
|
1365
|
+
scrubFrom(e) {
|
|
1366
|
+
const el = e.currentTarget.getBoundingClientRect();
|
|
1367
|
+
return Math.max(0, Math.min(1, (e.clientX - el.left) / el.width));
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* The bubble and the ghost fill follow the pointer, not the playhead:
|
|
1371
|
+
* the bubble's x in pixels (held off the edges so it never clips) and
|
|
1372
|
+
* the hover fraction, both as properties, both without a re-render.
|
|
1373
|
+
*/
|
|
1374
|
+
point(e) {
|
|
1375
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
1376
|
+
const x = Math.max(0, Math.min(r.width, e.clientX - r.left));
|
|
1377
|
+
const at = r.width > 0 ? x / r.width : 0;
|
|
1378
|
+
const pad = Math.min(56, r.width / 2);
|
|
1379
|
+
const st = this.pageEl?.style;
|
|
1380
|
+
st?.setProperty('--cf-tipx', Math.max(pad, Math.min(r.width - pad, x)).toFixed(1));
|
|
1381
|
+
st?.setProperty('--cf-hov', at.toFixed(5));
|
|
1382
|
+
return at;
|
|
1383
|
+
}
|
|
1384
|
+
scrubDown = event => {
|
|
1385
|
+
const e = event;
|
|
1386
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
1387
|
+
this.scrubAt = this.point(e);
|
|
1388
|
+
};
|
|
1389
|
+
scrubMove = event => {
|
|
1390
|
+
const e = event;
|
|
1391
|
+
const at = this.point(e);
|
|
1392
|
+
if (this.scrubAt !== null) {
|
|
1393
|
+
this.scrubAt = at;
|
|
1394
|
+
/* an exact film follows the hand: the picture IS the tip */
|
|
1395
|
+
if (this.exact) {
|
|
1396
|
+
this.seek(this.barSecs(this.scrubAt));
|
|
1397
|
+
}
|
|
1398
|
+
} else if (e.pointerType !== 'touch') {
|
|
1399
|
+
this.hoverAt = at;
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
scrubLeave = () => {
|
|
1403
|
+
this.hoverAt = null;
|
|
1404
|
+
this.pageEl?.style.setProperty('--cf-hov', '0');
|
|
1405
|
+
};
|
|
1406
|
+
/**
|
|
1407
|
+
* THE PLAYHEAD SNAPS TO A SHOT, because this film cannot seek: the
|
|
1408
|
+
* lens is an integrator and a run dropped into its own middle arrives
|
|
1409
|
+
* with the wrong velocity (docs/choreo-splices.md). So the drag reads
|
|
1410
|
+
* as time, the label names the shot under the hand, and the release
|
|
1411
|
+
* RE-CUTS from that shot's head — which is the same edit the chapter
|
|
1412
|
+
* buttons and the arrow keys make.
|
|
1413
|
+
*/ /**
|
|
1414
|
+
* A HAND ON THE BAR NEVER LANDS ON THE CARD. The bar's right edge is
|
|
1415
|
+
* the film's last second, and a click there — or a drag that ran off
|
|
1416
|
+
* the end — seeks to the total, which is the ending itself: the card
|
|
1417
|
+
* came up under a hand that wanted the last shot. The bar stops two
|
|
1418
|
+
* seconds short; the ending is reached by playing to it.
|
|
1419
|
+
*/
|
|
1420
|
+
barSecs(at) {
|
|
1421
|
+
return Math.min(at * this.totalSecs, Math.max(0, this.totalSecs - 2));
|
|
1422
|
+
}
|
|
1423
|
+
scrubUp = event => {
|
|
1424
|
+
const e = event;
|
|
1425
|
+
const at = this.scrubAt ?? this.scrubFrom(e);
|
|
1426
|
+
this.scrubAt = null;
|
|
1427
|
+
this.hoverAt = null;
|
|
1428
|
+
this.pageEl?.style.setProperty('--cf-hov', '0');
|
|
1429
|
+
this.seek(this.barSecs(at));
|
|
1430
|
+
};
|
|
1431
|
+
screen = () => {
|
|
1432
|
+
const el = this.pageEl ?? this.frameEl;
|
|
1433
|
+
if (!el) {
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
if (document.fullscreenElement) {
|
|
1437
|
+
void document.exitFullscreen();
|
|
1438
|
+
} else {
|
|
1439
|
+
void el.requestFullscreen?.();
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
fullChange = () => {
|
|
1443
|
+
this.full = !!document.fullscreenElement;
|
|
1444
|
+
};
|
|
1445
|
+
/**
|
|
1446
|
+
* THE POINTER MOVES THE WORLD A LITTLE.
|
|
1447
|
+
*
|
|
1448
|
+
* A film that cannot be touched is a video, and the whole argument
|
|
1449
|
+
* here is that this is a scene. So the cursor gets a few degrees of
|
|
1450
|
+
* lean: the lens takes a fraction of it (real parallax — the building
|
|
1451
|
+
* and the hills separate) and the type planes take more of it in the
|
|
1452
|
+
* other direction, each layer by its own depth. It is small enough to
|
|
1453
|
+
* be felt rather than played with, and damped, so it never fights the
|
|
1454
|
+
* shot the score is composing.
|
|
1455
|
+
*/
|
|
1456
|
+
lean = {
|
|
1457
|
+
x: 0,
|
|
1458
|
+
y: 0
|
|
1459
|
+
};
|
|
1460
|
+
leanTo = {
|
|
1461
|
+
x: 0,
|
|
1462
|
+
y: 0
|
|
1463
|
+
};
|
|
1464
|
+
aim = e => {
|
|
1465
|
+
const el = this.pageEl ?? this.frameEl;
|
|
1466
|
+
if (!el) {
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
const r = el.getBoundingClientRect();
|
|
1470
|
+
this.leanTo.x = Math.max(-1, Math.min(1, (e.clientX - r.left) / r.width * 2 - 1));
|
|
1471
|
+
this.leanTo.y = Math.max(-1, Math.min(1, (e.clientY - r.top) / r.height * 2 - 1));
|
|
1472
|
+
};
|
|
1473
|
+
/** the haze the beat asked for — the weather drift breathes around it */
|
|
1474
|
+
hazeBase = null;
|
|
1475
|
+
/* the score's goal, and the two stages that chase it */
|
|
1476
|
+
goal = {
|
|
1477
|
+
...(this.all[0] ?? EMPTY_BEAT).cam
|
|
1478
|
+
};
|
|
1479
|
+
mid = {
|
|
1480
|
+
...(this.all[0] ?? EMPTY_BEAT).cam
|
|
1481
|
+
};
|
|
1482
|
+
midV = {
|
|
1483
|
+
dolly: 0,
|
|
1484
|
+
fx: 0,
|
|
1485
|
+
fz: 0,
|
|
1486
|
+
lookY: 0,
|
|
1487
|
+
ox: 0,
|
|
1488
|
+
pitch: 0,
|
|
1489
|
+
yaw: 0
|
|
1490
|
+
};
|
|
1491
|
+
now = {
|
|
1492
|
+
...(this.all[0] ?? EMPTY_BEAT).cam
|
|
1493
|
+
};
|
|
1494
|
+
nowV = {
|
|
1495
|
+
dolly: 0,
|
|
1496
|
+
fx: 0,
|
|
1497
|
+
fz: 0,
|
|
1498
|
+
lookY: 0,
|
|
1499
|
+
ox: 0,
|
|
1500
|
+
pitch: 0,
|
|
1501
|
+
yaw: 0
|
|
1502
|
+
};
|
|
1503
|
+
/** the world-layer plane's own fade, so a chapter's type breathes in */
|
|
1504
|
+
skyOn = 0;
|
|
1505
|
+
/**
|
|
1506
|
+
* THE SKY WORD'S OPACITY AS ACTUALLY SENT — not the ramp above.
|
|
1507
|
+
*
|
|
1508
|
+
* `skyOn` only says a word is WANTED; what the word is actually wearing
|
|
1509
|
+
* is that ramp times the beat's own in and out, and the out takes it to
|
|
1510
|
+
* nothing by 68% of the beat. When the next beat had no word the film
|
|
1511
|
+
* used to hand the picture `skyOn` to fade from — a number still sitting
|
|
1512
|
+
* near 1 — so a word that had been gone for seconds snapped back to 92%
|
|
1513
|
+
* on the frame after the cut and faded out a second time, across the
|
|
1514
|
+
* seam. Fade from what the word is wearing instead.
|
|
1515
|
+
*/
|
|
1516
|
+
skyA = 0;
|
|
1517
|
+
/** the time of day the furniture is currently dressed for */
|
|
1518
|
+
wearing = '';
|
|
1519
|
+
/** the cast-shadow offset the type is currently wearing, px */
|
|
1520
|
+
shadow = {
|
|
1521
|
+
x: 0,
|
|
1522
|
+
y: 0
|
|
1523
|
+
};
|
|
1524
|
+
pageEl;
|
|
1525
|
+
/**
|
|
1526
|
+
* WHERE THE CUT STARTS — the arrow keys' one piece of state.
|
|
1527
|
+
*
|
|
1528
|
+
* A film this long is unwatchable without chapter skip, and seeking a run
|
|
1529
|
+
* that carries an integrator is not honest (the chaser's pose depends on
|
|
1530
|
+
* its history, which is the price Drift's doctrine names out loud). So
|
|
1531
|
+
* skipping RE-CUTS instead of seeking: the beat list is sliced here, the
|
|
1532
|
+
* path and the cues are both derived from that list, and the score the
|
|
1533
|
+
* region sees is a different, shorter film. An edited timeline replays
|
|
1534
|
+
* from its own head, which is exactly the behaviour wanted — and it is
|
|
1535
|
+
* the same move Sylva's lap makes to loop.
|
|
1536
|
+
*
|
|
1537
|
+
* `?from=N` seeds it, so a shot can be linked to.
|
|
1538
|
+
*/ /**
|
|
1539
|
+
* The deep link's shot, as asked (`?from=N`). Clamped against the
|
|
1540
|
+
* rows when READ, never when set: with the score a graph the rows
|
|
1541
|
+
* arrive a frame after construction, and clamping here against zero
|
|
1542
|
+
* rows once made every deep link −1.
|
|
1543
|
+
*/
|
|
1544
|
+
static {
|
|
1545
|
+
g(this.prototype, "fromRaw", [tracked], function () {
|
|
1546
|
+
return (() => {
|
|
1547
|
+
const q = new URLSearchParams(window.location.search).get('from');
|
|
1548
|
+
const n = q ? Number(q) : 0;
|
|
1549
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
1550
|
+
})();
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
#fromRaw = (i(this, "fromRaw"), void 0);
|
|
1554
|
+
get from() {
|
|
1555
|
+
return Math.min(this.fromRaw, Math.max(0, this.all.length - 1));
|
|
1556
|
+
}
|
|
1557
|
+
set from(index) {
|
|
1558
|
+
this.fromRaw = index;
|
|
1559
|
+
}
|
|
1560
|
+
/** sound is off until asked for: nobody's first second should be音 */
|
|
1561
|
+
static {
|
|
1562
|
+
g(this.prototype, "sound", [tracked], function () {
|
|
1563
|
+
return false;
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
#sound = (i(this, "sound"), void 0);
|
|
1567
|
+
static {
|
|
1568
|
+
g(this.prototype, "gate", [tracked], function () {
|
|
1569
|
+
return false;
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
#gate = (i(this, "gate"), void 0);
|
|
1573
|
+
/**
|
|
1574
|
+
* THE GATE. This film is narrated, and narration behind a mute button
|
|
1575
|
+
* is a film shown with the projector lamp off — so the front door asks.
|
|
1576
|
+
* One held frame, one choice, and the choice IS the user gesture that
|
|
1577
|
+
* autoplay policy wants anyway: the browser unlocks audio on the same
|
|
1578
|
+
* click that starts the picture. Museums have worked this way forever.
|
|
1579
|
+
*
|
|
1580
|
+
* `?from` skips it (an authoring link wants the shot, not the lobby)
|
|
1581
|
+
* and so does embedding.
|
|
1582
|
+
*/
|
|
1583
|
+
static {
|
|
1584
|
+
g(this.prototype, "ready", [tracked], function () {
|
|
1585
|
+
return false;
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
#ready = (i(this, "ready"), void 0);
|
|
1589
|
+
/** the scene is loaded and seated behind the gate — the door can open */
|
|
1590
|
+
static {
|
|
1591
|
+
g(this.prototype, "rolling", [tracked], function () {
|
|
1592
|
+
return false;
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
#rolling = (i(this, "rolling"), void 0);
|
|
1596
|
+
/**
|
|
1597
|
+
* One pass BEHIND `booted`, on purpose. A region's first render
|
|
1598
|
+
* collects no score, so anything standing in it on that pass — the
|
|
1599
|
+
* first beat's type, a head photo — was never inserted and never
|
|
1600
|
+
* animates. Content mounts on this flag instead, which flips a tick
|
|
1601
|
+
* later: the same render that gives the score its first real pass
|
|
1602
|
+
* hands the front layer a genuine insertion, and the opening beat's
|
|
1603
|
+
* type is DELIVERED like every other beat's instead of being found
|
|
1604
|
+
* already on the glass.
|
|
1605
|
+
*/
|
|
1606
|
+
get beats() {
|
|
1607
|
+
return this.from > 0 && !this.exact ? this.all.slice(this.from) : this.all;
|
|
1608
|
+
}
|
|
1609
|
+
/** the first beat of each chapter, in whole-film indices */
|
|
1610
|
+
get chapterHeads() {
|
|
1611
|
+
return chapterHeads(this.all);
|
|
1612
|
+
}
|
|
1613
|
+
/** where we are in the WHOLE film, not the current cut */
|
|
1614
|
+
get absoluteIndex() {
|
|
1615
|
+
return this.from + this.beatIndex;
|
|
1616
|
+
}
|
|
1617
|
+
get beat() {
|
|
1618
|
+
return this.beats[this.beatIndex] ?? this.beats[0] ?? EMPTY_BEAT;
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* CAPTIONS. `?subs` seeds them on; CC toggles them.
|
|
1622
|
+
*
|
|
1623
|
+
* They are the narration, printed. That makes them two things at once
|
|
1624
|
+
* and both are wanted: an accessibility track for anyone who cannot or
|
|
1625
|
+
* would rather not hear the voice, and — before any voice exists — the
|
|
1626
|
+
* only reliable way to find out that a line is two seconds too long for
|
|
1627
|
+
* the shot it is sitting on.
|
|
1628
|
+
*/
|
|
1629
|
+
static {
|
|
1630
|
+
g(this.prototype, "subsOn", [tracked], function () {
|
|
1631
|
+
return new URLSearchParams(window.location.search).has('subs');
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
#subsOn = (i(this, "subsOn"), void 0);
|
|
1635
|
+
get subs() {
|
|
1636
|
+
return this.subsOn;
|
|
1637
|
+
}
|
|
1638
|
+
cc = () => {
|
|
1639
|
+
this.subsOn = !this.subsOn;
|
|
1640
|
+
};
|
|
1641
|
+
get grade() {
|
|
1642
|
+
return this.beat.grade ?? this.chapter.grade ?? 'amber';
|
|
1643
|
+
}
|
|
1644
|
+
get chapter() {
|
|
1645
|
+
return this.chapters[this.beat.ch] ?? this.chapters[0] ?? EMPTY_CHAPTER;
|
|
1646
|
+
}
|
|
1647
|
+
get embed() {
|
|
1648
|
+
/* the demo page mounts the theater route in an iframe with ?embed —
|
|
1649
|
+
Sylva's pattern: the gate, the transport and the dive stand down
|
|
1650
|
+
and the film begins muted */
|
|
1651
|
+
return (this.args.embed ?? false) || /[?&]embed\b/.test(window.location.search);
|
|
1652
|
+
}
|
|
1653
|
+
/** `?debug` puts the build stamp in the corner — see `@build` */
|
|
1654
|
+
get debug() {
|
|
1655
|
+
return /[?&]debug\b/.test(window.location.search);
|
|
1656
|
+
}
|
|
1657
|
+
get build() {
|
|
1658
|
+
return this.args.build ?? '';
|
|
1659
|
+
}
|
|
1660
|
+
get srcdoc() {
|
|
1661
|
+
return this.picture?.srcdoc;
|
|
1662
|
+
}
|
|
1663
|
+
get src() {
|
|
1664
|
+
const src = this.picture?.src;
|
|
1665
|
+
return src ? `${src}?host${AWAKE ? '&awake' : ''}` : '';
|
|
1666
|
+
}
|
|
1667
|
+
get photoSrc() {
|
|
1668
|
+
return `${this.assets}${this.beat.photo?.src ?? ''}`;
|
|
1669
|
+
}
|
|
1670
|
+
/** a photograph that failed to load is no photograph — drop the plate */
|
|
1671
|
+
lost = new Set();
|
|
1672
|
+
static {
|
|
1673
|
+
g(this.prototype, "lostStamp", [tracked], function () {
|
|
1674
|
+
return 0;
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
#lostStamp = (i(this, "lostStamp"), void 0);
|
|
1678
|
+
get hasPhoto() {
|
|
1679
|
+
const p = this.beat.photo;
|
|
1680
|
+
return !!p && this.lostStamp >= 0 && !this.lost.has(p.src);
|
|
1681
|
+
}
|
|
1682
|
+
missing = () => {
|
|
1683
|
+
const p = this.beat.photo;
|
|
1684
|
+
if (p) {
|
|
1685
|
+
this.lost.add(p.src);
|
|
1686
|
+
this.lostStamp += 1;
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
/** a fresh name per lap: an edited score replays, a restarted one fights */
|
|
1690
|
+
get filmName() {
|
|
1691
|
+
return `film-${this.lap}`;
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* HOW MUCH HAND, in seconds — see `@settle`.
|
|
1695
|
+
*
|
|
1696
|
+
* The default is a QUARTER OF THE GAP BETWEEN WAYPOINTS, because that
|
|
1697
|
+
* is the only number that means the same thing to two different films.
|
|
1698
|
+
* A window is smoothing relative to the spacing of the kinks it
|
|
1699
|
+
* smooths: a quarter takes the curvature step off every pose change
|
|
1700
|
+
* and is far too little to stop the lens visiting the poses. Both
|
|
1701
|
+
* films that ship here spend two seconds a waypoint, so both get half
|
|
1702
|
+
* a second; a score that cuts four times as fast gets a quarter of
|
|
1703
|
+
* that without being asked, and half a second imposed on it would have
|
|
1704
|
+
* flattened its shots.
|
|
1705
|
+
*
|
|
1706
|
+
* Capped at half a second so a very slow film does not drift, and
|
|
1707
|
+
* floored at nothing: `@settle={{0}}` gives the raw spline.
|
|
1708
|
+
*/
|
|
1709
|
+
get settle() {
|
|
1710
|
+
const said = this.args.settle;
|
|
1711
|
+
if (said !== undefined) {
|
|
1712
|
+
return said;
|
|
1713
|
+
}
|
|
1714
|
+
const points = this.path.length;
|
|
1715
|
+
return points > 0 ? Math.min(0.5, this.filmSeconds / points / 4) : 0;
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* The pose the score OPENS on, declared to the region — without it the
|
|
1719
|
+
* first spline segment travels in from the library's default rig, a
|
|
1720
|
+
* two-second bounce every cold boot wore before its first real frame.
|
|
1721
|
+
*/
|
|
1722
|
+
get openPose() {
|
|
1723
|
+
const c = (this.beats[0] ?? EMPTY_BEAT).cam;
|
|
1724
|
+
return {
|
|
1725
|
+
dolly: c.dolly,
|
|
1726
|
+
look: {
|
|
1727
|
+
x: c.fx ?? 0,
|
|
1728
|
+
y: c.lookY,
|
|
1729
|
+
z: c.fz ?? 0
|
|
1730
|
+
},
|
|
1731
|
+
pitch: c.pitch,
|
|
1732
|
+
x: c.ox ?? 0,
|
|
1733
|
+
y: 0,
|
|
1734
|
+
yaw: c.yaw
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
get filmSeconds() {
|
|
1738
|
+
return this.beats.reduce((n, b, i) => n + b.ticks + (i === 0 ? 0 : b.lead ?? 0), 0) * TICK;
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* THE WHOLE FILM IS ONE CAMERA STEP.
|
|
1742
|
+
*
|
|
1743
|
+
* Every beat contributes `ticks` waypoints: a moving beat is sampled from
|
|
1744
|
+
* its head pose to its tail, a holding beat repeats its pose, and the
|
|
1745
|
+
* spline runs through the lot on one clock. So the shot list is a path
|
|
1746
|
+
* rather than a playlist, the camera crosses each mark with velocity
|
|
1747
|
+
* instead of arriving at it, and a hold is genuinely still without
|
|
1748
|
+
* anything having stopped.
|
|
1749
|
+
*/ /**
|
|
1750
|
+
* WHERE A SHOT ENDS — the beat's authored tail, floored so it always
|
|
1751
|
+
* reads as a move, and clamped so it never travels past the pose the
|
|
1752
|
+
* next shot begins on. Shared by the path (which lerps its waypoints
|
|
1753
|
+
* across it) and by the LAUNCH: a cut hands the chaser this shot's own
|
|
1754
|
+
* speed, so the lens is already travelling when the cut lands rather
|
|
1755
|
+
* than easing up from a standstill.
|
|
1756
|
+
*/
|
|
1757
|
+
tailFor(bi) {
|
|
1758
|
+
return tailFor(this.beats, bi);
|
|
1759
|
+
}
|
|
1760
|
+
get path() {
|
|
1761
|
+
return waypoints(this.beats, this.from > 0);
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Each beat's entrance, as a delay into the one camera step — offset
|
|
1765
|
+
* one tick, because the pose-in-force seed occupies the spline's first
|
|
1766
|
+
* slot: waypoint k is crossed at (k+1) slots, not k. The old uniform
|
|
1767
|
+
* skew was invisible (camera and cues equally late, a constant the
|
|
1768
|
+
* chaser's own lag swallowed); a SPLICE made it audible — the snap
|
|
1769
|
+
* fired two seconds before the goal stepped, and the chaser spent the
|
|
1770
|
+
* gap dragged back toward the old shot. Cue 0 stays at zero: the boot
|
|
1771
|
+
* and every re-cut apply their head beat by hand, and its immediate
|
|
1772
|
+
* re-fire has always been the first cue's job.
|
|
1773
|
+
*/
|
|
1774
|
+
get cues() {
|
|
1775
|
+
return cues(this.beats);
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* THE ATTACHMENTS: every window the score drives another region over.
|
|
1779
|
+
* The plate for every beat, the insert for a beat with a photograph,
|
|
1780
|
+
* the clip for a beat with one — each on the score's own clock, so a
|
|
1781
|
+
* seek stands all of them where playing there would. The regions are
|
|
1782
|
+
* keyed per beat and mounted for their windows; `remove` past a window
|
|
1783
|
+
* leaves them to that; a clip may outlive its beat and holds.
|
|
1784
|
+
*/
|
|
1785
|
+
get windows() {
|
|
1786
|
+
const beats = this.beats;
|
|
1787
|
+
const heads = cues(beats).filter(c => c.action === 'beat');
|
|
1788
|
+
const total = totalSecs(beats);
|
|
1789
|
+
const out = [];
|
|
1790
|
+
heads.forEach((c, k) => {
|
|
1791
|
+
const b = beats[k];
|
|
1792
|
+
const start = c.delay;
|
|
1793
|
+
const next = heads[k + 1]?.delay ?? total;
|
|
1794
|
+
out.push({
|
|
1795
|
+
end: 'remove',
|
|
1796
|
+
length: next - start,
|
|
1797
|
+
region: 'plate',
|
|
1798
|
+
start
|
|
1799
|
+
});
|
|
1800
|
+
if (b.photo) {
|
|
1801
|
+
out.push({
|
|
1802
|
+
end: 'remove',
|
|
1803
|
+
length: next - start,
|
|
1804
|
+
region: 'insert',
|
|
1805
|
+
start
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
b.clips?.forEach(c => {
|
|
1809
|
+
out.push({
|
|
1810
|
+
end: 'hold',
|
|
1811
|
+
length: clipWindow(c, start, next),
|
|
1812
|
+
region: 'clip',
|
|
1813
|
+
start: start + (c.at ?? 0)
|
|
1814
|
+
});
|
|
1815
|
+
});
|
|
1816
|
+
});
|
|
1817
|
+
return out;
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* THE TRANSPORT — a broadcast bar, not a thermometer. One segment per
|
|
1821
|
+
* chapter, sized by the chapter's actual running time, filled by
|
|
1822
|
+
* WHOLE-film progress (the old bar measured the current cut, so a
|
|
1823
|
+
* skip made it lie), and clickable: the segments are the same re-cut
|
|
1824
|
+
* the menu and the arrows perform.
|
|
1825
|
+
*/ /**
|
|
1826
|
+
* ONE TIMELINE, NOT TWO.
|
|
1827
|
+
*
|
|
1828
|
+
* The corner carried a strip of chapter segments beside a rule of years:
|
|
1829
|
+
* two timelines of the same film, at two different scales, in two
|
|
1830
|
+
* different shapes, six inches apart. So the chapters are ON the years
|
|
1831
|
+
* now — each one a dot at the year it opens, so the gap between THE SITE
|
|
1832
|
+
* and SILENCE is fifty-four years wide and the gap between the last two
|
|
1833
|
+
* is a decade, which is the fact the film is about. The dots are still
|
|
1834
|
+
* the doors into the chapters.
|
|
1835
|
+
*/
|
|
1836
|
+
get transport() {
|
|
1837
|
+
const here = this.absoluteIndex;
|
|
1838
|
+
const clock = this.args.clock;
|
|
1839
|
+
const total = this.totalSecs;
|
|
1840
|
+
return this.contents.map(c => {
|
|
1841
|
+
const b = this.all[c.head];
|
|
1842
|
+
let at;
|
|
1843
|
+
let label;
|
|
1844
|
+
if (clock) {
|
|
1845
|
+
/* a chapter is a dot at the year it opens, so the gap between two
|
|
1846
|
+
chapters is the fact the film is about */
|
|
1847
|
+
const y = clock.yearAt(Array.isArray(b.build) ? b.build[0] : b.build ?? 0);
|
|
1848
|
+
const [a, z] = clock.span;
|
|
1849
|
+
at = Math.max(0, Math.min(1, (y - a) / (z - a)));
|
|
1850
|
+
label = String(Math.floor(y));
|
|
1851
|
+
} else {
|
|
1852
|
+
at = this.secsBefore(c.head) / total;
|
|
1853
|
+
}
|
|
1854
|
+
return {
|
|
1855
|
+
at,
|
|
1856
|
+
done: here >= c.head,
|
|
1857
|
+
head: c.head,
|
|
1858
|
+
here: c.here,
|
|
1859
|
+
label,
|
|
1860
|
+
n: c.n,
|
|
1861
|
+
title: c.title
|
|
1862
|
+
};
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
/** the ends of the rule, when it is a clock */
|
|
1866
|
+
get railLabels() {
|
|
1867
|
+
const span = this.args.clock?.span;
|
|
1868
|
+
return span ? [String(span[0]), String(span[1])] : undefined;
|
|
1869
|
+
}
|
|
1870
|
+
/** the rail is on unless the film says otherwise */
|
|
1871
|
+
get railOn() {
|
|
1872
|
+
return this.args.rail ?? true;
|
|
1873
|
+
}
|
|
1874
|
+
get railReadout() {
|
|
1875
|
+
return this.args.clock ? String(this.yearNow) : '';
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* WHEN EACH CUE LANDS — paced against the voice, not the clock.
|
|
1879
|
+
*
|
|
1880
|
+
* The phrases used to arrive on four fixed delays, which meant a
|
|
1881
|
+
* fourteen-second beat finished its type with five seconds of dead air
|
|
1882
|
+
* and a short one crowded the read. So the cues spread themselves: the
|
|
1883
|
+
* first lands early, the last lands as the measured line is finishing
|
|
1884
|
+
* (`VO_SECS`), and a beat with no recording paces against two thirds of
|
|
1885
|
+
* its own length, which is where a read for it would sit anyway.
|
|
1886
|
+
*/ /**
|
|
1887
|
+
* THE TRANSITION FIRST, THEN THE TYPE. A setting that arrives while the
|
|
1888
|
+
* seam is still on screen is read against two pictures at once. The
|
|
1889
|
+
* block waits for the incoming join to finish and a breath more, then
|
|
1890
|
+
* enters in its order: kicker, glyph, reading — and the lines after.
|
|
1891
|
+
*/
|
|
1892
|
+
get typeAt() {
|
|
1893
|
+
/* A SEAM THAT COVERS THE FURNITURE NEEDS NO WAIT. The still is above
|
|
1894
|
+
the type, so the setting may enter from the beat's first frame and
|
|
1895
|
+
be under way — not starting — when the sweep reveals it. That is
|
|
1896
|
+
the whole point of `everything`. */
|
|
1897
|
+
if (this.overOf(this.beat) === 'everything') {
|
|
1898
|
+
return [0, 0.12, 0.36];
|
|
1899
|
+
}
|
|
1900
|
+
/* otherwise the type waits for the SEAM to finish, and an unnamed
|
|
1901
|
+
join is the default one — reading '' here meant every default cut
|
|
1902
|
+
typed straight through its own transition */
|
|
1903
|
+
const wait = this.secsOf(this.beat.join ?? this.defaultJoin) + 0.45;
|
|
1904
|
+
return [wait, wait + 0.12, wait + 0.36];
|
|
1905
|
+
}
|
|
1906
|
+
get sayAt() {
|
|
1907
|
+
const b = this.beat;
|
|
1908
|
+
const n = b.says?.length ?? 0;
|
|
1909
|
+
const dur = b.ticks * TICK;
|
|
1910
|
+
const vo = this.voSecs[b.id];
|
|
1911
|
+
const first = this.typeAt[2] + 0.3;
|
|
1912
|
+
/* land the last cue on the line's last breath, never inside the
|
|
1913
|
+
beat's own exit */
|
|
1914
|
+
const last = Math.max(first + 0.8, Math.min(vo ? vo - 0.4 : dur * 0.66, dur - 1.4));
|
|
1915
|
+
return [0, 1, 2, 3].map(i => n <= 1 ? first : first + Math.min(i, n - 1) * (last - first) / (n - 1));
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Thai and Khmer are not kanji: their ascenders, vowel marks and
|
|
1919
|
+
* subscripts stand far outside a CJK-tuned glyph box, so at the kanji
|
|
1920
|
+
* size they collide with the kicker above and the reading below. Tall
|
|
1921
|
+
* scripts take a reduced setting with real leading.
|
|
1922
|
+
*/ /**
|
|
1923
|
+
* HOW MANY CHARACTERS HAVE TO FIT. A vertical setting is as tall as
|
|
1924
|
+
* its string, and 一国一城令 is five glyphs — at the plate's own size
|
|
1925
|
+
* that column runs off the top of the frame and the film crops its
|
|
1926
|
+
* own title. The count goes to CSS, which sizes the column to the
|
|
1927
|
+
* height available rather than to a number somebody typed once while
|
|
1928
|
+
* looking at a three-glyph word.
|
|
1929
|
+
*/
|
|
1930
|
+
get glyphFit() {
|
|
1931
|
+
return `--cf-glyphs:${Math.max(2, [...(this.beat.kanji ?? '')].length)}`;
|
|
1932
|
+
}
|
|
1933
|
+
get glyphTone() {
|
|
1934
|
+
const k = this.beat.kanji ?? '';
|
|
1935
|
+
if (/[A-Za-zÀ-ÿ]/.test(k)) {
|
|
1936
|
+
return 'is-latin';
|
|
1937
|
+
}
|
|
1938
|
+
return /[-ក-]/.test(k) ? 'is-tall' : '';
|
|
1939
|
+
}
|
|
1940
|
+
/** the lineup's current kanji; empty between lineups */
|
|
1941
|
+
get stamp() {
|
|
1942
|
+
const c = this.beat.cycle;
|
|
1943
|
+
return c && this.cycleStep >= 0 ? c[this.cycleStep]?.kanji ?? '' : '';
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* THE POSTER IS AN EVENING (by default). The film proper opens in the
|
|
1947
|
+
* morning, so the door stands in the last of the light with a long hard
|
|
1948
|
+
* shadow across the empty half of the frame; pressing it turns the day
|
|
1949
|
+
* over. The film may dress its door otherwise (`@poster`).
|
|
1950
|
+
*/
|
|
1951
|
+
dressPoster(film) {
|
|
1952
|
+
const p = this.picture?.poster ?? {};
|
|
1953
|
+
film.theme(p.theme ?? 2, true);
|
|
1954
|
+
const l = p.light ?? {
|
|
1955
|
+
az: -105,
|
|
1956
|
+
el: 21
|
|
1957
|
+
};
|
|
1958
|
+
film.light({
|
|
1959
|
+
az: l.az * RAD,
|
|
1960
|
+
el: l.el * RAD
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
announced = false;
|
|
1964
|
+
mount = modifier(el => {
|
|
1965
|
+
/* the rows are a tracked read on purpose: a film whose score is a
|
|
1966
|
+
graph has none on its first pass, and this modifier re-installs
|
|
1967
|
+
— and re-runs the load path below — when they arrive */
|
|
1968
|
+
const rows = this.all.length;
|
|
1969
|
+
/* which cut is actually in the browser, said out loud once */
|
|
1970
|
+
if (!this.announced) {
|
|
1971
|
+
this.announced = true;
|
|
1972
|
+
console.info(`film ${this.args.name}: ${this.args.build ?? ''}`);
|
|
1973
|
+
}
|
|
1974
|
+
this.frameEl = el;
|
|
1975
|
+
this.pageEl = el.closest('.cf-page');
|
|
1976
|
+
window.addEventListener('keydown', this.key);
|
|
1977
|
+
window.addEventListener('pointermove', this.aim);
|
|
1978
|
+
window.addEventListener('pointermove', this.wake);
|
|
1979
|
+
window.addEventListener('wheel', this.wake, {
|
|
1980
|
+
passive: true
|
|
1981
|
+
});
|
|
1982
|
+
document.addEventListener('fullscreenchange', this.fullChange);
|
|
1983
|
+
/* NOT this.wake() — a modifier runs INSIDE the render pass, and
|
|
1984
|
+
`idle` is read by the template in that same computation; writing
|
|
1985
|
+
it here is a backtracking rerender, which Ember asserts on and
|
|
1986
|
+
which takes the whole pass (and the film) down with it. The timer
|
|
1987
|
+
is armed from a timeout instead, which is outside it. */
|
|
1988
|
+
window.setTimeout(() => {
|
|
1989
|
+
if (this.booted) {
|
|
1990
|
+
this.wake();
|
|
1991
|
+
}
|
|
1992
|
+
}, 0);
|
|
1993
|
+
window.addEventListener('error', this.trip);
|
|
1994
|
+
window.addEventListener('unhandledrejection', this.trip);
|
|
1995
|
+
const frame = el.querySelector('iframe');
|
|
1996
|
+
if (!frame) {
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
/* a film owns the whole frame: the app's bar and footer step out
|
|
2000
|
+
entirely rather than fading, because unlike Sylva's theater there
|
|
2001
|
+
is no lockup here that wants to stay superimposed */
|
|
2002
|
+
document.body.classList.add(this.embed ? 'cf-embedded' : 'cf-film');
|
|
2003
|
+
/**
|
|
2004
|
+
* THE FILM, REACHABLE FROM OUTSIDE THE PAGE.
|
|
2005
|
+
*
|
|
2006
|
+
* An exact film is a pure function of one number, which is the whole
|
|
2007
|
+
* claim of `@seek='exact'` — so it should be possible to stand it at
|
|
2008
|
+
* a time from a script and read the frame back, rather than recording
|
|
2009
|
+
* it in real time and hoping. `renderAt` has always existed on the
|
|
2010
|
+
* handle; it was simply not reachable by anything that is not a
|
|
2011
|
+
* template. Published under the film's own name so a page may hold
|
|
2012
|
+
* more than one.
|
|
2013
|
+
*/
|
|
2014
|
+
const reg = window.__choreo ??= {};
|
|
2015
|
+
reg[this.args.name] = {
|
|
2016
|
+
exact: this.exact,
|
|
2017
|
+
renderAt: this.renderAt,
|
|
2018
|
+
runtime: () => this.runtime,
|
|
2019
|
+
seconds: () => this.totalSecs,
|
|
2020
|
+
seek: this.seek
|
|
2021
|
+
};
|
|
2022
|
+
/* only ever gate a film that has not begun: a dev-mode template swap
|
|
2023
|
+
re-runs this modifier on the SAME instance, and resurrecting the
|
|
2024
|
+
door over a running film left its buttons answering to a guard
|
|
2025
|
+
that told them the film had already started — because it had */ /* AND AN EMBED IS GATED TOO. It used to start itself, on the
|
|
2026
|
+
grounds that a frame with no transport has no way to turn the
|
|
2027
|
+
sound on — but that is exactly backwards: a film that starts
|
|
2028
|
+
without being asked has spent no user gesture, and every browser
|
|
2029
|
+
withholds audio until one is spent. So the door goes up inside
|
|
2030
|
+
the frame as well, and the click that opens it is the gesture the
|
|
2031
|
+
audio graph has been waiting for. A deep link (`?from`) still
|
|
2032
|
+
skips it: that link asked for a shot, not for the lobby. */
|
|
2033
|
+
this.gate = this.from === 0 && !this.booted;
|
|
2034
|
+
const onLoad = () => {
|
|
2035
|
+
const w = frame.contentWindow;
|
|
2036
|
+
if (!w?.__film) {
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
/* no rows yet: the graph compiles a frame after render, and the
|
|
2040
|
+
boot below stands the picture on the FIRST shot — wait for it */
|
|
2041
|
+
if (!rows && !this.all.length) {
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
this.film = w.__film;
|
|
2045
|
+
this.takeGlass(this.film);
|
|
2046
|
+
this.film.volume?.(this.vol);
|
|
2047
|
+
/* a RUNNING film needs nothing from the door. This path re-fires
|
|
2048
|
+
whenever the modifier re-installs (it reads tracked state), and
|
|
2049
|
+
re-seating here yanked the lens back to the cut's head. */
|
|
2050
|
+
if (this.booted) {
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
try {
|
|
2054
|
+
/* the page opens on an empty site — its own clock is at zero —
|
|
2055
|
+
and most of this film is about a finished building. Standing it
|
|
2056
|
+
up before the first beat runs means a beat only ever has to say
|
|
2057
|
+
what it CHANGES, which is also what makes `?from` land on a
|
|
2058
|
+
real shot rather than on a field. */
|
|
2059
|
+
w.__film.time(this.standing);
|
|
2060
|
+
/* whatever the picture needs told once — the plan off, the city
|
|
2061
|
+
on — is the film's own */
|
|
2062
|
+
this.picture?.seat?.(w.__film);
|
|
2063
|
+
/* THE POSTER IS AN EVENING. The film proper opens in the morning
|
|
2064
|
+
— so the door stands in the last of the light, and pressing it
|
|
2065
|
+
turns the day over: the sky crossfades to nine o'clock while
|
|
2066
|
+
the lens flies out of the poster's circuit. Two things the
|
|
2067
|
+
viewer did not ask for, both answering the same click. */
|
|
2068
|
+
this.dressPoster(w.__film);
|
|
2069
|
+
/* seat the lens where the film opens, so the first frame is the
|
|
2070
|
+
shot and not a swing towards it */
|
|
2071
|
+
this.snap((this.beats[0] ?? EMPTY_BEAT).cam);
|
|
2072
|
+
} finally {
|
|
2073
|
+
/* the door unlocks NO MATTER WHAT the seating did: a gate whose
|
|
2074
|
+
buttons can be stranded disabled by a throw above is a locked
|
|
2075
|
+
theatre with the lights on */
|
|
2076
|
+
this.ready = true;
|
|
2077
|
+
}
|
|
2078
|
+
if (this.gate) {
|
|
2079
|
+
/* a click that arrived while the scene was still loading was a
|
|
2080
|
+
decision, not a miss — honour it now */
|
|
2081
|
+
if (this.wanted !== undefined) {
|
|
2082
|
+
this.begin(this.wanted);
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
this.posterAt = 0;
|
|
2086
|
+
this.posterRaf ??= requestAnimationFrame(this.poster);
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
/* IDEMPOTENT, or nothing. This load path re-runs whenever the
|
|
2090
|
+
modifier does (a booted flip re-renders the stage), and an
|
|
2091
|
+
unconditional begin(false) here reached the already-booted
|
|
2092
|
+
branch and TOGGLED THE SOUND BACK OFF — the "with sound" click
|
|
2093
|
+
un-clicking itself one pass later. Only a film that has not
|
|
2094
|
+
begun may be begun on its behalf. */ /**
|
|
2095
|
+
* ONLY A DEEP LINK STARTS ITSELF. A door that opens on a fast (or
|
|
2096
|
+
* cached) load is a film that started without being asked — which
|
|
2097
|
+
* is how the poster's own circuit once got two seconds and then
|
|
2098
|
+
* vanished.
|
|
2099
|
+
*/ /* ...and only a DEEP LINK starts itself now. `?from` named a shot,
|
|
2100
|
+
so the film goes there; it plays muted until something asks
|
|
2101
|
+
otherwise, because nothing has been clicked. Everything else —
|
|
2102
|
+
the theater route and every embed — waits behind its door for
|
|
2103
|
+
the click that buys the sound. */
|
|
2104
|
+
if (!this.booted && this.from > 0) {
|
|
2105
|
+
this.begin(true);
|
|
2106
|
+
}
|
|
2107
|
+
};
|
|
2108
|
+
frame.addEventListener('load', onLoad);
|
|
2109
|
+
/* a cached iframe can be complete before the listener is attached —
|
|
2110
|
+
but never boot from inside the mount modifier itself: this runs
|
|
2111
|
+
during the render pass, and a `booted` flipped here renders the
|
|
2112
|
+
score region in the SAME pass, where the rig is not an insertion
|
|
2113
|
+
and the whole run resolves to nothing. A timeout puts the flip in
|
|
2114
|
+
its own pass, which is what the load event gives the slow path. */
|
|
2115
|
+
let bootTimer = 0;
|
|
2116
|
+
if (frame.contentDocument?.readyState === 'complete') {
|
|
2117
|
+
bootTimer = window.setTimeout(onLoad, 0);
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* THE LOOP COMES BACK. This modifier reads tracked state (`from`,
|
|
2121
|
+
* `booted`), so it re-installs on every re-cut and on the boot
|
|
2122
|
+
* itself — and its own cleanup below cancels the frame loop each
|
|
2123
|
+
* time. The old code got away with it because the load path
|
|
2124
|
+
* re-booted the world wholesale; now that a running film is left
|
|
2125
|
+
* alone, the re-install has to hand back the one thing it took.
|
|
2126
|
+
*/
|
|
2127
|
+
if (this.booted) {
|
|
2128
|
+
/* the cleanup nulled the bridge as well; take it straight back
|
|
2129
|
+
rather than waiting a tick for the load path */
|
|
2130
|
+
const w = frame.contentWindow;
|
|
2131
|
+
if (w?.__film) {
|
|
2132
|
+
this.film = w.__film;
|
|
2133
|
+
this.takeGlass(this.film);
|
|
2134
|
+
this.film.volume?.(this.vol);
|
|
2135
|
+
}
|
|
2136
|
+
cancelAnimationFrame(this.raf);
|
|
2137
|
+
this.lastTick = performance.now();
|
|
2138
|
+
this.raf = requestAnimationFrame(this.frame);
|
|
2139
|
+
}
|
|
2140
|
+
return () => {
|
|
2141
|
+
cancelAnimationFrame(this.raf);
|
|
2142
|
+
window.clearTimeout(bootTimer);
|
|
2143
|
+
window.removeEventListener('keydown', this.key);
|
|
2144
|
+
window.removeEventListener('error', this.trip);
|
|
2145
|
+
window.removeEventListener('unhandledrejection', this.trip);
|
|
2146
|
+
window.removeEventListener('pointermove', this.aim);
|
|
2147
|
+
window.removeEventListener('pointermove', this.wake);
|
|
2148
|
+
window.removeEventListener('wheel', this.wake);
|
|
2149
|
+
document.removeEventListener('fullscreenchange', this.fullChange);
|
|
2150
|
+
window.clearTimeout(this.idleTimer);
|
|
2151
|
+
frame.removeEventListener('load', onLoad);
|
|
2152
|
+
document.body.classList.remove('cf-film', 'cf-embedded');
|
|
2153
|
+
delete window.__choreo?.[this.args.name];
|
|
2154
|
+
this.film = undefined;
|
|
2155
|
+
this.takeGlass(undefined);
|
|
2156
|
+
};
|
|
2157
|
+
});
|
|
2158
|
+
trackEls = modifier(el => {
|
|
2159
|
+
this.lineEl = el.querySelector('line.cf-leader');
|
|
2160
|
+
this.tetherEl = el.querySelector('line.cf-tether');
|
|
2161
|
+
this.dotEl = el.querySelector('circle');
|
|
2162
|
+
});
|
|
2163
|
+
dimEl = modifier(el => {
|
|
2164
|
+
this.dim = el;
|
|
2165
|
+
return () => {
|
|
2166
|
+
if (this.dim === el) {
|
|
2167
|
+
this.dim = undefined;
|
|
2168
|
+
}
|
|
2169
|
+
};
|
|
2170
|
+
});
|
|
2171
|
+
plate = modifier(el => {
|
|
2172
|
+
this.plateEl = el;
|
|
2173
|
+
/* the rows, nearest plane last — the big glyph is the near one */
|
|
2174
|
+
this.rowEls = [...el.children];
|
|
2175
|
+
return () => {
|
|
2176
|
+
if (this.plateEl === el) {
|
|
2177
|
+
this.plateEl = undefined;
|
|
2178
|
+
this.rowEls = [];
|
|
2179
|
+
}
|
|
2180
|
+
};
|
|
2181
|
+
});
|
|
2182
|
+
snap(c, launch, secs) {
|
|
2183
|
+
this.goal = {
|
|
2184
|
+
...c
|
|
2185
|
+
};
|
|
2186
|
+
this.mid = {
|
|
2187
|
+
...c
|
|
2188
|
+
};
|
|
2189
|
+
this.now = {
|
|
2190
|
+
...c
|
|
2191
|
+
};
|
|
2192
|
+
/**
|
|
2193
|
+
* A CUT LANDS ON A MOVING CAMERA.
|
|
2194
|
+
*
|
|
2195
|
+
* Snapping used to zero the chaser's velocity, so every cut arrived
|
|
2196
|
+
* at a standstill and eased up into its move — the one thing a cut
|
|
2197
|
+
* must never do, because the ease-in is the join announcing itself.
|
|
2198
|
+
* An operator does not stop between shots; the next shot is already
|
|
2199
|
+
* running when the frame changes. So the chaser is handed the
|
|
2200
|
+
* incoming shot's OWN speed (its whole travel over its whole
|
|
2201
|
+
* length) and starts at pace.
|
|
2202
|
+
*/
|
|
2203
|
+
for (const k of ['dolly', 'fx', 'fz', 'lookY', 'ox', 'pitch', 'yaw']) {
|
|
2204
|
+
const a = c[k] ?? 0;
|
|
2205
|
+
const b = launch ? launch[k] ?? 0 : a;
|
|
2206
|
+
/**
|
|
2207
|
+
* AND IT IS A NUDGE, NOT A LAUNCH.
|
|
2208
|
+
*
|
|
2209
|
+
* The chaser is handed the incoming shot's own speed so the cut
|
|
2210
|
+
* lands on a moving camera rather than a standstill. But the spring
|
|
2211
|
+
* is pulling toward the shot's HEAD while that speed points at its
|
|
2212
|
+
* TAIL, so the whole of it has to be absorbed and given back — and
|
|
2213
|
+
* once the stiffness came down to 3.9 there was not enough spring
|
|
2214
|
+
* left to absorb it: the lens ran past its own opening frame and
|
|
2215
|
+
* came back, which is the bounce at every cut. Roughly half the
|
|
2216
|
+
* speed still reads as an operator already moving, and the return
|
|
2217
|
+
* is inside the first breath of the shot instead of on top of it.
|
|
2218
|
+
*/ /* BOTH stages of the cascade get the shot's own speed. Handing it
|
|
2219
|
+
only to the first one left the second accelerating from rest,
|
|
2220
|
+
which reads as a short ease-in at every cut — and reads worst
|
|
2221
|
+
where the new shot orbits the OTHER WAY, because the lens
|
|
2222
|
+
appears to hesitate before changing its mind. */
|
|
2223
|
+
this.midV[k] = secs ? (b - a) / secs * 0.45 : 0;
|
|
2224
|
+
this.nowV[k] = this.midV[k];
|
|
2225
|
+
}
|
|
2226
|
+
this.sent = undefined;
|
|
2227
|
+
this.film?.pose({
|
|
2228
|
+
az: c.yaw * RAD,
|
|
2229
|
+
el: c.pitch * RAD,
|
|
2230
|
+
fx: c.fx ?? 0,
|
|
2231
|
+
fz: c.fz ?? 0,
|
|
2232
|
+
lookY: c.lookY,
|
|
2233
|
+
ox: c.ox ?? 0,
|
|
2234
|
+
snap: true,
|
|
2235
|
+
zoom: c.dolly
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* THE WHIP. A 'whip' join does not snap: the goal steps across the
|
|
2240
|
+
* seam (the splice does that) and the chaser races it on a briefly
|
|
2241
|
+
* stiff spring — a fast, smooth tween between the shots that is over
|
|
2242
|
+
* in a third of a second and never reads as a glide. While it runs,
|
|
2243
|
+
* the spring is ~3× its documentary stiffness; then the hand relaxes.
|
|
2244
|
+
*/
|
|
2245
|
+
whipUntil = 0;
|
|
2246
|
+
/**
|
|
2247
|
+
* One critically-damped stage. The page's own chase is the second, which
|
|
2248
|
+
* is the whole cascade argument getting made for free by the fact that the
|
|
2249
|
+
* scene lives in another document.
|
|
2250
|
+
*/
|
|
2251
|
+
chase(dt) {
|
|
2252
|
+
/* softer than it was: the lens is an operator's hand, not a servo.
|
|
2253
|
+
Lower stiffness filters the spline's residual sway before the
|
|
2254
|
+
page's own chase filters it again — except mid-whip, when the
|
|
2255
|
+
hand is deliberately fast (see whipUntil). */ /* SLOWER. At 5.2 the chaser was arriving at its pose about a third of
|
|
2256
|
+
the way into the beat and then holding, which reads as a film that
|
|
2257
|
+
hurries to its composition and waits — the opposite of a building
|
|
2258
|
+
that took a century. At 3.9 the move is still arriving as the last
|
|
2259
|
+
cue lands, so every shot is travelling for as long as it is on. */
|
|
2260
|
+
const w = this.whipUntil > performance.now() ? 11 : 3.9;
|
|
2261
|
+
for (const k of ['dolly', 'fx', 'fz', 'lookY', 'ox', 'pitch', 'yaw']) {
|
|
2262
|
+
const g = this.goal[k] ?? 0;
|
|
2263
|
+
const m = this.mid[k] ?? 0;
|
|
2264
|
+
const v = this.midV[k] + (w * w * (g - m) - 2 * w * this.midV[k]) * dt;
|
|
2265
|
+
this.midV[k] = v;
|
|
2266
|
+
const next = m + v * dt;
|
|
2267
|
+
this.mid[k] = next;
|
|
2268
|
+
this.now[k] = next;
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* THE POSTER TURNS.
|
|
2273
|
+
*
|
|
2274
|
+
* A still frame behind a title is indistinguishable from a JPEG, and
|
|
2275
|
+
* this whole film's argument is that it is not one. So while the door
|
|
2276
|
+
* is up the lens makes a slow circuit of the opening pose — a few
|
|
2277
|
+
* degrees either side, breathing in and out — which says "live 3D"
|
|
2278
|
+
* before a word is read and costs one rAF. It hands over on the
|
|
2279
|
+
* click: the film does not snap away from the poster, it flies from
|
|
2280
|
+
* wherever the circuit had reached.
|
|
2281
|
+
*/
|
|
2282
|
+
poster = stamp => {
|
|
2283
|
+
if (!this.gate || this.booted) {
|
|
2284
|
+
this.posterRaf = undefined;
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
this.posterRaf = requestAnimationFrame(this.poster);
|
|
2288
|
+
const film = this.film;
|
|
2289
|
+
if (!film) {
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
if (!this.posterAt) {
|
|
2293
|
+
this.posterAt = stamp;
|
|
2294
|
+
}
|
|
2295
|
+
const t = (stamp - this.posterAt) / 1000;
|
|
2296
|
+
const c = (this.beats[0] ?? EMPTY_BEAT).cam;
|
|
2297
|
+
/* THREE-QUARTERS ON, and swinging. Dead in front of a building is
|
|
2298
|
+
an elevation drawing; the corner is where a tower shows you that
|
|
2299
|
+
it has depth — two faces, two eave lines, and a shadow that
|
|
2300
|
+
reads. The swing is slow and even, side to side, so the frame is
|
|
2301
|
+
never still and never travelling anywhere either. */ /* THE POSTER IS CLOSE. The film's opening frame is as wide as the
|
|
2302
|
+
lens goes, which is right for a first shot and wrong for a
|
|
2303
|
+
poster: the door should show the building at a size worth
|
|
2304
|
+
pressing play for, and the press then RECOILS — a fast pull back
|
|
2305
|
+
out to the wide opening frame, from which the film's own slow
|
|
2306
|
+
push begins. */
|
|
2307
|
+
const pose = {
|
|
2308
|
+
dolly: c.dolly * (2.25 + 0.09 * Math.sin(t * 0.11 + 1.1)),
|
|
2309
|
+
lookY: c.lookY + 1.4 + 0.6 * Math.sin(t * 0.15),
|
|
2310
|
+
/* the two halves of the poster lean back toward the middle: the
|
|
2311
|
+
building comes in off the left edge, the wordmark off the right,
|
|
2312
|
+
and the gap between them is the composition */
|
|
2313
|
+
ox: (c.ox ?? 0) * 0.66,
|
|
2314
|
+
pitch: c.pitch + 2.4 + 1.4 * Math.sin(t * 0.13),
|
|
2315
|
+
yaw: c.yaw + 34 + 11 * Math.sin(t * 0.2)
|
|
2316
|
+
};
|
|
2317
|
+
film.pose({
|
|
2318
|
+
az: pose.yaw * RAD,
|
|
2319
|
+
el: pose.pitch * RAD,
|
|
2320
|
+
fx: c.fx ?? 0,
|
|
2321
|
+
fz: c.fz ?? 0,
|
|
2322
|
+
lookY: pose.lookY,
|
|
2323
|
+
ox: pose.ox,
|
|
2324
|
+
zoom: pose.dolly
|
|
2325
|
+
});
|
|
2326
|
+
this.posterPose = pose;
|
|
2327
|
+
};
|
|
2328
|
+
posterRaf;
|
|
2329
|
+
posterAt = 0;
|
|
2330
|
+
/** where the poster's circuit had reached when the door was answered */
|
|
2331
|
+
posterPose;
|
|
2332
|
+
/**
|
|
2333
|
+
* ONE SETTING AT A TIME, AND THIS TIME IT IS GUARANTEED.
|
|
2334
|
+
*
|
|
2335
|
+
* The hand-off is a leaver and an arriver sharing the corner for half
|
|
2336
|
+
* a second, which is the design. But a leaver whose exit never
|
|
2337
|
+
* completes just stays — and a block from the first beat was still
|
|
2338
|
+
* standing at full strength eight beats later, with two settings of
|
|
2339
|
+
* different chapters overprinting each other. Whatever strands it (a
|
|
2340
|
+
* removal that waits on a cue the outgoing beat never had), the rule
|
|
2341
|
+
* the film states out loud in its own comment is worth enforcing
|
|
2342
|
+
* rather than trusting: any block that is not the current one and has
|
|
2343
|
+
* outstayed the longest hand-off is taken off the glass.
|
|
2344
|
+
*/
|
|
2345
|
+
sweepBlocks(now) {
|
|
2346
|
+
/* the type layer is a sibling of the frame, not a child of it — the
|
|
2347
|
+
first version of this looked for it inside the wrong element and
|
|
2348
|
+
swept nothing, which is how three chapters' settings came to be on
|
|
2349
|
+
screen at once */
|
|
2350
|
+
const root = this.pageEl ?? this.frameEl ?? document.body;
|
|
2351
|
+
const blocks = root.querySelectorAll('.cf-type .cf-block');
|
|
2352
|
+
if (blocks.length < 2) {
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
for (let i = 0; i < blocks.length - 1; i++) {
|
|
2356
|
+
const el = blocks[i];
|
|
2357
|
+
const seen = Number(el.dataset['cfSeen'] ?? 0);
|
|
2358
|
+
/* two is a hand-off and gets its half second; three is a leak, and
|
|
2359
|
+
everything but the newest goes now */
|
|
2360
|
+
if (blocks.length > 2 || seen && now - seen > 900) {
|
|
2361
|
+
el.remove();
|
|
2362
|
+
} else if (!seen) {
|
|
2363
|
+
el.dataset['cfSeen'] = String(now);
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
frame = stamp => {
|
|
2368
|
+
this.raf = requestAnimationFrame(this.frame);
|
|
2369
|
+
this.tick(stamp);
|
|
2370
|
+
};
|
|
2371
|
+
tick(stamp) {
|
|
2372
|
+
this.sweepBlocks(stamp);
|
|
2373
|
+
const film = this.film;
|
|
2374
|
+
if (!film) {
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
/* A HIDDEN TAB DOES NO WORK. And once the end card is up nothing under
|
|
2378
|
+
it will change again: the page is told so and stops drawing the
|
|
2379
|
+
finished building into a frame nobody composites. */
|
|
2380
|
+
if (document.hidden && !AWAKE) {
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
if (this.ended) {
|
|
2384
|
+
if (!this.idled) {
|
|
2385
|
+
film.idle(true);
|
|
2386
|
+
this.idled = true;
|
|
2387
|
+
}
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
if (this.idled) {
|
|
2391
|
+
film.idle(false);
|
|
2392
|
+
this.idled = false;
|
|
2393
|
+
}
|
|
2394
|
+
/* held by the viewer: nothing advances, nothing is written. An exact
|
|
2395
|
+
film keeps deriving — its clock is the only thing that stops — so
|
|
2396
|
+
a scrub under a hold still draws the frame under the hand. */
|
|
2397
|
+
if (this.paused && !this.exact) {
|
|
2398
|
+
this.lastTick = stamp;
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
const dt = Math.min(0.08, this.lastTick ? (stamp - this.lastTick) / 1000 : 0);
|
|
2402
|
+
this.lastTick = stamp;
|
|
2403
|
+
/* THE CLOCK. An exact film advances one number and derives the rest;
|
|
2404
|
+
a cut film reads the wall and the beat it was last handed. */
|
|
2405
|
+
const jumpedNow = this.jumped;
|
|
2406
|
+
if (this.exact) {
|
|
2407
|
+
if (this.playing && this.rolling && !this.paused) {
|
|
2408
|
+
this.t = Math.min(this.totalSecs, this.t + dt);
|
|
2409
|
+
}
|
|
2410
|
+
this.fold();
|
|
2411
|
+
if (this.ended) {
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
const since = this.exact ? this.t - this.beatStart(this.absoluteIndex) : (stamp - this.beatAt) / 1000;
|
|
2416
|
+
/* the seam, both halves, from one progress — before the pose, so a
|
|
2417
|
+
frame never carries a lens that has moved past a seam that has not */
|
|
2418
|
+
this.driveSeam(film, this.t);
|
|
2419
|
+
if (this.exact) {
|
|
2420
|
+
/* no integrator in the pose path: the lens IS the spline */this.now = {
|
|
2421
|
+
...this.goal
|
|
2422
|
+
};
|
|
2423
|
+
} else {
|
|
2424
|
+
this.chase(dt);
|
|
2425
|
+
}
|
|
2426
|
+
/* a chaser lands on a millionth of a pixel rather than on zero, and an
|
|
2427
|
+
off-centre frustum that is never quite centred keeps the projection
|
|
2428
|
+
matrix rebuilt every frame for nothing — so zero means zero */
|
|
2429
|
+
const k = Math.min(1, dt * 1.8);
|
|
2430
|
+
this.lean.x += (this.leanTo.x - this.lean.x) * k;
|
|
2431
|
+
this.lean.y += (this.leanTo.y - this.lean.y) * k;
|
|
2432
|
+
/**
|
|
2433
|
+
* EVERY SHOT IS FLOWN. The sway started as a comparison-chapter
|
|
2434
|
+
* idea and belongs to the whole film: a camera that holds perfectly
|
|
2435
|
+
* still is a render, and a degree of drift costs nothing and says
|
|
2436
|
+
* "somebody is holding this" in every shot.
|
|
2437
|
+
*
|
|
2438
|
+
* It is divided by the lens, though. A degree of tilt at a 4x
|
|
2439
|
+
* close-up throws the frame around; the same degree on a wide is a
|
|
2440
|
+
* breath. So the amplitude is scaled down as the shot gets tighter
|
|
2441
|
+
* and a beat can still ask for more of it (the drone chapter does).
|
|
2442
|
+
*/
|
|
2443
|
+
const air = (this.beat.bob ?? 0.5) / Math.max(0.8, Math.min(3, this.now.dolly));
|
|
2444
|
+
const flown = Math.min(1, Math.max(0, since / (this.beat.ticks * TICK)));
|
|
2445
|
+
/* ONE breath across the shot, faded in and out at the ends so the
|
|
2446
|
+
sway never starts or stops abruptly. Three half-cycles of it (the
|
|
2447
|
+
first attempt) is not a drone in the air, it is a hand shaking —
|
|
2448
|
+
and on top of a move that is already covering ground it reads as
|
|
2449
|
+
the whole shot being unsteady. */
|
|
2450
|
+
const bob = air * Math.sin(flown * Math.PI * 1.6) * smooth(Math.min(1, flown / 0.18)) * smooth(Math.min(1, (1 - flown) / 0.18));
|
|
2451
|
+
/* the aim, when the shot asks to stay on the subject (Beat.hold):
|
|
2452
|
+
the MIDDLE of whatever is standing, in the rig's own coordinates
|
|
2453
|
+
— half the built height, so a crane can go anywhere and the
|
|
2454
|
+
building stays centred on the way */ /* a held shot on the whole building aims at half of what stands; a
|
|
2455
|
+
held shot on a POINT (a finial, a cross) keeps the aim it was given */
|
|
2456
|
+
const rise = this.followRise(film, dt);
|
|
2457
|
+
const lookY = rise ? rise.lookY : this.beat.hold === 'top' ? /* the cut line, kept a little above centre, never under the ground */Math.max(1.4, film.height() * 0.62) - this.rigMid : this.beat.hold && !this.beat.to ? film.height() * 0.5 - this.rigMid : this.now.lookY;
|
|
2458
|
+
const ox = Math.abs(this.now.ox ?? 0) < 1e-4 ? 0 : this.now.ox;
|
|
2459
|
+
/**
|
|
2460
|
+
* ONE LAST FILTER BEFORE THE GLASS.
|
|
2461
|
+
*
|
|
2462
|
+
* Everything upstream is smooth on its own — a spline, a spring,
|
|
2463
|
+
* the scene's own chase — but they are SUMMED here, along with the
|
|
2464
|
+
* bob and the pointer's lean, and a sum of smooth things is only as
|
|
2465
|
+
* smooth as its roughest term. A short one-pole on the pose that
|
|
2466
|
+
* actually ships takes the last of it out.
|
|
2467
|
+
*
|
|
2468
|
+
* It must never soften a CUT, though: a filter that carries sixty
|
|
2469
|
+
* milliseconds of the outgoing shot into the incoming one turns
|
|
2470
|
+
* every edit into a tiny dissolve. The snap clears it, so the first
|
|
2471
|
+
* frame of a new building is exactly the new building.
|
|
2472
|
+
*/ /**
|
|
2473
|
+
* THE HAND ON THE STICKS.
|
|
2474
|
+
*
|
|
2475
|
+
* Three sines at unrelated rates, summed and scaled by the lens:
|
|
2476
|
+
* about a sixth of a degree of wander on a wide shot and a fraction
|
|
2477
|
+
* of that on a close-up, plus a breath of it in the zoom. It is
|
|
2478
|
+
* deterministic (no RNG, so no frame ever jumps), continuous (no
|
|
2479
|
+
* discontinuity to catch the eye), and slower than a shake — this
|
|
2480
|
+
* is the drift of somebody holding a machine steady, not a handheld
|
|
2481
|
+
* effect. Turn it off by setting HAND to 0.
|
|
2482
|
+
*/ /* on the film's clock when the film is exact, so the breath is the
|
|
2483
|
+
same breath at the same frame every time */
|
|
2484
|
+
const clock = this.exact ? this.t : stamp / 1000;
|
|
2485
|
+
const wander = (a, b, c) => Math.sin(clock * a) * 0.6 + Math.sin(clock * b) * 0.3 + Math.sin(clock * c) * 0.1;
|
|
2486
|
+
const HAND = 0.17;
|
|
2487
|
+
const hand = HAND / Math.max(0.8, Math.min(3.4, this.now.dolly));
|
|
2488
|
+
const want = {
|
|
2489
|
+
/* the lens leans into the cursor: a couple of degrees of orbit and
|
|
2490
|
+
a hand's width of height, which is enough for the hills to move
|
|
2491
|
+
against the building and nowhere near enough to fight the shot */ /* MOSTLY TILT. A machine in the air holds its heading far better
|
|
2492
|
+
than it holds its nose: yaw is what the operator is steering and
|
|
2493
|
+
pitch is what the wind is doing to it. So the wander is nearly
|
|
2494
|
+
all in the tilt, with a quarter of it in the orbit to keep the
|
|
2495
|
+
two from looking mechanically separate. */
|
|
2496
|
+
az: (this.now.yaw + this.lean.x * 0.8 + hand * 0.25 * wander(0.37, 0.93, 2.11)) * RAD,
|
|
2497
|
+
el: (this.now.pitch - this.lean.y * 0.5 + bob + hand * 1.2 * wander(0.29, 1.07, 1.83)) * RAD,
|
|
2498
|
+
fx: this.now.fx ?? 0,
|
|
2499
|
+
fz: this.now.fz ?? 0,
|
|
2500
|
+
lookY: lookY + this.lean.y * 0.3 + (this.beat.hold ? 0 : bob * 0.3),
|
|
2501
|
+
ox: ox - this.lean.x * 0.012,
|
|
2502
|
+
zoom: Math.min(this.now.dolly, rise?.zoom ?? Infinity) * (1 + 0.0011 * wander(0.23, 0.71, 1.51))
|
|
2503
|
+
};
|
|
2504
|
+
/* and the send is filtered a little harder, so nothing the spring
|
|
2505
|
+
does arrives at the page as a step */
|
|
2506
|
+
const g = Math.min(1, dt * 12);
|
|
2507
|
+
const sent = this.exact ? {
|
|
2508
|
+
...want
|
|
2509
|
+
} : this.sent ?? {
|
|
2510
|
+
...want
|
|
2511
|
+
};
|
|
2512
|
+
if (!this.exact) {
|
|
2513
|
+
sent.az += (want.az - sent.az) * g;
|
|
2514
|
+
sent.el += (want.el - sent.el) * g;
|
|
2515
|
+
sent.fx += (want.fx - sent.fx) * g;
|
|
2516
|
+
sent.fz += (want.fz - sent.fz) * g;
|
|
2517
|
+
sent.lookY += (want.lookY - sent.lookY) * g;
|
|
2518
|
+
sent.ox += (want.ox - sent.ox) * g;
|
|
2519
|
+
sent.zoom += (want.zoom - sent.zoom) * g;
|
|
2520
|
+
}
|
|
2521
|
+
this.sent = sent;
|
|
2522
|
+
const eye = this.beat.eye;
|
|
2523
|
+
/* the two halves of a ground shot (Beat.lift): the feet finish before
|
|
2524
|
+
the head starts, with a beat of stillness between them */
|
|
2525
|
+
const lift = Math.max(0, Math.min(0.9, this.beat.lift ?? 0));
|
|
2526
|
+
const walk = eye?.to ? smooth(lift > 0 ? Math.min(1, flown / lift) : flown) : 0;
|
|
2527
|
+
if (lift > 0 && this.beat.toCam) {
|
|
2528
|
+
const from = this.beat.cam;
|
|
2529
|
+
const to = this.beat.toCam;
|
|
2530
|
+
const tilt = smooth(Math.max(0, Math.min(1, (flown - lift) / Math.max(0.1, 1 - lift))));
|
|
2531
|
+
/* the aim is driven straight from the beat on a lifted shot: the
|
|
2532
|
+
spring is the wrong instrument for a head that has to hold still
|
|
2533
|
+
and then move, and these shots never dolly while they tilt */
|
|
2534
|
+
sent.lookY = lerp(from.lookY ?? 0, to.lookY ?? 0, tilt) + bob * 0.3;
|
|
2535
|
+
sent.el = lerp(from.pitch, to.pitch, tilt) * RAD + bob * 0.5 * RAD;
|
|
2536
|
+
}
|
|
2537
|
+
film.pose({
|
|
2538
|
+
...sent,
|
|
2539
|
+
/* and the page's own chase is stood down too: two integrators is
|
|
2540
|
+
two histories, and an exact film has none */
|
|
2541
|
+
snap: this.exact ? true : undefined,
|
|
2542
|
+
fov: eye ? eye.fov : null,
|
|
2543
|
+
near: eye ? [lerp(eye.at[0], (eye.to ?? eye.at)[0], walk), lerp(eye.at[1], (eye.to ?? eye.at)[1], walk), lerp(eye.at[2], (eye.to ?? eye.at)[2], walk)] : null
|
|
2544
|
+
});
|
|
2545
|
+
/* the mood and the sun's rake go to the glass only when they change:
|
|
2546
|
+
the page eases between them itself, so this is a target, not a frame */
|
|
2547
|
+
const mood = this.grade;
|
|
2548
|
+
const snap = this.gradeSnap;
|
|
2549
|
+
if (mood !== this.gradeSent || this.rakeDeg !== this.rakeSent) {
|
|
2550
|
+
const spec = this.grades[mood] ?? this.grades['amber'];
|
|
2551
|
+
film.grade({
|
|
2552
|
+
...spec,
|
|
2553
|
+
rake: this.rakeDeg * RAD
|
|
2554
|
+
}, snap);
|
|
2555
|
+
this.gradeSnap = false;
|
|
2556
|
+
this.gradeSent = mood;
|
|
2557
|
+
this.rakeSent = this.rakeDeg;
|
|
2558
|
+
}
|
|
2559
|
+
/* the stock rides beside the mood: a name the page bakes, or a .cube
|
|
2560
|
+
under `assets/luts/`, crossfaded in the glass */
|
|
2561
|
+
const b = this.beat;
|
|
2562
|
+
/* a film that names no stock gets none: the grade alone is a look */
|
|
2563
|
+
const chapLut = this.chapters[b.ch ?? 0]?.lut ?? null;
|
|
2564
|
+
const lutName = b.lut === null ? null : b.lut ?? b.look ?? chapLut;
|
|
2565
|
+
if (lutName !== this.lutSent && film.lut) {
|
|
2566
|
+
const fx = this.lookFx[lutName ?? ''];
|
|
2567
|
+
film.lut(lutName === null ? null : lutName.includes('.') ? {
|
|
2568
|
+
amount: this.lutAmount,
|
|
2569
|
+
url: `${this.assets}luts/${lutName}`
|
|
2570
|
+
} : {
|
|
2571
|
+
...(fx ?? {
|
|
2572
|
+
amount: this.lutAmount
|
|
2573
|
+
}),
|
|
2574
|
+
look: lutName
|
|
2575
|
+
}, snap);
|
|
2576
|
+
this.lutSent = lutName;
|
|
2577
|
+
}
|
|
2578
|
+
/* walk the key light to the shot's own sun — the short way round,
|
|
2579
|
+
over about a second and a half */
|
|
2580
|
+
const goal = this.sunGoal;
|
|
2581
|
+
if (this.exact) {
|
|
2582
|
+
/* the walk is a function of the beat's clock: from where the last
|
|
2583
|
+
beat left the light to where this one asks, over a second and a
|
|
2584
|
+
half, the short way round */
|
|
2585
|
+
if (goal) {
|
|
2586
|
+
const from = this.sunFrom ?? goal;
|
|
2587
|
+
const k = smooth(Math.min(1, since / 1.5));
|
|
2588
|
+
this.sunNow = {
|
|
2589
|
+
az: from.az + wrapAngle(goal.az - from.az) * k,
|
|
2590
|
+
el: lerp(from.el, goal.el, k)
|
|
2591
|
+
};
|
|
2592
|
+
film.light(this.sunNow);
|
|
2593
|
+
}
|
|
2594
|
+
} else if (goal && this.sunNow) {
|
|
2595
|
+
/**
|
|
2596
|
+
* AT A RATE, not on a time constant. An eased approach takes the
|
|
2597
|
+
* same second and a half whether the sun moves two degrees or
|
|
2598
|
+
* fifty-five — and fifty-five degrees in a second and a half is
|
|
2599
|
+
* the jarring relight between the construction chapter (near
|
|
2600
|
+
* overhead) and the detail chapter (raking). Capping the angular
|
|
2601
|
+
* speed makes a small change quick and a big one a proper move,
|
|
2602
|
+
* which the chapter's own flight has time for.
|
|
2603
|
+
*/
|
|
2604
|
+
const cap = 0.3 * dt;
|
|
2605
|
+
const ease = Math.min(1, dt * 1.6);
|
|
2606
|
+
const walk = (from, to) => {
|
|
2607
|
+
const d = (to - from) * ease;
|
|
2608
|
+
return from + (Math.abs(d) > cap ? Math.sign(d) * cap : d);
|
|
2609
|
+
};
|
|
2610
|
+
let d = goal.az - this.sunNow.az;
|
|
2611
|
+
while (d > Math.PI) {
|
|
2612
|
+
d -= Math.PI * 2;
|
|
2613
|
+
}
|
|
2614
|
+
while (d < -Math.PI) {
|
|
2615
|
+
d += Math.PI * 2;
|
|
2616
|
+
}
|
|
2617
|
+
this.sunNow.az = walk(this.sunNow.az, this.sunNow.az + d);
|
|
2618
|
+
this.sunNow.el = walk(this.sunNow.el, goal.el);
|
|
2619
|
+
film.light(this.sunNow);
|
|
2620
|
+
}
|
|
2621
|
+
this.pageEl?.style.setProperty('--cf-lx', this.lean.x.toFixed(3));
|
|
2622
|
+
this.pageEl?.style.setProperty('--cf-ly', this.lean.y.toFixed(3));
|
|
2623
|
+
/* THE PLAYHEAD, as a custom property rather than tracked state: it
|
|
2624
|
+
changes sixty times a second and nothing about the template's
|
|
2625
|
+
shape changes with it, so it must never cause a re-render. */
|
|
2626
|
+
const at = this.exact ? this.t : this.secsBefore(this.absoluteIndex) + Math.min(this.beat.ticks * TICK, since);
|
|
2627
|
+
const total = this.totalSecs;
|
|
2628
|
+
const prog = this.scrubAt ?? Math.min(1, at / total);
|
|
2629
|
+
this.pageEl?.style.setProperty('--cf-prog', prog.toFixed(5));
|
|
2630
|
+
/* the rail's head rides the film's own clock when it has one, and
|
|
2631
|
+
the playhead when it does not */
|
|
2632
|
+
if (!this.args.clock) {
|
|
2633
|
+
this.pageEl?.style.setProperty('--cf-head', prog.toFixed(5));
|
|
2634
|
+
}
|
|
2635
|
+
if (this.trackW > 0) {
|
|
2636
|
+
this.pageEl?.style.setProperty('--cf-hpx', this.headPx(prog).toFixed(1));
|
|
2637
|
+
}
|
|
2638
|
+
const pct = Math.round(prog * 100);
|
|
2639
|
+
if (pct !== this.shownPct) {
|
|
2640
|
+
this.shownPct = pct;
|
|
2641
|
+
this.scrubEl?.setAttribute('aria-valuenow', String(pct));
|
|
2642
|
+
}
|
|
2643
|
+
const sec = Math.floor(at);
|
|
2644
|
+
if (sec !== this.shownSec) {
|
|
2645
|
+
this.shownSec = sec;
|
|
2646
|
+
this.clock = Film.mmss(at);
|
|
2647
|
+
}
|
|
2648
|
+
this.clips(film, at, jumpedNow);
|
|
2649
|
+
const beat = this.beat;
|
|
2650
|
+
/**
|
|
2651
|
+
* The beat's own local clock. It is re-based at every beat entrance, so
|
|
2652
|
+
* whatever it drifts is bounded by one beat rather than by the film —
|
|
2653
|
+
* the same seek-unsafe-but-self-correcting bargain the chaser makes,
|
|
2654
|
+
* and for the same reason: these are simulations, not tweens.
|
|
2655
|
+
*/
|
|
2656
|
+
const local = Math.min(1, since / (beat.ticks * TICK));
|
|
2657
|
+
/* only while the film is rolling: an ended or gated film must not keep
|
|
2658
|
+
driving the last beat's clock, or a restart finds the tower still
|
|
2659
|
+
down behind the door */
|
|
2660
|
+
if (Array.isArray(beat.build) && this.rolling) {
|
|
2661
|
+
/* a settle: the shot is seen standing before the first stone moves */const settle = beat.settle ?? 0;
|
|
2662
|
+
const span0 = beat.ticks * TICK;
|
|
2663
|
+
const bl = Math.max(0, Math.min(1, (since - settle) / Math.max(0.01, span0 - settle)));
|
|
2664
|
+
const t = lerp(beat.build[0], beat.build[1], smooth(Math.min(1, bl / (beat.buildBy ?? 1))));
|
|
2665
|
+
film.time(t);
|
|
2666
|
+
this.showYear(t);
|
|
2667
|
+
} else if (beat.build !== undefined) {
|
|
2668
|
+
this.showYear(Array.isArray(beat.build) ? beat.build[0] : beat.build);
|
|
2669
|
+
}
|
|
2670
|
+
/**
|
|
2671
|
+
* The corner empties half a second before the beat changes — and
|
|
2672
|
+
* "changes" means the next CUE, not the end of this beat's own
|
|
2673
|
+
* ticks. A beat followed by a chapter flight is on screen for its
|
|
2674
|
+
* own length PLUS that flight's lead, and measuring against the
|
|
2675
|
+
* short number faded the type out early and then held it at zero
|
|
2676
|
+
* for the rest of the shot.
|
|
2677
|
+
*/
|
|
2678
|
+
const nextLead = this.all[this.absoluteIndex + 1]?.lead ?? 0;
|
|
2679
|
+
const span = (beat.ticks + nextLead) * TICK;
|
|
2680
|
+
const left = Math.max(0, span - since);
|
|
2681
|
+
const last = !this.all[this.absoluteIndex + 1];
|
|
2682
|
+
/* THE TYPE LEAVES BEFORE THE CUT AND DOES NOT COME BACK. The fade is
|
|
2683
|
+
written on the block itself, not only on the container: a container
|
|
2684
|
+
that snaps back to 1 for the next beat while the old block is still
|
|
2685
|
+
standing inside it as a leaver is the flash — the old setting at
|
|
2686
|
+
full strength for the length of its exit. With the block at 0 on
|
|
2687
|
+
its own inline style, the leaver has nothing to come back to. */ /* and it does not ARRIVE until the seam is over: the entrance tweens
|
|
2688
|
+
apply their from-values when they start, not when the pass starts,
|
|
2689
|
+
so for the length of the wait the block would stand at full
|
|
2690
|
+
strength over the transition. The block itself is held at 0 until
|
|
2691
|
+
the wait is up and rises over the next 0.4 s, under the tweens. */
|
|
2692
|
+
const wait = this.typeAt[0] - 0.05;
|
|
2693
|
+
const enter = smooth(Math.max(0, Math.min(1, (since - wait) / 0.4)));
|
|
2694
|
+
/* THE LAST BEAT'S TYPE LEAVES BEFORE THE CARD. Its cue landed a slot
|
|
2695
|
+
after its nominal start (the spline-seed offset every cue but the
|
|
2696
|
+
first carries), so measured on its own clock the beat has a tick
|
|
2697
|
+
left when the run's last cue ends the film — and the type was
|
|
2698
|
+
standing at full strength when the scrim came. Measure to the
|
|
2699
|
+
ending instead: gone half a second before it, over two seconds. */
|
|
2700
|
+
const endOff = !this.exact && this.beatIndex === 0 ? 0 : TICK;
|
|
2701
|
+
const tailA = this.ended ? 0 : last ? smooth(Math.max(0, Math.min(1, (left - endOff - 0.5) / 2.0))) : smooth(Math.min(1, left / 1.0));
|
|
2702
|
+
/* THE SEAM, HALF TWO. Whatever the seam is holding over the picture,
|
|
2703
|
+
it holds over the type, the stamp and the inserts too — so a
|
|
2704
|
+
caption is uncovered by the same gesture that uncovers the shot
|
|
2705
|
+
behind it, rather than waiting for it or hiding under it. Outside a
|
|
2706
|
+
seam this is 1 and costs nothing. */
|
|
2707
|
+
const typeA = Math.min(enter, tailA, this.reveal);
|
|
2708
|
+
this.pageEl?.style.setProperty('--cf-type-a', typeA.toFixed(3));
|
|
2709
|
+
/* THE TITLE HANDS THE FRAME TO THE SUBJECT. The opening card is
|
|
2710
|
+
type-first — the study's name at poster size while the lens is
|
|
2711
|
+
still far out — and as the push arrives the plate settles to the
|
|
2712
|
+
size every other beat's type is set at. One property, eased, held
|
|
2713
|
+
for the first breath. */
|
|
2714
|
+
const titleK = beat.mode === 'title' ? smooth(Math.max(0, Math.min(1, (since - 1.0) / Math.max(0.5, span - 1.4)))) : 1;
|
|
2715
|
+
this.pageEl?.style.setProperty('--cf-title-k', titleK.toFixed(3));
|
|
2716
|
+
/**
|
|
2717
|
+
* THE ANNOUNCED YEAR STANDS IN THE PLACE, NOT ON THE GLASS.
|
|
2718
|
+
*
|
|
2719
|
+
* On the glass it was a caption the size of the frame: nothing could
|
|
2720
|
+
* pass in front of it, and it was white whatever the light was doing.
|
|
2721
|
+
* As a plane in the scene the building crosses it, the shot moves past
|
|
2722
|
+
* it, and it takes its colour from the hour — a warm ink on a bright
|
|
2723
|
+
* morning, a pale wash at night — which is the difference between a
|
|
2724
|
+
* date that belongs to the picture and one laid over it.
|
|
2725
|
+
*/
|
|
2726
|
+
const stampA = this.stampAt(beat, local) * typeA;
|
|
2727
|
+
if (beat.stamp && (stampA > 0.002 || this.stampOn)) {
|
|
2728
|
+
this.stampOn = stampA > 0.002;
|
|
2729
|
+
const pal = film.palette();
|
|
2730
|
+
const night = luminance(pal.paper) < 0.42;
|
|
2731
|
+
film.sky('stamp', {
|
|
2732
|
+
color: night ? 'rgba(247,240,224,0.5)' : 'rgba(62,50,26,0.42)',
|
|
2733
|
+
family: this.worldFamily,
|
|
2734
|
+
lines: [String(beat.stamp.y)],
|
|
2735
|
+
shadow: night ? 'rgba(0,0,0,0.35)' : 'rgba(255,252,244,0.3)',
|
|
2736
|
+
shadowBlur: 0.12,
|
|
2737
|
+
size: 5.4,
|
|
2738
|
+
track: this.worldTrack * 0.5,
|
|
2739
|
+
weight: this.worldWeight
|
|
2740
|
+
}, {
|
|
2741
|
+
billboard: true,
|
|
2742
|
+
opacity: stampA * 0.9,
|
|
2743
|
+
x: -Math.sin(this.skyAz) * 34,
|
|
2744
|
+
y: 6.4,
|
|
2745
|
+
z: -Math.cos(this.skyAz) * 34
|
|
2746
|
+
});
|
|
2747
|
+
}
|
|
2748
|
+
if (this.plateEl) {
|
|
2749
|
+
this.plateEl.style.opacity = typeA.toFixed(3);
|
|
2750
|
+
}
|
|
2751
|
+
/* THE HOURS OF A BEAT: a beat can walk the page's times of day across
|
|
2752
|
+
its length — the ending goes morning, noon, dusk, night as the
|
|
2753
|
+
tower comes down, and stays in the night */
|
|
2754
|
+
if (beat.hours?.length && this.rolling) {
|
|
2755
|
+
const over = beat.hoursOver ?? 1;
|
|
2756
|
+
const hl = Math.min(1, local / over);
|
|
2757
|
+
const step = Math.min(beat.hours.length - 1, Math.floor(hl * beat.hours.length));
|
|
2758
|
+
if (step !== this.hourStep) {
|
|
2759
|
+
this.hourStep = step;
|
|
2760
|
+
/* each hour blends across its whole share of the beat, so the
|
|
2761
|
+
day turns rather than steps */
|
|
2762
|
+
film.themeDur(beat.ticks * TICK * over / beat.hours.length);
|
|
2763
|
+
film.theme(beat.hours[step], false);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
/* THE CHAPTER'S NUMERAL LEAVES WITH THE CHAPTER. It is furniture
|
|
2767
|
+
for the passage, not for the beat, so it holds across the shots
|
|
2768
|
+
inside a chapter and fades out over the tail of the last one —
|
|
2769
|
+
an 02 still sitting in the corner while 03 arrives is the film
|
|
2770
|
+
forgetting which chapter it is in. */
|
|
2771
|
+
const here = this.absoluteIndex;
|
|
2772
|
+
const tail = (this.all[here + 1]?.ch ?? -1) !== beat.ch;
|
|
2773
|
+
/* and it gives way while the announced year is standing out in the
|
|
2774
|
+
scene: two pale numerals of different sizes in one frame is not a
|
|
2775
|
+
texture, it is a collision */
|
|
2776
|
+
this.pageEl?.style.setProperty('--cf-ghost-a', ((tail ? 1 - smooth(Math.max(0, (local - 0.72) / 0.22)) : 1) * (1 - this.stampAt(beat, local))).toFixed(3));
|
|
2777
|
+
/* the bolt the cut asked for, once per landing on this beat */
|
|
2778
|
+
if (beat.lightning !== undefined && this.struck !== beat.id && since >= beat.lightning) {
|
|
2779
|
+
this.struck = beat.id;
|
|
2780
|
+
film.lightning();
|
|
2781
|
+
}
|
|
2782
|
+
if (beat.dissolve) {
|
|
2783
|
+
/* the building goes, the frame stays */film.modelFade(1 - smooth(Math.max(0, (local - 0.12) / 0.72)));
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* WEATHER CROSSES THE SHOT. The scene lights itself beautifully and
|
|
2787
|
+
* then holds that light for four minutes, which is the one thing
|
|
2788
|
+
* daylight never does. So every beat long enough to notice gets a
|
|
2789
|
+
* cloud: the air thickens, a cool shadow passes over the picture,
|
|
2790
|
+
* and it clears again before the beat is out. It is one pass, phased
|
|
2791
|
+
* off the beat's own place in the script so no two land alike, and
|
|
2792
|
+
* it never touches the grade — a chapter's colour is an argument,
|
|
2793
|
+
* this is only the sky.
|
|
2794
|
+
*/
|
|
2795
|
+
if (beat.ticks * TICK >= 8) {
|
|
2796
|
+
const phase = this.beatIndex * 0.37 % 1 * 0.25;
|
|
2797
|
+
const w = Math.max(0, Math.min(1, (local - 0.12 - phase) / 0.62));
|
|
2798
|
+
const cloud = Math.sin(Math.PI * w) ** 2;
|
|
2799
|
+
film.haze((this.hazeBase ?? 0) + this.cloudHaze * cloud);
|
|
2800
|
+
this.setCloud(film, cloud);
|
|
2801
|
+
} else {
|
|
2802
|
+
this.setCloud(film, 0);
|
|
2803
|
+
}
|
|
2804
|
+
/* and the wash, which changes with the shot and the palette */
|
|
2805
|
+
this.pushWash(film);
|
|
2806
|
+
/* the lineup's metronome: the beat divides itself evenly among its
|
|
2807
|
+
towers and the last one holds the remainder, so the recap ends
|
|
2808
|
+
standing on the film's own subject */
|
|
2809
|
+
if (beat.cycle?.length) {
|
|
2810
|
+
const step = Math.min(beat.cycle.length - 1, Math.floor(local * beat.cycle.length));
|
|
2811
|
+
if (step !== this.cycleStep) {
|
|
2812
|
+
this.cycleStep = step;
|
|
2813
|
+
const c = beat.cycle[step];
|
|
2814
|
+
/* a subject by index (a scene with several), a moment on the
|
|
2815
|
+
film's own clock, or simply the standing subject */
|
|
2816
|
+
if (c.style !== undefined && film.style && film.styleIndex) {
|
|
2817
|
+
if (film.styleIndex() !== c.style) {
|
|
2818
|
+
film.style(c.style);
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
film.time(c.year !== undefined && this.args.clock ? this.args.clock.tAt(c.year) : this.standing);
|
|
2822
|
+
/* each stamp lands with the scene's own construction hit; the
|
|
2823
|
+
final one — the film's subject — gets the bell */
|
|
2824
|
+
film.ping(step < beat.cycle.length - 1 ? 0 : 99);
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
/* the world layer fades with the beat rather than cutting with it */
|
|
2828
|
+
if (beat.sky) {
|
|
2829
|
+
const d = beat.sky.dist ?? 30;
|
|
2830
|
+
/**
|
|
2831
|
+
* PARALLAX. This used to be placed at `view().az + offset`, which
|
|
2832
|
+
* pins the word to the LENS: the camera orbits, the glyph orbits
|
|
2833
|
+
* with it, and a hundred feet of type sits perfectly still in the
|
|
2834
|
+
* frame — the one thing that tells an eye it is looking at a
|
|
2835
|
+
* sticker rather than a place. The angle is now taken once, when
|
|
2836
|
+
* the beat lands, and the word stays where it was put: the shot
|
|
2837
|
+
* moves past it.
|
|
2838
|
+
*/
|
|
2839
|
+
const a = this.skyAz;
|
|
2840
|
+
this.skyOn = this.exact ? smooth(Math.min(1, since / 0.62)) : this.skyOn + (1 - this.skyOn) * Math.min(1, dt * 1.6);
|
|
2841
|
+
film.sky('chapter', {
|
|
2842
|
+
/* HOLLOW, HUGE, AND NEARLY GONE. A filled glyph sharing air
|
|
2843
|
+
with the building is a stain on the lens; a hard outline is
|
|
2844
|
+
a diagram drawn on the sky. What the shot wants is a word
|
|
2845
|
+
the WEATHER is holding — twice the size, a hairline, and
|
|
2846
|
+
faint enough that the hill reads straight through it. */
|
|
2847
|
+
color: 'rgba(252,247,236,0.34)',
|
|
2848
|
+
/* THE YEAR IS THE CENTENARY'S OWN NUMERAL. It was set in the
|
|
2849
|
+
page's old-style serif — the typography of the standalone
|
|
2850
|
+
study, not of this film — which read as a caption borrowed
|
|
2851
|
+
from somewhere else every time a date came up in the sky.
|
|
2852
|
+
Heavy grotesque, barely tracked, the way the programme sets
|
|
2853
|
+
a date. */
|
|
2854
|
+
family: this.worldFamily,
|
|
2855
|
+
lines: beat.sky.lines,
|
|
2856
|
+
shadow: 'rgba(40,30,14,0.34)',
|
|
2857
|
+
shadowBlur: 0.16,
|
|
2858
|
+
size: beat.sky.size * 3.4,
|
|
2859
|
+
weight: this.worldWeight,
|
|
2860
|
+
/* LIGHT, not ink. Drawn in the same warm white the front door
|
|
2861
|
+
uses, at a hairline: over this film's grounds a pale
|
|
2862
|
+
outline reads as a word held in the air, where a dark one
|
|
2863
|
+
reads as a diagram printed on the sky. */
|
|
2864
|
+
track: beat.sky.track ?? this.worldTrack
|
|
2865
|
+
}, {
|
|
2866
|
+
billboard: true,
|
|
2867
|
+
opacity: this.skyA = this.skyOn * (beat.sky.opacity ?? 0.4) * 0.78 * smooth(Math.max(0, Math.min(1, (local - 0.16) / 0.16))) * (1 - smooth(Math.max(0, Math.min(1, (local - 0.52) / 0.16)))),
|
|
2868
|
+
x: -Math.sin(a) * d,
|
|
2869
|
+
y: beat.sky.y,
|
|
2870
|
+
z: -Math.cos(a) * d
|
|
2871
|
+
});
|
|
2872
|
+
} else if (this.skyA > 0.001 || this.skyOn > 0.001) {
|
|
2873
|
+
this.skyOn = this.exact ? 0 : this.skyOn + (0 - this.skyOn) * Math.min(1, dt * 2.4);
|
|
2874
|
+
/* from where the word actually is, which is usually already nothing */
|
|
2875
|
+
this.skyA = this.exact ? 0 : this.skyA + (0 - this.skyA) * Math.min(1, dt * 2.4);
|
|
2876
|
+
film.fade('chapter', this.skyA);
|
|
2877
|
+
}
|
|
2878
|
+
this.stageMark(film, beat, local, dt);
|
|
2879
|
+
this.trackPoint(film, beat, local, typeA);
|
|
2880
|
+
this.traceShape(film, beat, local, dt);
|
|
2881
|
+
this.parallax(local);
|
|
2882
|
+
this.follow(film);
|
|
2883
|
+
this.wear(film);
|
|
2884
|
+
}
|
|
2885
|
+
/**
|
|
2886
|
+
* TYPE THROWS ITS SHADOW WHERE THE BUILDING THROWS ITS OWN.
|
|
2887
|
+
*
|
|
2888
|
+
* The scene lights itself from a key whose position is a property of the
|
|
2889
|
+
* hour, and the tower's cast shadow lies along the ground at whatever
|
|
2890
|
+
* diagonal that produces. Type set over the frame with a shadow offset
|
|
2891
|
+
* down and right — the default of every drop shadow ever shipped — is
|
|
2892
|
+
* lit by a different sun than everything behind it, and the eye reads
|
|
2893
|
+
* the mismatch long before it can name it. So the key's own screen
|
|
2894
|
+
* position comes back from the scene and the offset is simply the
|
|
2895
|
+
* direction away from it. At night, or with the sun behind the lens,
|
|
2896
|
+
* there is no honest cast shadow and the type wears none.
|
|
2897
|
+
*/
|
|
2898
|
+
follow(film) {
|
|
2899
|
+
const el = this.pageEl;
|
|
2900
|
+
if (!el) {
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
const s = film.sun();
|
|
2904
|
+
const cx = s.w / 2;
|
|
2905
|
+
const cy = s.h * 0.55;
|
|
2906
|
+
const dx = cx - s.x;
|
|
2907
|
+
const dy = cy - s.y;
|
|
2908
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
2909
|
+
const throwPx = s.on ? 3.2 : 0;
|
|
2910
|
+
const nx = dx / len * throwPx;
|
|
2911
|
+
const ny = dy / len * throwPx;
|
|
2912
|
+
/* a whole pixel is enough to read and small enough never to smear */
|
|
2913
|
+
const q = v => Math.round(v * 10) / 10;
|
|
2914
|
+
if (q(nx) !== this.shadow.x || q(ny) !== this.shadow.y) {
|
|
2915
|
+
this.shadow = {
|
|
2916
|
+
x: q(nx),
|
|
2917
|
+
y: q(ny)
|
|
2918
|
+
};
|
|
2919
|
+
el.style.setProperty('--cf-shx', `${this.shadow.x}px`);
|
|
2920
|
+
el.style.setProperty('--cf-shy', `${this.shadow.y}px`);
|
|
2921
|
+
/* the same diagonal, longer, for the graphic rules */
|
|
2922
|
+
this.rakeDeg = q(Math.atan2(ny, nx) * 57.2958);
|
|
2923
|
+
el.style.setProperty('--cf-rake', `${this.rakeDeg}deg`);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
/** which of the frame's layers this picture draws itself */
|
|
2927
|
+
takeGlass(film) {
|
|
2928
|
+
this.washInGlass = !!film?.wash;
|
|
2929
|
+
this.cloudInGlass = !!film?.cloud;
|
|
2930
|
+
this.dimInGlass = !!film?.dim;
|
|
2931
|
+
/* a picture arriving mid-run has not been told the wash yet */
|
|
2932
|
+
this.washSent = '';
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* THE WASH, TOLD ON A CHANGE. Two things decide it — the shot's own mode
|
|
2936
|
+
* and the palette's paper — and neither changes often, so the picture is
|
|
2937
|
+
* told when one does and eases the change itself over the same 700 ms
|
|
2938
|
+
* the stylesheet used to.
|
|
2939
|
+
*/
|
|
2940
|
+
pushWash(film) {
|
|
2941
|
+
if (!film.wash) {
|
|
2942
|
+
return;
|
|
2943
|
+
}
|
|
2944
|
+
const mode = this.beat.bare ? '' : this.beat.mode ?? '';
|
|
2945
|
+
const sig = `${mode}|${this.paperNow}`;
|
|
2946
|
+
if (sig === this.washSent) {
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
this.washSent = sig;
|
|
2950
|
+
film.wash(mode, this.paperNow);
|
|
2951
|
+
}
|
|
2952
|
+
/** the cloud: into the glass where the picture takes it, onto a CSS plate where it does not */
|
|
2953
|
+
setCloud(film, k) {
|
|
2954
|
+
if (film.cloud) {
|
|
2955
|
+
film.cloud(k, this.rakeDeg);
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
this.pageEl?.style.setProperty('--cf-cloud', k.toFixed(3));
|
|
2959
|
+
this.pageEl?.classList.toggle('is-cloudless', k < 0.02);
|
|
2960
|
+
}
|
|
2961
|
+
/**
|
|
2962
|
+
* THE FURNITURE WEARS THE SCENE'S PALETTE.
|
|
2963
|
+
*
|
|
2964
|
+
* The page carries four times of day and swings its whole palette between
|
|
2965
|
+
* them, so a scrim and a caption painted in one fixed ochre are correct in
|
|
2966
|
+
* exactly one chapter and wrong in the other four — at noon the film's
|
|
2967
|
+
* warm paper sits on a cool scene like a sticker. The scene already
|
|
2968
|
+
* publishes its ink as CSS variables, so the film simply reads them and
|
|
2969
|
+
* dresses itself the same, once per change rather than once per frame.
|
|
2970
|
+
*/
|
|
2971
|
+
wear(film) {
|
|
2972
|
+
const p = film.palette();
|
|
2973
|
+
if (!p.time) {
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* THE INK IS JUDGED AGAINST WHAT IS ACTUALLY ON SCREEN. The paper
|
|
2978
|
+
* alone lied: a noon paper under a heavy grade, a dim, and thick
|
|
2979
|
+
* haze is a DARK frame wearing a light theme, and dark ink on it
|
|
2980
|
+
* disappears. So the measured luminance is discounted by the
|
|
2981
|
+
* grade's own brightness, by whether this beat runs the dim, and
|
|
2982
|
+
* by its haze — and the type flips to light whenever the frame it
|
|
2983
|
+
* sits on has genuinely gone dark, not merely when the clock says
|
|
2984
|
+
* night.
|
|
2985
|
+
*/
|
|
2986
|
+
const GB = this.picture?.gradeLum ?? GRADE_LUM;
|
|
2987
|
+
const beat = this.beat;
|
|
2988
|
+
const dimmed = beat.trace || beat.to ? 0.74 : 1;
|
|
2989
|
+
const eff = luminance(p.paper) * (GB[this.grade] ?? 1) * dimmed * (1 - 0.25 * (beat.haze ?? 0));
|
|
2990
|
+
const dark = eff < 0.42;
|
|
2991
|
+
const ch = this.chapters[this.beat.ch ?? 0] ?? this.chapters[0];
|
|
2992
|
+
const sig = `${p.time}${dark ? '#d' : '#l'}${ch.n}`;
|
|
2993
|
+
if (sig === this.wearing) {
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
this.wearing = sig;
|
|
2997
|
+
const el = this.pageEl;
|
|
2998
|
+
if (!el) {
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
/* the wash is mixed from this too, wherever it is drawn */
|
|
3002
|
+
this.paperNow = p.paper;
|
|
3003
|
+
el.style.setProperty('--cf-paper', p.paper);
|
|
3004
|
+
el.style.setProperty('--cf-paper-a', rgba(p.paper, 0.94));
|
|
3005
|
+
el.style.setProperty('--cf-paper-b', rgba(p.paper, 0.76));
|
|
3006
|
+
el.style.setProperty('--cf-paper-c', rgba(p.paper, 0));
|
|
3007
|
+
/**
|
|
3008
|
+
* WHEN THE GROUND GOES DARK, THE TYPE GOES LIGHT.
|
|
3009
|
+
*
|
|
3010
|
+
* The scrims are mixed from the scene's own paper, so at night they
|
|
3011
|
+
* are a dark wash — and dark ink on a dark wash is unreadable however
|
|
3012
|
+
* carefully the scrim was tuned. Rather than hand-pick a palette per
|
|
3013
|
+
* hour, the ink is DERIVED — from the frame's effective luminance,
|
|
3014
|
+
* computed above. One rule, right for all four hours, every grade,
|
|
3015
|
+
* and any theme anybody adds later.
|
|
3016
|
+
*/
|
|
3017
|
+
el.classList.toggle('is-dark', dark);
|
|
3018
|
+
el.style.setProperty('--cf-ink', dark ? '#f7f0e0' : p.ink);
|
|
3019
|
+
el.style.setProperty('--cf-ink2', dark ? '#bdb3a0' : p.ink2);
|
|
3020
|
+
el.style.setProperty('--cf-ink3', dark ? '#e6dcc8' : p.ink3);
|
|
3021
|
+
/* gold, not amber: the liturgical metal, by day and under floodlight */
|
|
3022
|
+
el.style.setProperty('--cf-accent', this.accentFor(dark, p.accent));
|
|
3023
|
+
el.style.setProperty('--cf-rule', p.rule);
|
|
3024
|
+
/* the chapter's own colour, which every piece of type in the
|
|
3025
|
+
information layer is set in (see this.chapters) */
|
|
3026
|
+
el.style.setProperty('--cf-chap', (dark ? ch.tintD : ch.tint) ?? this.accentFor(dark, p.accent));
|
|
3027
|
+
}
|
|
3028
|
+
/**
|
|
3029
|
+
* PLANES, MOVING AT THEIR OWN RATES.
|
|
3030
|
+
*
|
|
3031
|
+
* The world layer parallaxes because it is genuinely out in the scene at
|
|
3032
|
+
* different distances. The front layer has no depth to borrow, so it is
|
|
3033
|
+
* given one: over the life of a beat each row drifts and scales on its
|
|
3034
|
+
* own rate, the big glyph fastest and nearest, the small print slowest
|
|
3035
|
+
* and furthest, so the type reads as a set of planes rather than a
|
|
3036
|
+
* sheet. It is the oldest trick in motion graphics and it is here for
|
|
3037
|
+
* the oldest reason — a still caption over a moving picture looks
|
|
3038
|
+
* pasted on, and a caption that moves WITH the picture looks composited
|
|
3039
|
+
* into it.
|
|
3040
|
+
*
|
|
3041
|
+
* It runs on the beat's own clock rather than Motion's, deliberately:
|
|
3042
|
+
* the entrances belong to the score and this belongs to the shot, and
|
|
3043
|
+
* putting the two on one timeline would mean the drift restarting every
|
|
3044
|
+
* time a word did.
|
|
3045
|
+
*/
|
|
3046
|
+
parallax(local) {
|
|
3047
|
+
const rows = this.rowEls;
|
|
3048
|
+
if (!rows.length) {
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
/* ease in over the head of the beat, then KEEP MOVING: the old curve
|
|
3052
|
+
saturated at half the beat and the block sat still for the rest,
|
|
3053
|
+
which is exactly the pasted-on look the parallax exists to kill.
|
|
3054
|
+
This one never stops — a slow push that lasts the whole shot. */
|
|
3055
|
+
const t = smooth(Math.min(1, local * 1.6)) * (0.35 + 0.65 * local);
|
|
3056
|
+
/**
|
|
3057
|
+
* A CLOSE-UP GETS LESS OF THIS. Parallax on the type exists to
|
|
3058
|
+
* stop a caption looking pasted onto a moving picture. On a 4× detail
|
|
3059
|
+
* shot the picture is barely moving and the caption is the largest
|
|
3060
|
+
* thing on screen, so the same amplitude stops reading as depth and
|
|
3061
|
+
* starts reading as drift. It has to scale with what the shot behind
|
|
3062
|
+
* it is actually doing.
|
|
3063
|
+
*/
|
|
3064
|
+
const amp = this.beat.mode === 'point' ? 0.35 : 1;
|
|
3065
|
+
/* the last moments of the beat let go: the planes keep travelling
|
|
3066
|
+
and fade under the swap, so an exit is an exit and not a vanish */
|
|
3067
|
+
const out = local > 0.93 ? Math.max(0, (1 - local) / 0.07) : 1;
|
|
3068
|
+
for (const [i, el] of rows.entries()) {
|
|
3069
|
+
/* the glyph sits near the top of the block and should be the
|
|
3070
|
+
NEAREST plane, so depth runs down the block rather than up it —
|
|
3071
|
+
and each plane travels at its OWN rate, slightly detuned, so the
|
|
3072
|
+
stack reads as separate sheets of glass rather than one card */
|
|
3073
|
+
const depth = 1 - i / Math.max(1, rows.length - 1);
|
|
3074
|
+
const rate = 1 + 0.22 * Math.sin(i * 2.4);
|
|
3075
|
+
/* the x-glide is SHARED: staggering it per row shifts every text
|
|
3076
|
+
line's left edge differently, which the eye reads as broken
|
|
3077
|
+
indentation, not depth. The planes separate in y and scale,
|
|
3078
|
+
where the type's own alignment cannot be injured. */
|
|
3079
|
+
const dx = -11 * t * amp;
|
|
3080
|
+
const dy = -(5 + 13 * depth) * t * amp * rate;
|
|
3081
|
+
const sc = 1 + 0.055 * depth * t * amp;
|
|
3082
|
+
el.style.transform = `translate3d(${dx.toFixed(2)}px,${dy.toFixed(2)}px,0) scale(${sc.toFixed(4)})`;
|
|
3083
|
+
el.style.opacity = out.toFixed(3);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
/**
|
|
3087
|
+
* THE TRACES LIVE IN THE SCENE NOW. They began as SVG projected over
|
|
3088
|
+
* the frame — approximately right and one frame behind the lens on
|
|
3089
|
+
* every move. As tubes in the page's own scene they are exact: they
|
|
3090
|
+
* lie ON the eave because they are geometry at the eave's own
|
|
3091
|
+
* coordinates, the building occludes them honestly, and the draw-on
|
|
3092
|
+
* is the tube's index buffer revealed in path order. Each line in a
|
|
3093
|
+
* set starts a moment after the one before it — a hand annotating,
|
|
3094
|
+
* not a diagram switching on.
|
|
3095
|
+
*/ /** how much of the follow is in force (eased), and its last aim and zoom */
|
|
3096
|
+
riseK = 0;
|
|
3097
|
+
riseAim = 0;
|
|
3098
|
+
riseZoom = 1;
|
|
3099
|
+
/**
|
|
3100
|
+
* THE LENS FOLLOWS THE WORK. Chris's rule for a building that goes up
|
|
3101
|
+
* in the shot: if a spire is rising, ZOOM OUT; as you push in, follow
|
|
3102
|
+
* the top that is being built. So while the followed campaign is
|
|
3103
|
+
* between its base and its top, the aim rides a little above the
|
|
3104
|
+
* middle of what stands and the zoom is capped to keep base and top
|
|
3105
|
+
* in the frame; when it finishes, the authored pose takes over again,
|
|
3106
|
+
* on a one-pole so the release is a move and not a jump.
|
|
3107
|
+
*/
|
|
3108
|
+
followRise(film, dt) {
|
|
3109
|
+
const b = this.beat;
|
|
3110
|
+
const want = b.follow !== false && Array.isArray(b.build) ? film.rising?.(typeof b.follow === 'string' ? b.follow : undefined) ?? null : null;
|
|
3111
|
+
const on = !!want && want.rising;
|
|
3112
|
+
if (this.exact) {
|
|
3113
|
+
/* the follow eases in and out on the campaign's own progress, which
|
|
3114
|
+
is a function of the page's clock and so of the film's */
|
|
3115
|
+
if (!on || !want) {
|
|
3116
|
+
return null;
|
|
3117
|
+
}
|
|
3118
|
+
const span = Math.max(1.6, want.top - want.base + 1.0);
|
|
3119
|
+
const aim = want.base + 0.58 * (want.top - want.base) - this.rigMid;
|
|
3120
|
+
const zoom = Math.min(this.now.dolly, 7.0 / span);
|
|
3121
|
+
const k = smooth(Math.min(1, want.k / 0.12)) * (1 - smooth(Math.max(0, (want.k - 0.86) / 0.14)));
|
|
3122
|
+
return {
|
|
3123
|
+
lookY: lerp(this.now.lookY, aim, k),
|
|
3124
|
+
zoom: lerp(this.now.dolly, zoom, k)
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
if (on && want) {
|
|
3128
|
+
const span = Math.max(1.6, want.top - want.base + 1.0);
|
|
3129
|
+
const aim = want.base + 0.58 * (want.top - want.base) - this.rigMid;
|
|
3130
|
+
const zoom = Math.min(this.now.dolly, 7.0 / span);
|
|
3131
|
+
const k = Math.min(1, dt / 1.1);
|
|
3132
|
+
this.riseAim += (aim - this.riseAim) * (this.riseK < 0.02 ? 1 : k);
|
|
3133
|
+
this.riseZoom += (zoom - this.riseZoom) * (this.riseK < 0.02 ? 1 : k);
|
|
3134
|
+
}
|
|
3135
|
+
this.riseK += ((on ? 1 : 0) - this.riseK) * Math.min(1, dt / 0.9);
|
|
3136
|
+
if (this.riseK < 0.01) {
|
|
3137
|
+
return null;
|
|
3138
|
+
}
|
|
3139
|
+
return {
|
|
3140
|
+
lookY: lerp(this.now.lookY, this.riseAim, this.riseK),
|
|
3141
|
+
zoom: lerp(this.now.dolly, this.riseZoom, this.riseK)
|
|
3142
|
+
};
|
|
3143
|
+
}
|
|
3144
|
+
/** the beat whose lines are being timed, and the local time they became drawable */
|
|
3145
|
+
traceBeat = '';
|
|
3146
|
+
traceFrom = -1;
|
|
3147
|
+
/** whether the beat's lines may be on the glass right now */
|
|
3148
|
+
traceOk = false;
|
|
3149
|
+
/**
|
|
3150
|
+
* NO LINE ON A THING STILL GOING UP. A trace or a callout is drawn on a
|
|
3151
|
+
* finished part: the followed campaign must be done (or, with nothing
|
|
3152
|
+
* followed, the built height must have passed the line). The draw-on
|
|
3153
|
+
* is timed from the moment that became true, not from the head.
|
|
3154
|
+
*/
|
|
3155
|
+
traceReady(film, beat, local) {
|
|
3156
|
+
if (this.traceBeat !== beat.id) {
|
|
3157
|
+
this.traceBeat = beat.id;
|
|
3158
|
+
this.traceFrom = -1;
|
|
3159
|
+
}
|
|
3160
|
+
let ready = true;
|
|
3161
|
+
if (beat.follow !== false && Array.isArray(beat.build)) {
|
|
3162
|
+
const r = film.rising?.(typeof beat.follow === 'string' ? beat.follow : undefined);
|
|
3163
|
+
if (typeof beat.follow === 'string') {
|
|
3164
|
+
ready = !!r && r.done;
|
|
3165
|
+
} else {
|
|
3166
|
+
ready = !r || !r.rising;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
if (ready && this.traceFrom < 0) {
|
|
3170
|
+
this.traceFrom = local;
|
|
3171
|
+
}
|
|
3172
|
+
this.traceOk = ready && this.traceFrom >= 0;
|
|
3173
|
+
return this.traceOk;
|
|
3174
|
+
}
|
|
3175
|
+
traceShape(film, beat, local, dt) {
|
|
3176
|
+
const specs = beat.trace ?? [];
|
|
3177
|
+
let drew = 0;
|
|
3178
|
+
/* AND THEY LEAVE. A hand that annotates a drawing also takes the
|
|
3179
|
+
annotation away; a red line that survives to the cut reads as
|
|
3180
|
+
something the film forgot. The whole set fades over the last
|
|
3181
|
+
fifth of the beat, in the order it was drawn. */
|
|
3182
|
+
const gone = Math.min(1, Math.max(0, (local - 0.78) / 0.16));
|
|
3183
|
+
const since = this.traceReady(film, beat, local) ? local - this.traceFrom : -1;
|
|
3184
|
+
for (const [i] of specs.entries()) {
|
|
3185
|
+
const draw = Math.min(1, Math.max(0, (since - 0.04 - i * 0.12) / 0.16));
|
|
3186
|
+
/* UNDRAWN, NOT FADED: the pen lifts back along the path it drew,
|
|
3187
|
+
first line first, so the annotation leaves as a gesture */
|
|
3188
|
+
const undraw = Math.min(1, Math.max(0, gone * (1 + i * 0.25)));
|
|
3189
|
+
drew = Math.max(drew, draw * (1 - undraw));
|
|
3190
|
+
if (undraw > 0 && film.traceUndraw) {
|
|
3191
|
+
film.traceUndraw(`t${i}`, undraw);
|
|
3192
|
+
film.traceFade(`t${i}`, undraw >= 1 ? 0 : 1);
|
|
3193
|
+
} else {
|
|
3194
|
+
film.traceDraw(`t${i}`, draw);
|
|
3195
|
+
/* a page that cannot lift the pen fades the line instead */
|
|
3196
|
+
film.traceFade(`t${i}`, 1 - undraw);
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
/* the callout earns the dim too — a leader and a ring are annotation
|
|
3200
|
+
just as much as a traced eave is */
|
|
3201
|
+
if (beat.to) {
|
|
3202
|
+
drew = Math.max(drew, Math.min(1, Math.max(0, (since - 0.04) / 0.18)));
|
|
3203
|
+
}
|
|
3204
|
+
this.dimNow = this.exact ? drew : this.dimNow + (drew - this.dimNow) * Math.min(1, dt * 3.2);
|
|
3205
|
+
if (this.dimInGlass) {
|
|
3206
|
+
film.dim?.(this.dimNow);
|
|
3207
|
+
} else if (this.dim) {
|
|
3208
|
+
this.dim.style.opacity = this.dimNow.toFixed(3);
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
/** the mark's facing, damped behind the camera's own bearing */
|
|
3212
|
+
markFace = 0;
|
|
3213
|
+
/**
|
|
3214
|
+
* Place the stage mark: climb to its height with a settle, hold, and
|
|
3215
|
+
* face the camera a beat late.
|
|
3216
|
+
*/
|
|
3217
|
+
stageMark(film, beat, local, dt) {
|
|
3218
|
+
const m = beat.mark;
|
|
3219
|
+
const tether = this.tetherEl;
|
|
3220
|
+
/* THE CLIMB BELONGS TO THE WORD, NOT TO THE BEAT. A word that waits
|
|
3221
|
+
for its wall to be built must still ENTER low — otherwise it
|
|
3222
|
+
arrives at whatever height a ramp running since the top of the
|
|
3223
|
+
beat has carried it to, which for the stone base meant appearing
|
|
3224
|
+
halfway up the sky it was supposed to rise into. The rise is
|
|
3225
|
+
measured from the moment it shows up. */
|
|
3226
|
+
if (!m) {
|
|
3227
|
+
film.fade('mark', 0);
|
|
3228
|
+
if (tether) {
|
|
3229
|
+
tether.style.opacity = '0';
|
|
3230
|
+
}
|
|
3231
|
+
return;
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* IT COMES IN BEHIND THE BUILDING, EVERY TIME. The entry height is
|
|
3235
|
+
* read off the structure at the moment the word arrives — a little
|
|
3236
|
+
* under the top of whatever has been built so far — so the thing on
|
|
3237
|
+
* screen always hides the word's first frames and hands it over as
|
|
3238
|
+
* it climbs. Taken once per beat, because reading it every frame
|
|
3239
|
+
* would make the word ride the build instead of rising past it.
|
|
3240
|
+
*/
|
|
3241
|
+
if (!this.markSeated && local >= (m.at ?? 0.14)) {
|
|
3242
|
+
this.markSeated = true;
|
|
3243
|
+
this.markFrom = Math.max(0.4, film.height() - 3.4);
|
|
3244
|
+
}
|
|
3245
|
+
/* the climb takes the first fifth of the beat and SETTLES — barely
|
|
3246
|
+
past its mark, once. The old spring bounced at the top like a
|
|
3247
|
+
carnival bell, which no surveyor's mark has ever done. */
|
|
3248
|
+
const raw = Math.min(1, Math.max(0, (local - 0.04) / 0.2));
|
|
3249
|
+
const c1 = 0.45;
|
|
3250
|
+
const p = raw - 1;
|
|
3251
|
+
const rise = raw >= 1 ? 1 : 1 + (c1 + 1) * p * p * p + c1 * p * p;
|
|
3252
|
+
const y = m.to * rise;
|
|
3253
|
+
const a = m.bearing * RAD;
|
|
3254
|
+
const v = film.view();
|
|
3255
|
+
/* a damped follow, and wrapped so the lag never takes the long way */
|
|
3256
|
+
this.markFace = this.exact ? v.az : this.markFace + wrapAngle(v.az - this.markFace) * Math.min(1, dt * 1.6);
|
|
3257
|
+
film.sky('mark', {
|
|
3258
|
+
/* BIG, WHITE, SOFT, AND BEHIND. A dark label pasted over the
|
|
3259
|
+
scaffolding is a sticker; a big pale word standing further out
|
|
3260
|
+
than the structure — soft-edged, its own glow holding it
|
|
3261
|
+
against the hills, the building crossing in FRONT of it — is
|
|
3262
|
+
part of the place. */ /* white OUTLINE, on its own plane behind the building, and
|
|
3263
|
+
smaller than the sky word: this one names a part rather than
|
|
3264
|
+
the chapter, so it stands at the height of the thing it names
|
|
3265
|
+
and keeps climbing with it. */ /* BIGGER, FAINTER, AND IN THE SAME PLACE EVERY TIME. A word
|
|
3266
|
+
that lands somewhere new each beat is a caption chasing the
|
|
3267
|
+
building; one that always stands in the same spot behind it
|
|
3268
|
+
becomes a fixture of the film — you stop reading it as an
|
|
3269
|
+
annotation and start reading it as the name of what you are
|
|
3270
|
+
watching. Big enough to be architecture, faint enough that
|
|
3271
|
+
the timber crossing it always wins. */ /* TINTED, not outlined. An outline is a drawing of a word; a
|
|
3272
|
+
tint is the word itself, standing in the same air as
|
|
3273
|
+
everything else and taking the same light. Grey because the
|
|
3274
|
+
sky it stands in is nearly white, and faint enough that the
|
|
3275
|
+
building crossing it always wins. */
|
|
3276
|
+
color: 'rgba(78,71,56,0.3)',
|
|
3277
|
+
family: this.worldFamily,
|
|
3278
|
+
lines: m.lines,
|
|
3279
|
+
shadow: 'rgba(255,252,244,0.28)',
|
|
3280
|
+
shadowBlur: 0.14,
|
|
3281
|
+
size: m.size * 2.7,
|
|
3282
|
+
track: this.worldTrack,
|
|
3283
|
+
weight: this.worldWeight
|
|
3284
|
+
}, {
|
|
3285
|
+
/* IN LATE, OUT EARLY. The word arrives after the shot has been
|
|
3286
|
+
running long enough to be about something, names the stage, and
|
|
3287
|
+
is gone before the middle — a title that outstays the moment it
|
|
3288
|
+
titles becomes furniture. */
|
|
3289
|
+
opacity: 0.62 * smooth(Math.max(0, Math.min(1, (local - (m.at ?? 0.14)) / 0.16))) * (1 - smooth(Math.max(0, Math.min(1, (local - ((m.at ?? 0.14) + (m.hold ?? 0.34))) / 0.2)))),
|
|
3290
|
+
ry: this.markFace / RAD % 360,
|
|
3291
|
+
/**
|
|
3292
|
+
* IT RISES OUT FROM BEHIND THE BUILDING. The word starts low
|
|
3293
|
+
* enough that the structure stands in front of it, fades up
|
|
3294
|
+
* while it is still half hidden, climbs past the frame the
|
|
3295
|
+
* carpenters are raising, and is gone before it reaches the top
|
|
3296
|
+
* of the picture — so the BUILDING reveals it, rather than a
|
|
3297
|
+
* caption appearing beside the building. Planted on one azimuth
|
|
3298
|
+
* so the camera moves PAST it, and nudged back toward the lens
|
|
3299
|
+
* if the orbit carries it out of frame (below).
|
|
3300
|
+
*/
|
|
3301
|
+
x: Math.sin(this.markAz) * 26 + Math.cos(this.markAz) * this.markOff,
|
|
3302
|
+
/* a short climb, not a launch: the word should read as drifting
|
|
3303
|
+
up out of the structure over its whole life, not crossing the
|
|
3304
|
+
frame — same time on screen, a third of the distance */
|
|
3305
|
+
y: this.markFrom + Math.max(0, local - (m.at ?? 0.14)) * 7,
|
|
3306
|
+
z: Math.cos(this.markAz) * 26 - Math.sin(this.markAz) * this.markOff
|
|
3307
|
+
});
|
|
3308
|
+
/**
|
|
3309
|
+
* KEEP IT ON SCREEN — WHILE IT IS ON SCREEN. A planted word
|
|
3310
|
+
* parallaxes, which is the point, but the shot can orbit far enough
|
|
3311
|
+
* to carry it out of the picture, so the anchor is nudged back
|
|
3312
|
+
* toward the lens when what it projects to leaves the safe area.
|
|
3313
|
+
*
|
|
3314
|
+
* Two things this must NOT do. It must not correct a word that has
|
|
3315
|
+
* already faded out — the first mark used to slide sideways as it
|
|
3316
|
+
* left, because the correction was still hunting a glyph nobody
|
|
3317
|
+
* could see. And it must not treat the TOP edge as a failure: this
|
|
3318
|
+
* word is supposed to rise out of frame. Only the sides count, and
|
|
3319
|
+
* only while it is visible.
|
|
3320
|
+
*/
|
|
3321
|
+
const seen = film.project(Math.sin(this.markAz) * 26 + Math.cos(this.markAz) * this.markOff, this.markFrom + Math.max(0, local - (m.at ?? 0.14)) * 7, Math.cos(this.markAz) * 26 - Math.sin(this.markAz) * this.markOff);
|
|
3322
|
+
const lit = local > (m.at ?? 0.14) - 0.02 && local < (m.at ?? 0.14) + (m.hold ?? 0.34) + 0.02;
|
|
3323
|
+
const outside = lit && (!seen.on || seen.x < v.w * 0.12 || seen.x > v.w * 0.88);
|
|
3324
|
+
if (outside) {
|
|
3325
|
+
let d = v.az + Math.PI - this.markAz;
|
|
3326
|
+
while (d > Math.PI) {
|
|
3327
|
+
d -= Math.PI * 2;
|
|
3328
|
+
}
|
|
3329
|
+
while (d < -Math.PI) {
|
|
3330
|
+
d += Math.PI * 2;
|
|
3331
|
+
}
|
|
3332
|
+
this.markAz += d * Math.min(1, dt * 0.9);
|
|
3333
|
+
}
|
|
3334
|
+
/* AND IT IS TETHERED. A label floating beside a building names
|
|
3335
|
+
nothing; a line back to the height it is describing turns it into a
|
|
3336
|
+
measurement. */
|
|
3337
|
+
if (tether && this.frameEl) {
|
|
3338
|
+
const host = this.frameEl.getBoundingClientRect();
|
|
3339
|
+
const sx = host.width / v.w;
|
|
3340
|
+
const sy = host.height / v.h;
|
|
3341
|
+
const from = film.project(Math.sin(a) * m.r * 0.72, y, Math.cos(a) * m.r * 0.72);
|
|
3342
|
+
const to = film.project(0, y, 0);
|
|
3343
|
+
tether.setAttribute('x1', String(from.x * sx));
|
|
3344
|
+
tether.setAttribute('y1', String(from.y * sy));
|
|
3345
|
+
/* it reaches for the word rather than appearing beside it */
|
|
3346
|
+
const tk = Math.min(1, Math.max(0, (raw - 0.4) / 0.22));
|
|
3347
|
+
const fx = Number(tether.getAttribute('x1') ?? 0);
|
|
3348
|
+
const fy = Number(tether.getAttribute('y1') ?? 0);
|
|
3349
|
+
tether.setAttribute('x2', String(fx + (to.x * sx - fx) * tk));
|
|
3350
|
+
tether.setAttribute('y2', String(fy + (to.y * sy - fy) * tk));
|
|
3351
|
+
tether.style.opacity = tk > 0.01 ? '0.55' : '0';
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
/**
|
|
3355
|
+
* THE TRACKING LINE — the one piece of the front layer that has to know
|
|
3356
|
+
* where the camera is pointing this frame. The caption stays where the
|
|
3357
|
+
* layout put it and a line reaches from it to the actual point on the
|
|
3358
|
+
* building, redrawn every frame, so the label is attached to the thing
|
|
3359
|
+
* rather than merely near it.
|
|
3360
|
+
*/
|
|
3361
|
+
trackPoint(film, beat, local, typeA) {
|
|
3362
|
+
const line = this.lineEl;
|
|
3363
|
+
const dot = this.dotEl;
|
|
3364
|
+
if (!line || !dot) {
|
|
3365
|
+
return;
|
|
3366
|
+
}
|
|
3367
|
+
if (!beat.to || !this.frameEl || !this.plateEl) {
|
|
3368
|
+
line.style.opacity = '0';
|
|
3369
|
+
dot.style.opacity = '0';
|
|
3370
|
+
return;
|
|
3371
|
+
}
|
|
3372
|
+
/* nothing is worth pointing at before it is built: the clip plane is
|
|
3373
|
+
the honest test, since the point may be above it for most of a beat */
|
|
3374
|
+
if (beat.to[1] > film.height() + 0.15) {
|
|
3375
|
+
line.style.opacity = '0';
|
|
3376
|
+
dot.style.opacity = '0';
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
const p = film.project(...beat.to);
|
|
3380
|
+
const v = film.view();
|
|
3381
|
+
const host = this.frameEl.getBoundingClientRect();
|
|
3382
|
+
const box = this.plateEl.getBoundingClientRect();
|
|
3383
|
+
/* the page renders at its own size; the frame may be laid out at another */
|
|
3384
|
+
const sx = host.width / v.w;
|
|
3385
|
+
const sy = host.height / v.h;
|
|
3386
|
+
const x2 = p.x * sx;
|
|
3387
|
+
const y2 = p.y * sy;
|
|
3388
|
+
/* leave from the edge of the caption that faces the point */
|
|
3389
|
+
const cx = box.left - host.left + box.width / 2;
|
|
3390
|
+
const cy = box.top - host.top + box.height / 2;
|
|
3391
|
+
const x1 = x2 > cx ? box.right - host.left : box.left - host.left;
|
|
3392
|
+
const y1 = cy;
|
|
3393
|
+
/**
|
|
3394
|
+
* A RED LINE IS DRAWN, NEVER SWITCHED ON.
|
|
3395
|
+
*
|
|
3396
|
+
* The traces on the building draw themselves along their own path and
|
|
3397
|
+
* lift back off it; the callout — the leader and the ring — was
|
|
3398
|
+
* fading in and out beside them, so half the annotation was a gesture
|
|
3399
|
+
* and half was a light switch. Same clock, same behaviour: the leader
|
|
3400
|
+
* runs out from the caption to the point, the ring is drawn round it,
|
|
3401
|
+
* and at the end the leader's tail catches up with its head while the
|
|
3402
|
+
* ring un-draws the way it came.
|
|
3403
|
+
*/
|
|
3404
|
+
const gate = p.on && this.traceOk;
|
|
3405
|
+
const inK = Math.min(1, Math.max(0, (local - 0.12) / 0.16));
|
|
3406
|
+
const outK = Math.min(1, Math.max(0, (local - 0.78) / 0.16));
|
|
3407
|
+
const draw = gate ? inK : 0;
|
|
3408
|
+
const lift = gate ? outK : 1;
|
|
3409
|
+
/* the head runs out first, then the tail follows it home */
|
|
3410
|
+
const hx = x1 + (x2 - x1) * draw;
|
|
3411
|
+
const hy = y1 + (y2 - y1) * draw;
|
|
3412
|
+
const tx = x1 + (hx - x1) * lift;
|
|
3413
|
+
const ty = y1 + (hy - y1) * lift;
|
|
3414
|
+
line.setAttribute('x1', String(tx));
|
|
3415
|
+
line.setAttribute('y1', String(ty));
|
|
3416
|
+
line.setAttribute('x2', String(hx));
|
|
3417
|
+
line.setAttribute('y2', String(hy));
|
|
3418
|
+
dot.setAttribute('cx', String(x2));
|
|
3419
|
+
dot.setAttribute('cy', String(y2));
|
|
3420
|
+
/* the line belongs to the caption: it fades with the type, so a
|
|
3421
|
+
chapter flight after the beat does not leave a rule hanging in the
|
|
3422
|
+
air over a caption that has already gone */
|
|
3423
|
+
line.style.opacity = draw > 0.01 && lift < 0.99 ? (0.75 * typeA).toFixed(3) : '0';
|
|
3424
|
+
/* the ring: an arc that grows from nothing and is rubbed out from
|
|
3425
|
+
the same end it started at */
|
|
3426
|
+
const vis = Math.max(0, draw - lift);
|
|
3427
|
+
dot.style.strokeDasharray = `${vis.toFixed(3)} 1`;
|
|
3428
|
+
dot.style.strokeDashoffset = String(-lift.toFixed(3));
|
|
3429
|
+
dot.style.opacity = vis > 0.005 ? typeA.toFixed(3) : '0';
|
|
3430
|
+
}
|
|
3431
|
+
/**
|
|
3432
|
+
* THE READOUT AND THE MARKER. The numeral is tracked and written only
|
|
3433
|
+
* when the whole year changes, which is a handful of times a run; the
|
|
3434
|
+
* marker's position is a custom property written every frame, because a
|
|
3435
|
+
* dot that steps once a year along a rule is a dot that looks broken.
|
|
3436
|
+
*/ /**
|
|
3437
|
+
* WHEN THE NUMERAL IS ON. It rises as the voice reaches the year, holds
|
|
3438
|
+
* while the phrase finishes, and is gone well before the cut — a date
|
|
3439
|
+
* that outstays the sentence that said it becomes a watermark.
|
|
3440
|
+
*/
|
|
3441
|
+
stampAt(beat, local) {
|
|
3442
|
+
const st = beat.stamp;
|
|
3443
|
+
if (!st) {
|
|
3444
|
+
return 0;
|
|
3445
|
+
}
|
|
3446
|
+
const secs = beat.ticks * TICK;
|
|
3447
|
+
const read = this.voSecs[beat.id] ?? secs * 0.66;
|
|
3448
|
+
const t0 = (read * (st.at ?? 0.06) + this.typeAt[0]) / secs;
|
|
3449
|
+
const rise = 0.9 / secs;
|
|
3450
|
+
const hold = 2.6 / secs;
|
|
3451
|
+
const fall = 1.1 / secs;
|
|
3452
|
+
const inK = Math.max(0, Math.min(1, (local - t0) / rise));
|
|
3453
|
+
const outK = Math.max(0, Math.min(1, (local - t0 - rise - hold) / fall));
|
|
3454
|
+
return smooth(inK) * (1 - smooth(outK));
|
|
3455
|
+
}
|
|
3456
|
+
/** the film's clock readout, from page seconds — nothing without a clock */
|
|
3457
|
+
showYear(t) {
|
|
3458
|
+
const clock = this.args.clock;
|
|
3459
|
+
if (!clock) {
|
|
3460
|
+
return;
|
|
3461
|
+
}
|
|
3462
|
+
const y = clock.yearAt(t);
|
|
3463
|
+
const n = Math.floor(y);
|
|
3464
|
+
if (n !== this.yearSent) {
|
|
3465
|
+
this.yearSent = n;
|
|
3466
|
+
this.yearNow = n;
|
|
3467
|
+
}
|
|
3468
|
+
const [a, z] = clock.span;
|
|
3469
|
+
const p = Math.max(0, Math.min(1, (y - a) / (z - a)));
|
|
3470
|
+
this.pageEl?.style.setProperty('--cf-head', p.toFixed(4));
|
|
3471
|
+
}
|
|
3472
|
+
/** everything a beat changes in the world, done once on entry */
|
|
3473
|
+
applyBeat(beat, instant = false, seam) {
|
|
3474
|
+
const film = this.film;
|
|
3475
|
+
if (!film) {
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
this.beatAt = performance.now();
|
|
3479
|
+
this.cycleStep = -1;
|
|
3480
|
+
/* the traces are the beat's own; stand this beat's up unrevealed and
|
|
3481
|
+
take the last beat's down */
|
|
3482
|
+
for (let i = 0; i < 4; i++) {
|
|
3483
|
+
film.clearTrace(`t${i}`);
|
|
3484
|
+
}
|
|
3485
|
+
/* the tube's thickness is a SCREEN quantity wearing world units: a
|
|
3486
|
+
marker line should read the same weight in a wide shot as in a 4×
|
|
3487
|
+
close-up, so the radius is sized against the shot's magnification */
|
|
3488
|
+
const mag = (beat.cam.dolly + (beat.toCam?.dolly ?? beat.cam.dolly)) / 2;
|
|
3489
|
+
beat.trace?.forEach((spec, i) => {
|
|
3490
|
+
const r = Math.max(0.03, Math.min(0.2, (spec.wide ? 0.13 : 0.085) / mag));
|
|
3491
|
+
film.trace(`t${i}`, {
|
|
3492
|
+
pts: spec.pts,
|
|
3493
|
+
r
|
|
3494
|
+
});
|
|
3495
|
+
});
|
|
3496
|
+
/**
|
|
3497
|
+
* A JOIN WITHOUT A CUT. Most seams are cuts, but not all of them:
|
|
3498
|
+
* the construction chapter FLIES in and then has to clear the
|
|
3499
|
+
* standing building off the ground before it can build one, and a
|
|
3500
|
+
* tower blinking out of existence mid-sweep is a glitch. So a beat
|
|
3501
|
+
* may name a join without being a cut — the overlay plays over the
|
|
3502
|
+
* arriving shot and the camera is left alone.
|
|
3503
|
+
*/
|
|
3504
|
+
if (beat.cut || beat.join) {
|
|
3505
|
+
const join = beat.join ?? this.defaultJoin;
|
|
3506
|
+
if (this.exact) {
|
|
3507
|
+
this.seamExact(film, beat, join, seam);
|
|
3508
|
+
} else if (join === 'whip') {
|
|
3509
|
+
this.whipUntil = performance.now() + 360;
|
|
3510
|
+
} else {
|
|
3511
|
+
/**
|
|
3512
|
+
* Every remaining join works the same way: capture the OUTGOING
|
|
3513
|
+
* frame first — the bridge renders one on demand and reads it
|
|
3514
|
+
* back synchronously — then snap, then run the join's overlay on
|
|
3515
|
+
* the still while the live incoming shot plays underneath.
|
|
3516
|
+
* 'wipe' sweeps it off along the sun's diagonal with a feathered
|
|
3517
|
+
* edge; 'blend' is a push dissolve (it fades AND travels);
|
|
3518
|
+
* 'iris' closes a circle onto the new shot's own subject; 'dip'
|
|
3519
|
+
* covers the seam with a colour. A failed capture (zero-sized
|
|
3520
|
+
* surface) degrades to a clean cut — a dip still gets its veil.
|
|
3521
|
+
*/ /**
|
|
3522
|
+
* THE FREEZE BELONGS ON THE GPU, NOT IN A JPEG.
|
|
3523
|
+
*
|
|
3524
|
+
* Every join that is not a blend used to read the canvas back and
|
|
3525
|
+
* encode it — `toDataURL` on a full-resolution, device-pixel-ratio
|
|
3526
|
+
* canvas, synchronously, on the frame of the cut. That is tens of
|
|
3527
|
+
* milliseconds of stall AT EVERY CUT, which is precisely where the
|
|
3528
|
+
* film could least afford it, and with the dip now the default
|
|
3529
|
+
* join it was happening twenty-odd times a run. The page can hold
|
|
3530
|
+
* the outgoing frame in its own render target instead, for free.
|
|
3531
|
+
* The still is kept only as the fallback for a join whose overlay
|
|
3532
|
+
* genuinely needs pixels in the DOM (the wipe sweeps one, the iris
|
|
3533
|
+
* closes over one).
|
|
3534
|
+
*/
|
|
3535
|
+
let held = '';
|
|
3536
|
+
const deep = this.overOf(beat);
|
|
3537
|
+
/**
|
|
3538
|
+
* THE SEAM IN TWO HALVES. A mix seam the picture can hold goes
|
|
3539
|
+
* into the glass — where it is composited in LIGHT, which an
|
|
3540
|
+
* `<img>` faded by this document can never be — and the incoming
|
|
3541
|
+
* furniture is revealed by the same two numbers rather than
|
|
3542
|
+
* hidden under a still. No read-back, no JPEG, no decode to wait
|
|
3543
|
+
* for, and a seek through it is a function of the clock.
|
|
3544
|
+
*
|
|
3545
|
+
* A wipe and an iris are shapes rather than mixes: they need
|
|
3546
|
+
* pixels to sweep and to clip, so they keep their still.
|
|
3547
|
+
*/
|
|
3548
|
+
const glass = this.glassFor(join);
|
|
3549
|
+
if (glass) {
|
|
3550
|
+
film.freeze?.(true);
|
|
3551
|
+
this.glassKind = join;
|
|
3552
|
+
this.glassName = glass.name ?? '';
|
|
3553
|
+
this.glassLen = this.secsOf(join);
|
|
3554
|
+
this.glassColor = beat.dipTo ?? '#0d0905';
|
|
3555
|
+
this.reveal = 0;
|
|
3556
|
+
this.freeze = '';
|
|
3557
|
+
this.joinKind = '';
|
|
3558
|
+
if (beat.cut) {
|
|
3559
|
+
this.snap(beat.cam, this.tailFor(this.beatIndex), beat.ticks * TICK);
|
|
3560
|
+
}
|
|
3561
|
+
} else if (deep !== 'everything' && (join === 'blend' || join === 'melt' || join === 'dip')) {
|
|
3562
|
+
/* the crossfade lives in the glass now; the still is the
|
|
3563
|
+
fallback, and a stale one must never stand in for it */ /* A/B IN THE GLASS: when nothing but the lens changes across
|
|
3564
|
+
the seam, the outgoing shot keeps moving under the dissolve
|
|
3565
|
+
(the page draws both cameras). A seam that changes the hour,
|
|
3566
|
+
the weather, the model, the snow or the mood keeps the graded
|
|
3567
|
+
still — a live A under B's light is the old shot dressed
|
|
3568
|
+
wrong, which is worse than a frozen one. */
|
|
3569
|
+
const live = beat.theme === undefined && beat.wx === undefined && beat.style === undefined && beat.winter === undefined && beat.grade === undefined && !beat.hours;
|
|
3570
|
+
if (!film.dissolve(join, undefined, live)) {
|
|
3571
|
+
const shot = film.snapshot();
|
|
3572
|
+
held = shot.length > 64 ? shot : '';
|
|
3573
|
+
}
|
|
3574
|
+
} else if (join !== 'cut') {
|
|
3575
|
+
const shot = film.snapshot();
|
|
3576
|
+
held = shot.length > 64 ? shot : '';
|
|
3577
|
+
}
|
|
3578
|
+
/* THE LENS WAITS FOR THE STILL (`film/seam.ts`): everything below
|
|
3579
|
+
happens on the frame the outgoing frame can actually be painted
|
|
3580
|
+
on, not on the frame it was read back. And while it waits, the
|
|
3581
|
+
PICTURE holds the outgoing frame — the rest of this method lands
|
|
3582
|
+
the beat's hour, build, model and weather immediately, and for
|
|
3583
|
+
the two or three frames the decode takes, the canvas would
|
|
3584
|
+
otherwise show the incoming world with nothing over it. */
|
|
3585
|
+
const index = this.beatIndex;
|
|
3586
|
+
if (this.glassKind === join) ; else if (held) {
|
|
3587
|
+
film.freeze?.(true);
|
|
3588
|
+
}
|
|
3589
|
+
this.seam.hold(this.glassKind === join ? '' : held, () => {
|
|
3590
|
+
if (this.glassKind === join) {
|
|
3591
|
+
return;
|
|
3592
|
+
}
|
|
3593
|
+
film.freeze?.(false);
|
|
3594
|
+
this.freeze = held;
|
|
3595
|
+
/* A WIPE IS A CUT with punctuation over it (docs/choreo-splices.md):
|
|
3596
|
+
the still of the outgoing shot sweeps off to reveal the incoming
|
|
3597
|
+
one, so the lens must already BE on the incoming shot. Without
|
|
3598
|
+
the cut, the sweep revealed the old shot still gliding toward the
|
|
3599
|
+
new pose — the same picture twice, once as a still and once live. */
|
|
3600
|
+
if (beat.cut || join === 'wipe') {
|
|
3601
|
+
this.snap(beat.cam, this.tailFor(index), beat.ticks * TICK);
|
|
3602
|
+
}
|
|
3603
|
+
if (join === 'dip') {
|
|
3604
|
+
this.dipColor = beat.dipTo ?? '#0d0905';
|
|
3605
|
+
this.play('dip', deep);
|
|
3606
|
+
} else if (join === 'flash') {
|
|
3607
|
+
this.play('flash', deep);
|
|
3608
|
+
} else if (join === 'sweep') {
|
|
3609
|
+
this.lightSweep(film, beat);
|
|
3610
|
+
} else if (join === 'defocus') {
|
|
3611
|
+
/* the rack: the freeze blurs away above while the live frame
|
|
3612
|
+
arrives soft and pulls itself sharp underneath */
|
|
3613
|
+
if (held) {
|
|
3614
|
+
this.play('defocus', deep);
|
|
3615
|
+
}
|
|
3616
|
+
this.liveEl?.animate([{
|
|
3617
|
+
filter: 'blur(9px)'
|
|
3618
|
+
}, {
|
|
3619
|
+
filter: 'blur(0px)'
|
|
3620
|
+
}], {
|
|
3621
|
+
duration: 700,
|
|
3622
|
+
easing: 'cubic-bezier(0.3, 0, 0.3, 1)'
|
|
3623
|
+
});
|
|
3624
|
+
} else if (held) {
|
|
3625
|
+
if (join === 'iris') {
|
|
3626
|
+
/* the circle closes ON the incoming shot's named point —
|
|
3627
|
+
projected now, with the camera already snapped — so the
|
|
3628
|
+
iris hands the eye directly to the subject */
|
|
3629
|
+
const v = film.view();
|
|
3630
|
+
const p = beat.to ? film.project(...beat.to) : undefined;
|
|
3631
|
+
const cx = p ? Math.max(12, Math.min(88, p.x / v.w * 100)) : 50;
|
|
3632
|
+
const cy = p ? Math.max(12, Math.min(88, p.y / v.h * 100)) : 50;
|
|
3633
|
+
this.irisAt = `--ix:${cx.toFixed(1)}%;--iy:${cy.toFixed(1)}%`;
|
|
3634
|
+
this.play('iris', deep);
|
|
3635
|
+
} else if (this.presentations[join]?.component) {
|
|
3636
|
+
this.play(join, deep);
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
});
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
/**
|
|
3643
|
+
* WHICH BUILDING THIS BEAT IS ABOUT — asserted, never inherited.
|
|
3644
|
+
*
|
|
3645
|
+
* This used to run only when a beat NAMED a style, so every beat
|
|
3646
|
+
* that did not name one (all of chapters one to four) simply kept
|
|
3647
|
+
* whatever tower was standing. Straight through from the top that is
|
|
3648
|
+
* invisible, because the film opens on the keep and only the
|
|
3649
|
+
* comparison names anything else. Come back the other way — the
|
|
3650
|
+
* comparison or the lineup, then a chapter skip back into DETAIL —
|
|
3651
|
+
* and the film narrates the Japanese keep over a Chinese pagoda: the
|
|
3652
|
+
* shot called "at the foot" frames somebody else's balcony, and the
|
|
3653
|
+
* traces, which are computed from the keep's own level table, point
|
|
3654
|
+
* at empty air. That is the "off by one section" and the second
|
|
3655
|
+
* building in the frame, and it is not a motion problem at all: it
|
|
3656
|
+
* is a beat inheriting state it never asked for. The subject of this
|
|
3657
|
+
* film is the keep, so a beat that says nothing means style zero.
|
|
3658
|
+
*/ /* WHICH SUBJECT THIS BEAT IS ABOUT — asserted, never inherited, in a
|
|
3659
|
+
scene that has several: a beat that says nothing means subject
|
|
3660
|
+
zero, or a chapter skip narrates one building over another */
|
|
3661
|
+
if (film.style && film.styleIndex) {
|
|
3662
|
+
const style = beat.style ?? 0;
|
|
3663
|
+
if (film.styleIndex() !== style) {
|
|
3664
|
+
film.style(style);
|
|
3665
|
+
film.time(Array.isArray(beat.build) ? beat.build[0] : beat.build ?? this.standing);
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
/* whatever the last beat did to the model, this one starts whole */
|
|
3669
|
+
if (!beat.dissolve) {
|
|
3670
|
+
film.modelFade(1);
|
|
3671
|
+
}
|
|
3672
|
+
if (typeof beat.build === 'number') {
|
|
3673
|
+
film.time(beat.build);
|
|
3674
|
+
} else if (Array.isArray(beat.build)) {
|
|
3675
|
+
film.time(beat.build[0]);
|
|
3676
|
+
} else {
|
|
3677
|
+
/* EVERY BEAT OWNS ITS BUILD STATE. A beat that says nothing wants
|
|
3678
|
+
the building whole — so a chapter skip, a scrub or a restart
|
|
3679
|
+
landing anywhere after the ending finds the tower standing, not
|
|
3680
|
+
the last beat's rubble. Only the construction chapter and the
|
|
3681
|
+
ending say otherwise, and they say it explicitly. */
|
|
3682
|
+
film.time(this.standing);
|
|
3683
|
+
}
|
|
3684
|
+
if (beat.sky) {
|
|
3685
|
+
/* plant the word where the shot can see it, once */ /* IT HAS TO BE IN THE SHOT.
|
|
3686
|
+
The bearing was taken from the lens at the head of the beat and
|
|
3687
|
+
then held — which is right, because a word that rides the lens is
|
|
3688
|
+
a sticker. But the shot MOVES after that: by the time the camera
|
|
3689
|
+
had swung forty degrees the word was off the left edge, standing
|
|
3690
|
+
in a piece of sky nobody was looking at. So the bearing is taken
|
|
3691
|
+
at the middle of the shot's own swing instead. The word is then
|
|
3692
|
+
behind the building at both ends of the move, the structure
|
|
3693
|
+
crosses it on the way past, and it never leaves the frame. */const swing = beat.toCam ? (beat.toCam.yaw - beat.cam.yaw) * RAD / 2 : 0;
|
|
3694
|
+
this.skyAz = (this.film?.view().az ?? 0) + swing + (beat.sky.az ?? 0) * RAD;
|
|
3695
|
+
}
|
|
3696
|
+
if (beat.mark) {
|
|
3697
|
+
/* AND THE MARK STANDS BEHIND THE BUILDING. Taken once, along the
|
|
3698
|
+
lens axis at the moment the beat lands, so the word is directly
|
|
3699
|
+
behind the subject — the structure crosses it, the camera moves
|
|
3700
|
+
PAST it, and it never reads as a caption stuck on the glass. */
|
|
3701
|
+
this.markAz = (this.film?.view().az ?? 0) + Math.PI;
|
|
3702
|
+
/**
|
|
3703
|
+
* A ONE-CHARACTER WORD IS NOT A TWO-CHARACTER WORD. 瓦 centred on
|
|
3704
|
+
* the same anchor as 石垣 reads as sitting too far left — an eye
|
|
3705
|
+
* centres a line of type on its mass, not on its box. So a lone
|
|
3706
|
+
* glyph is nudged toward the right of frame, and which way THAT
|
|
3707
|
+
* is depends on where the lens is standing: both candidates are
|
|
3708
|
+
* projected and the one further right wins.
|
|
3709
|
+
*/
|
|
3710
|
+
const lone = (beat.mark.lines[0] ?? '').length <= 1;
|
|
3711
|
+
this.markOff = 0;
|
|
3712
|
+
if (lone && this.film) {
|
|
3713
|
+
const a = this.markAz;
|
|
3714
|
+
const at = k => this.film.project(Math.sin(a) * 26 + Math.cos(a) * k, 9, Math.cos(a) * 26 - Math.sin(a) * k).x;
|
|
3715
|
+
this.markOff = at(3.6) > at(-3.6) ? 3.6 : -3.6;
|
|
3716
|
+
}
|
|
3717
|
+
/* the entry height is taken when the word ARRIVES, not here —
|
|
3718
|
+
by then the building has grown and the word has to enter
|
|
3719
|
+
behind whatever is standing (see stageMark) */
|
|
3720
|
+
this.markSeated = false;
|
|
3721
|
+
this.markFrom = (beat.mark.to ?? 8) * 0.34 - 1.6;
|
|
3722
|
+
}
|
|
3723
|
+
this.applyAir(beat, instant);
|
|
3724
|
+
/**
|
|
3725
|
+
* THE VOICE CROSSES ON ITS OWN CLOCK. Tracks are linked by the seam
|
|
3726
|
+
* but independent across it: a beat with its own line fades the old
|
|
3727
|
+
* one under (~120ms) and speaks; a beat with none is an L-CUT — the
|
|
3728
|
+
* outgoing sentence finishes over the new shot and the duck lifts
|
|
3729
|
+
* when it lands, exactly as an editor would lay it. A designed
|
|
3730
|
+
* silence (`hush`) is the exception that asks for quiet.
|
|
3731
|
+
*/
|
|
3732
|
+
if (beat.vo || beat.hush) {
|
|
3733
|
+
this.hush();
|
|
3734
|
+
this.speak(beat);
|
|
3735
|
+
} else if (!this.sound || !this.film?.speaking()) {
|
|
3736
|
+
/* nothing is actually speaking: the silent beat gets its music */this.film?.duck(1);
|
|
3737
|
+
}
|
|
3738
|
+
this.args.onBeat?.(beat, film);
|
|
3739
|
+
}
|
|
3740
|
+
/**
|
|
3741
|
+
* THE AIR OF A SHOT: grade, sun, haze, weather. Split out of the beat
|
|
3742
|
+
* so a chapter's sky can begin turning while the lens is still flying
|
|
3743
|
+
* into it — the `air` cue fires at the head of a lead, the beat's own
|
|
3744
|
+
* cue when it lands, and applying it twice is harmless because every
|
|
3745
|
+
* one of these is a set, not a step.
|
|
3746
|
+
*/
|
|
3747
|
+
applyAir(beat, instant = false) {
|
|
3748
|
+
/* THE STILL IS A, THE LIVE IS B. One scene cannot show two shots at
|
|
3749
|
+
once, so every join that carries a still of the outgoing shot (a
|
|
3750
|
+
cut, a wipe, a blend, a melt, a dip) needs the incoming shot to be
|
|
3751
|
+
ENTIRELY itself on its first live frame — hour, weather, grade
|
|
3752
|
+
snapped, not eased. Otherwise the sweep reveals the same building
|
|
3753
|
+
still wearing the last shot's light, and the seam reads as the
|
|
3754
|
+
same picture twice. Only a join with no still (a whip, a plain
|
|
3755
|
+
glide) gets to ease. */
|
|
3756
|
+
const hard = instant || !!beat.cut || beat.join !== undefined && this.holdsStill(beat.join);
|
|
3757
|
+
const film = this.film;
|
|
3758
|
+
if (!film) {
|
|
3759
|
+
return;
|
|
3760
|
+
}
|
|
3761
|
+
if (beat.theme !== undefined) {
|
|
3762
|
+
film.theme(beat.theme, hard);
|
|
3763
|
+
}
|
|
3764
|
+
/**
|
|
3765
|
+
* THE SUN MOVES, it does not switch on. Both sun and haze are shot
|
|
3766
|
+
* properties, released when a beat does not ask — otherwise one
|
|
3767
|
+
* raking close-up lights the rest of the film. But a beat that DOES
|
|
3768
|
+
* ask used to get its light in a single frame, which in the
|
|
3769
|
+
* construction chapter (side light at twelve degrees, then sixty,
|
|
3770
|
+
* then near-overhead) read as somebody flipping a switch. The goal
|
|
3771
|
+
* is set here and the frame loop walks the light to it.
|
|
3772
|
+
*/
|
|
3773
|
+
const want = beat.sun ? {
|
|
3774
|
+
az: beat.sun.az * RAD,
|
|
3775
|
+
el: beat.sun.el * RAD
|
|
3776
|
+
} : null;
|
|
3777
|
+
this.sunFrom = instant ? null : this.sunGoal;
|
|
3778
|
+
this.sunGoal = want;
|
|
3779
|
+
if (hard || !this.sunNow || !want) {
|
|
3780
|
+
this.sunNow = want ? {
|
|
3781
|
+
...want
|
|
3782
|
+
} : null;
|
|
3783
|
+
film.light(this.sunNow);
|
|
3784
|
+
}
|
|
3785
|
+
this.struck = '';
|
|
3786
|
+
this.hourStep = -1;
|
|
3787
|
+
this.gradeSnap = hard;
|
|
3788
|
+
if (!beat.hours) {
|
|
3789
|
+
film.themeDur(null);
|
|
3790
|
+
}
|
|
3791
|
+
this.hazeBase = beat.haze ?? null;
|
|
3792
|
+
film.haze(this.hazeBase);
|
|
3793
|
+
if (beat.wx !== undefined) {
|
|
3794
|
+
film.wx(beat.wx, hard || !!beat.wxCut);
|
|
3795
|
+
}
|
|
3796
|
+
if (beat.winter !== undefined) {
|
|
3797
|
+
film.winter(beat.winter);
|
|
3798
|
+
}
|
|
3799
|
+
film.mix(beat.mix ?? null);
|
|
3800
|
+
film.tag(Array.isArray(beat.build) && beat.build[1] > beat.build[0]);
|
|
3801
|
+
/* the weather preset draws rain for the page's own wide shot; a beat
|
|
3802
|
+
that is ABOUT the rain asks for more of it (Beat.rain) */
|
|
3803
|
+
film.rain(beat.rain ?? null);
|
|
3804
|
+
film.rim(beat.rim ?? null, true);
|
|
3805
|
+
film.quality?.(beat.quality ?? null);
|
|
3806
|
+
film.grass?.(beat.grass ?? false);
|
|
3807
|
+
if (beat.theme !== undefined) {
|
|
3808
|
+
this.hourNow = beat.theme;
|
|
3809
|
+
}
|
|
3810
|
+
/* by night the frosted city goes nearly under: the church is the lit thing */
|
|
3811
|
+
film.city?.(beat.city ?? 'glass', this.hourNow === 3 ? 0.08 : this.cityGlass);
|
|
3812
|
+
this.args.onAir?.(beat, film, hard);
|
|
3813
|
+
}
|
|
3814
|
+
/**
|
|
3815
|
+
* THE HOUR A CUT INHERITS.
|
|
3816
|
+
*
|
|
3817
|
+
* Theme and weather are stated where they CHANGE, so a beat halfway
|
|
3818
|
+
* through the film usually says nothing about either — which is right
|
|
3819
|
+
* for a film played from the top and wrong for one dropped into. A
|
|
3820
|
+
* deep link (or a chapter skip) would open in whatever sky was last
|
|
3821
|
+
* set, which since the front door became an evening meant the
|
|
3822
|
+
* construction chapter got built at dusk. So a cut resolves its own
|
|
3823
|
+
* hour first: walk back to the last beat that named one, and take it.
|
|
3824
|
+
*/
|
|
3825
|
+
settleAir(index) {
|
|
3826
|
+
const film = this.film;
|
|
3827
|
+
if (!film) {
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
for (let i = index; i >= 0; i -= 1) {
|
|
3831
|
+
const t = this.all[i]?.theme;
|
|
3832
|
+
if (t !== undefined) {
|
|
3833
|
+
film.theme(t, true);
|
|
3834
|
+
break;
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3837
|
+
for (let i = index; i >= 0; i -= 1) {
|
|
3838
|
+
const w = this.all[i]?.wx;
|
|
3839
|
+
if (w !== undefined) {
|
|
3840
|
+
film.wx(w, true);
|
|
3841
|
+
break;
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
/* the snow a cut inherits — and a jump to before the first winter
|
|
3845
|
+
beat must land on bare ground, not on whatever was last pinned */
|
|
3846
|
+
let winter = null;
|
|
3847
|
+
for (let i = index; i >= 0; i -= 1) {
|
|
3848
|
+
if (this.all[i]?.winter !== undefined) {
|
|
3849
|
+
winter = this.all[i].winter;
|
|
3850
|
+
break;
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
film.winter(winter ?? null);
|
|
3854
|
+
}
|
|
3855
|
+
shot = state => {
|
|
3856
|
+
this.goal = {
|
|
3857
|
+
dolly: state.dolly,
|
|
3858
|
+
fx: state.look?.x ?? 0,
|
|
3859
|
+
fz: state.look?.z ?? 0,
|
|
3860
|
+
lookY: state.look?.y ?? 0,
|
|
3861
|
+
ox: state.x,
|
|
3862
|
+
pitch: state.pitch,
|
|
3863
|
+
yaw: state.yaw
|
|
3864
|
+
};
|
|
3865
|
+
};
|
|
3866
|
+
dispatch = command => {
|
|
3867
|
+
if (command.action === 'lap') {
|
|
3868
|
+
/* the last cue has run. Looping past your own ending is how a film
|
|
3869
|
+
tells the viewer it never meant any of it. */
|
|
3870
|
+
this.end();
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
if (command.action === 'air') {
|
|
3874
|
+
const i = Number(command.target);
|
|
3875
|
+
if (Number.isFinite(i) && this.beats[i]) {
|
|
3876
|
+
this.applyAir(this.beats[i]);
|
|
3877
|
+
}
|
|
3878
|
+
return;
|
|
3879
|
+
}
|
|
3880
|
+
if (command.action === 'beat') {
|
|
3881
|
+
const i = Number(command.target);
|
|
3882
|
+
/* THE HEAD BEAT IS APPLIED ONCE. A cut applies its beat itself, so
|
|
3883
|
+
the picture is right before the run exists; the run's first cue
|
|
3884
|
+
then names the same beat a pass later. Applying it again ran
|
|
3885
|
+
the join twice — and the second wipe snapshotted a lens that
|
|
3886
|
+
had already moved, so the sweep revealed the shot it covered. */
|
|
3887
|
+
if (i === 0 && this.primedLap === this.lap) {
|
|
3888
|
+
this.primedLap = -1;
|
|
3889
|
+
this.beatIndex = 0;
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
if (Number.isFinite(i) && this.beats[i]) {
|
|
3893
|
+
this.beatIndex = i;
|
|
3894
|
+
this.applyBeat(this.beats[i]);
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
};
|
|
3898
|
+
/**
|
|
3899
|
+
* PAUSE MEANS EVERYTHING STOPS. The score's run holds where it is (the
|
|
3900
|
+
* lens included), the page's own clock holds (grass, weather, the
|
|
3901
|
+
* build, the day), the audio graph suspends, and the beat clock is
|
|
3902
|
+
* shifted forward by the length of the hold on resume so the hours,
|
|
3903
|
+
* the build and the type pick up exactly where they stood. Tearing the
|
|
3904
|
+
* Sequence down was the old pause, and it re-ran the chapter on play.
|
|
3905
|
+
* An exact film's runs are already standing — its clock is the only
|
|
3906
|
+
* thing that has to hold.
|
|
3907
|
+
*/
|
|
3908
|
+
toggle = () => {
|
|
3909
|
+
if (!this.playing) {
|
|
3910
|
+
return;
|
|
3911
|
+
}
|
|
3912
|
+
this.paused = !this.paused;
|
|
3913
|
+
this.pop(this.paused ? 'pause' : 'play');
|
|
3914
|
+
if (!this.exact) {
|
|
3915
|
+
const run = this.scoreCtx?.run ?? null;
|
|
3916
|
+
if (this.paused) {
|
|
3917
|
+
this.pausedAt = performance.now();
|
|
3918
|
+
run?.pause();
|
|
3919
|
+
} else {
|
|
3920
|
+
this.beatAt += performance.now() - this.pausedAt;
|
|
3921
|
+
run?.play();
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
this.film?.hold?.(this.paused);
|
|
3925
|
+
this.film?.pause(this.paused);
|
|
3926
|
+
};
|
|
3927
|
+
/** any re-cut or restart clears a hold: the new run starts rolling */
|
|
3928
|
+
unhold() {
|
|
3929
|
+
if (this.paused) {
|
|
3930
|
+
this.paused = false;
|
|
3931
|
+
this.film?.hold?.(false);
|
|
3932
|
+
this.film?.pause(false);
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
/**
|
|
3936
|
+
* SKIP A CHAPTER — by re-cutting, never by seeking.
|
|
3937
|
+
*
|
|
3938
|
+
* `from` moves to a chapter head, which changes the beat list, which
|
|
3939
|
+
* changes the path, the cues and the sequence's name all at once. The
|
|
3940
|
+
* region sees a different score and plays it from its head. Seeking the
|
|
3941
|
+
* existing run would be the obvious alternative and it would be wrong:
|
|
3942
|
+
* the lens is an integrator whose pose depends on where it has been, so
|
|
3943
|
+
* a run dropped into the middle of itself arrives with the wrong
|
|
3944
|
+
* velocity — the same reason `notes/sylva-one-world.md` calls the chaser
|
|
3945
|
+
* seek-unsafe and means it.
|
|
3946
|
+
*/
|
|
3947
|
+
goChapter = delta => {
|
|
3948
|
+
const heads = this.chapterHeads;
|
|
3949
|
+
const here = this.absoluteIndex;
|
|
3950
|
+
let i = heads.findIndex((h, n) => here >= h && (heads[n + 1] ?? Infinity) > here);
|
|
3951
|
+
if (i < 0) {
|
|
3952
|
+
i = 0;
|
|
3953
|
+
}
|
|
3954
|
+
/* back, from more than a moment into a chapter, means this chapter's
|
|
3955
|
+
head — the behaviour every transport control in the world has */
|
|
3956
|
+
const restart = delta < 0 && here > heads[i];
|
|
3957
|
+
const next = restart ? i : Math.max(0, Math.min(heads.length - 1, i + delta));
|
|
3958
|
+
this.cutTo(heads[next]);
|
|
3959
|
+
};
|
|
3960
|
+
cutTo(index, hard = false) {
|
|
3961
|
+
this.unhold();
|
|
3962
|
+
if (this.exact) {
|
|
3963
|
+
this.seek(this.beatStart(index));
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
this.from = index;
|
|
3967
|
+
this.beatIndex = 0;
|
|
3968
|
+
this.ended = false;
|
|
3969
|
+
this.playing = true;
|
|
3970
|
+
this.lap += 1;
|
|
3971
|
+
this.primedLap = this.lap;
|
|
3972
|
+
this.settleAir(index);
|
|
3973
|
+
this.applyBeat(this.all[index], true);
|
|
3974
|
+
/* the re-cut score opens on a spliced first waypoint, so the goal
|
|
3975
|
+
steps straight to this pose; the snap lands the lens beside it,
|
|
3976
|
+
already carrying that shot's own speed */
|
|
3977
|
+
this.snap(this.all[index].cam, hard ? undefined : this.tailFor(0), hard ? undefined : this.all[index].ticks * TICK);
|
|
3978
|
+
}
|
|
3979
|
+
/** an uncaught error anywhere becomes a visible line — a film that
|
|
3980
|
+
* dies silently mid-reel cannot be debugged from a chair */
|
|
3981
|
+
trip = e => {
|
|
3982
|
+
if (this.fault) {
|
|
3983
|
+
return;
|
|
3984
|
+
}
|
|
3985
|
+
const err = e;
|
|
3986
|
+
this.fault = String(err.message ?? err.reason ?? 'unknown fault').slice(0, 200);
|
|
3987
|
+
};
|
|
3988
|
+
prev = () => this.goChapter(-1);
|
|
3989
|
+
next = () => this.goChapter(1);
|
|
3990
|
+
key = e => {
|
|
3991
|
+
if (e.metaKey || e.ctrlKey || e.altKey) {
|
|
3992
|
+
return;
|
|
3993
|
+
}
|
|
3994
|
+
/* at the door, one key opens it — with the sound the film was made
|
|
3995
|
+
with, since a keypress is as much a gesture as a click */
|
|
3996
|
+
if (this.gate) {
|
|
3997
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
3998
|
+
e.preventDefault();
|
|
3999
|
+
this.begin(true);
|
|
4000
|
+
}
|
|
4001
|
+
return;
|
|
4002
|
+
}
|
|
4003
|
+
if (this.ended && (e.key === ' ' || e.key === 'Enter')) {
|
|
4004
|
+
e.preventDefault();
|
|
4005
|
+
this.replay();
|
|
4006
|
+
return;
|
|
4007
|
+
}
|
|
4008
|
+
if (e.key === 'ArrowRight') {
|
|
4009
|
+
e.preventDefault();
|
|
4010
|
+
this.next();
|
|
4011
|
+
} else if (e.key === 'ArrowLeft') {
|
|
4012
|
+
e.preventDefault();
|
|
4013
|
+
this.prev();
|
|
4014
|
+
} else if (e.key === ' ' || e.key === 'k' || e.key === 'K') {
|
|
4015
|
+
e.preventDefault();
|
|
4016
|
+
this.toggle();
|
|
4017
|
+
} else if (e.key === 'f' || e.key === 'F') {
|
|
4018
|
+
this.screen();
|
|
4019
|
+
} else if (e.key >= '1' && e.key <= '9') {
|
|
4020
|
+
const head = this.chapterHeads[Number(e.key) - 1];
|
|
4021
|
+
if (head !== undefined) {
|
|
4022
|
+
this.pick(head);
|
|
4023
|
+
}
|
|
4024
|
+
} else if (e.key === '0' || e.key === 'Home') {
|
|
4025
|
+
this.restart();
|
|
4026
|
+
} else if (e.key === 'm' || e.key === 'M') {
|
|
4027
|
+
this.hear();
|
|
4028
|
+
} else if (e.key === 'c' || e.key === 'C' || e.key === 'Escape') {
|
|
4029
|
+
this.toc();
|
|
4030
|
+
} else if (e.key === 'v' || e.key === 'V') {
|
|
4031
|
+
this.cc();
|
|
4032
|
+
}
|
|
4033
|
+
};
|
|
4034
|
+
/**
|
|
4035
|
+
* THE NARRATION, one file per beat.
|
|
4036
|
+
*
|
|
4037
|
+
* `assets/vo/<id>.mp3`, played on the beat's entrance and stopped
|
|
4038
|
+
* when the beat changes. Per beat rather than one long track because
|
|
4039
|
+
* chapter skip RE-CUTS the film: a single track would have to be sought,
|
|
4040
|
+
* and a sought track against a re-cut score drifts inside a chapter.
|
|
4041
|
+
*
|
|
4042
|
+
* A missing file is silence, not an error. The film ships before the
|
|
4043
|
+
* voice does, and the score has to be right either way. While a line
|
|
4044
|
+
* plays the scene's own music ducks under it, which is the one piece of
|
|
4045
|
+
* mixing that cannot wait for a mix.
|
|
4046
|
+
*/
|
|
4047
|
+
voSrc(beat) {
|
|
4048
|
+
return `${this.assets}vo/${beat.id}.mp3`;
|
|
4049
|
+
}
|
|
4050
|
+
/** ask the page to fetch and decode the line after this one, now */
|
|
4051
|
+
primeNext(beat) {
|
|
4052
|
+
const next = this.all[this.all.indexOf(beat) + 1];
|
|
4053
|
+
if (next?.vo) {
|
|
4054
|
+
this.film?.voicePrime(this.voSrc(next));
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
/**
|
|
4058
|
+
* A BOUNDARY NEVER CLIPS THE VOICE — and every line has its own
|
|
4059
|
+
* throat. One shared element made politeness impossible: however
|
|
4060
|
+
* gently the old line was being faded, the new line's src swap
|
|
4061
|
+
* guillotined it mid-word. So an interrupted line keeps its OWN
|
|
4062
|
+
* element and fades there (~120ms, fast enough to read as a stop,
|
|
4063
|
+
* slow enough that no waveform is cut mid-cycle) while the new line
|
|
4064
|
+
* starts clean on a fresh one — the game-dialogue barge-in, which is
|
|
4065
|
+
* what a chapter skip actually is. ("Stop at the next word" is the
|
|
4066
|
+
* refinement this is built to take: an analyser watching for the
|
|
4067
|
+
* inter-word trough before the fade — see docs/choreo-splices.md.)
|
|
4068
|
+
*/ /**
|
|
4069
|
+
* A JOIN, EXHIBITED. Any seam as a pure overlay on whatever is playing —
|
|
4070
|
+
* no beat change, no snap: the transition itself, on the living
|
|
4071
|
+
* picture. What a cutting room's wall plate triggers.
|
|
4072
|
+
*/
|
|
4073
|
+
preview = join => {
|
|
4074
|
+
const film = this.film;
|
|
4075
|
+
if (!film || !this.booted) {
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
if ((join === 'blend' || join === 'melt') && film.dissolve(join)) {
|
|
4079
|
+
this.freeze = '';
|
|
4080
|
+
return;
|
|
4081
|
+
}
|
|
4082
|
+
if (join !== 'flash' && join !== 'sweep' && join !== 'whip') {
|
|
4083
|
+
const shot = film.snapshot();
|
|
4084
|
+
this.freeze = shot.length > 64 ? shot : '';
|
|
4085
|
+
if (!this.freeze && join !== 'dip') {
|
|
4086
|
+
return;
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
switch (join) {
|
|
4090
|
+
case 'defocus':
|
|
4091
|
+
this.play('defocus');
|
|
4092
|
+
this.liveEl?.animate([{
|
|
4093
|
+
filter: 'blur(9px)'
|
|
4094
|
+
}, {
|
|
4095
|
+
filter: 'blur(0px)'
|
|
4096
|
+
}], {
|
|
4097
|
+
duration: 700,
|
|
4098
|
+
easing: 'cubic-bezier(0.3, 0, 0.3, 1)'
|
|
4099
|
+
});
|
|
4100
|
+
break;
|
|
4101
|
+
case 'dip':
|
|
4102
|
+
this.dipColor = '#0d0905';
|
|
4103
|
+
this.play('dip');
|
|
4104
|
+
break;
|
|
4105
|
+
case 'iris':
|
|
4106
|
+
{
|
|
4107
|
+
const v = film.view();
|
|
4108
|
+
const b = this.beat;
|
|
4109
|
+
const pt = b.to ? film.project(...b.to) : undefined;
|
|
4110
|
+
const cx = pt ? Math.max(12, Math.min(88, pt.x / v.w * 100)) : 50;
|
|
4111
|
+
const cy = pt ? Math.max(12, Math.min(88, pt.y / v.h * 100)) : 46;
|
|
4112
|
+
this.irisAt = `--ix:${cx.toFixed(1)}%;--iy:${cy.toFixed(1)}%`;
|
|
4113
|
+
this.play('iris');
|
|
4114
|
+
break;
|
|
4115
|
+
}
|
|
4116
|
+
case 'sweep':
|
|
4117
|
+
this.lightSweep(film, this.beat);
|
|
4118
|
+
break;
|
|
4119
|
+
case 'whip':
|
|
4120
|
+
this.whipUntil = performance.now() + 360;
|
|
4121
|
+
break;
|
|
4122
|
+
case 'cut':
|
|
4123
|
+
break;
|
|
4124
|
+
default:
|
|
4125
|
+
this.play(join);
|
|
4126
|
+
}
|
|
4127
|
+
};
|
|
4128
|
+
/** the wrapper the live picture sits in, for rack-defocus */
|
|
4129
|
+
liveEl;
|
|
4130
|
+
liveWrap = modifier(el => {
|
|
4131
|
+
this.liveEl = el;
|
|
4132
|
+
return () => {
|
|
4133
|
+
if (this.liveEl === el) {
|
|
4134
|
+
this.liveEl = undefined;
|
|
4135
|
+
}
|
|
4136
|
+
};
|
|
4137
|
+
});
|
|
4138
|
+
/**
|
|
4139
|
+
* THE LIGHT SWEEP: the sun itself flares across the seam — swings
|
|
4140
|
+
* high and past, then settles back onto whatever light the beat
|
|
4141
|
+
* actually asked for. Fire-and-forget; the restore hands the key
|
|
4142
|
+
* back to the beat's own sun (or the hour's).
|
|
4143
|
+
*/
|
|
4144
|
+
lightSweep(film, beat) {
|
|
4145
|
+
const t0 = performance.now();
|
|
4146
|
+
const dur = 950;
|
|
4147
|
+
const base = beat.sun ? {
|
|
4148
|
+
az: beat.sun.az * RAD,
|
|
4149
|
+
el: beat.sun.el * RAD
|
|
4150
|
+
} : null;
|
|
4151
|
+
const step = () => {
|
|
4152
|
+
const f = (performance.now() - t0) / dur;
|
|
4153
|
+
if (f >= 1 || !this.film) {
|
|
4154
|
+
film.light(base);
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
4157
|
+
const swing = Math.sin(f * Math.PI);
|
|
4158
|
+
film.light({
|
|
4159
|
+
az: (base?.az ?? 0.4) + (1 - f) * 2.2 - 1.1,
|
|
4160
|
+
el: (base?.el ?? 0.4) + swing * 0.3
|
|
4161
|
+
});
|
|
4162
|
+
requestAnimationFrame(step);
|
|
4163
|
+
};
|
|
4164
|
+
requestAnimationFrame(step);
|
|
4165
|
+
}
|
|
4166
|
+
/** the scene brought its own score — six of them, one per tower */ /** the line for this beat, at the level the session was mixed to */
|
|
4167
|
+
speak(beat) {
|
|
4168
|
+
this.film?.voice(this.voSrc(beat), this.voGain[beat.id] ?? 1);
|
|
4169
|
+
this.primeNext(beat);
|
|
4170
|
+
}
|
|
4171
|
+
hush() {
|
|
4172
|
+
this.film?.voiceStop();
|
|
4173
|
+
}
|
|
4174
|
+
hear = () => {
|
|
4175
|
+
this.sound = !this.sound;
|
|
4176
|
+
this.film?.sound(this.sound);
|
|
4177
|
+
/* turning sound on mid-beat should speak the line you are LOOKING at,
|
|
4178
|
+
not wait for the next one — otherwise the first thing anybody hears
|
|
4179
|
+
is a chapter they have already read */
|
|
4180
|
+
if (this.sound) {
|
|
4181
|
+
this.speak(this.beat);
|
|
4182
|
+
} else {
|
|
4183
|
+
this.hush();
|
|
4184
|
+
}
|
|
4185
|
+
};
|
|
4186
|
+
/**
|
|
4187
|
+
* FROM THE TOP means the front door, not the first beat.
|
|
4188
|
+
*
|
|
4189
|
+
* A film restarted straight into its opening shot skips the one piece
|
|
4190
|
+
* of the picture that says what it is — and the poster is a
|
|
4191
|
+
* composition in its own right, with the building turning in the last
|
|
4192
|
+
* of the light. So this clears everything the run has done and puts
|
|
4193
|
+
* the door back up, with the lens seated where it opens.
|
|
4194
|
+
*/
|
|
4195
|
+
restart = () => {
|
|
4196
|
+
const film = this.film;
|
|
4197
|
+
this.unhold();
|
|
4198
|
+
this.freeze = '';
|
|
4199
|
+
this.film?.voiceStop(120);
|
|
4200
|
+
this.playing = false;
|
|
4201
|
+
this.ended = false;
|
|
4202
|
+
this.menu = false;
|
|
4203
|
+
this.from = 0;
|
|
4204
|
+
this.beatIndex = 0;
|
|
4205
|
+
this.markSeated = false;
|
|
4206
|
+
this.skyOn = 0;
|
|
4207
|
+
this.skyA = 0;
|
|
4208
|
+
this.dimNow = 0;
|
|
4209
|
+
this.sent = undefined;
|
|
4210
|
+
this.t = 0;
|
|
4211
|
+
this.jumped = false;
|
|
4212
|
+
this.seamAt = -1;
|
|
4213
|
+
this.endSeam();
|
|
4214
|
+
this.airedFor = -1;
|
|
4215
|
+
this.scoreRun = null;
|
|
4216
|
+
this.clipNow.clear();
|
|
4217
|
+
this.clipRows = [];
|
|
4218
|
+
this.clipFreezes.clear();
|
|
4219
|
+
if (film) {
|
|
4220
|
+
film.modelFade(1);
|
|
4221
|
+
film.rain(null);
|
|
4222
|
+
film.rim(null);
|
|
4223
|
+
film.fade('mark', 0);
|
|
4224
|
+
film.fade('chapter', 0);
|
|
4225
|
+
for (let i = 0; i < 4; i++) {
|
|
4226
|
+
film.clearTrace(`t${i}`);
|
|
4227
|
+
}
|
|
4228
|
+
film.duck(1);
|
|
4229
|
+
/* the end card idled the page (nothing under it changes); the door
|
|
4230
|
+
needs it drawing again, or the poster is the ending's last frame */
|
|
4231
|
+
film.idle(false);
|
|
4232
|
+
this.idled = false;
|
|
4233
|
+
film.time(this.standing);
|
|
4234
|
+
this.picture?.seat?.(film);
|
|
4235
|
+
this.dressPoster(film);
|
|
4236
|
+
this.snap((this.all[0] ?? EMPTY_BEAT).cam);
|
|
4237
|
+
}
|
|
4238
|
+
/* the run only exists while the film is booted; taking that away
|
|
4239
|
+
ends it, and the door decides when the next one starts */
|
|
4240
|
+
this.booted = false;
|
|
4241
|
+
this.rolling = false;
|
|
4242
|
+
this.gate = true;
|
|
4243
|
+
this.posterAt = 0;
|
|
4244
|
+
this.posterRaf ??= requestAnimationFrame(this.poster);
|
|
4245
|
+
};
|
|
4246
|
+
/** a choice made at the door before the scene finished loading */
|
|
4247
|
+
wanted;
|
|
4248
|
+
/** through the gate — on the click the audio policy was waiting for */
|
|
4249
|
+
begin = withSound => {
|
|
4250
|
+
if (this.booted) {
|
|
4251
|
+
/* a gate over a film already running is a stale door, not a
|
|
4252
|
+
request to boot twice — step aside and honour the sound choice */
|
|
4253
|
+
this.gate = false;
|
|
4254
|
+
if (withSound !== this.sound) {
|
|
4255
|
+
this.hear();
|
|
4256
|
+
}
|
|
4257
|
+
return;
|
|
4258
|
+
}
|
|
4259
|
+
if (!this.film) {
|
|
4260
|
+
/* the scene is still arriving: keep the choice, not the click */this.wanted = withSound;
|
|
4261
|
+
return;
|
|
4262
|
+
}
|
|
4263
|
+
this.gate = false;
|
|
4264
|
+
if (this.posterRaf !== undefined) {
|
|
4265
|
+
cancelAnimationFrame(this.posterRaf);
|
|
4266
|
+
this.posterRaf = undefined;
|
|
4267
|
+
}
|
|
4268
|
+
/* the door's choice is the page's state too — a muted begin that
|
|
4269
|
+
leaves the page's own switch on plays every hit and bed under a
|
|
4270
|
+
viewer who asked for silence */
|
|
4271
|
+
this.sound = withSound;
|
|
4272
|
+
this.film.sound(withSound);
|
|
4273
|
+
if (this.exact) {
|
|
4274
|
+
/* the clock opens at the linked beat's head; the door's flight is
|
|
4275
|
+
the chaser's and an exact film cuts in instead */
|
|
4276
|
+
const head = this.from;
|
|
4277
|
+
this.from = 0;
|
|
4278
|
+
this.t = this.beatStart(head);
|
|
4279
|
+
this.beatIndex = head;
|
|
4280
|
+
this.jumped = false;
|
|
4281
|
+
this.seamAt = -1;
|
|
4282
|
+
this.endSeam();
|
|
4283
|
+
this.airedFor = -1;
|
|
4284
|
+
}
|
|
4285
|
+
const first = this.exact ? this.all[this.beatIndex] : this.beats[0] ?? EMPTY_BEAT;
|
|
4286
|
+
this.settleAir(this.exact ? this.beatIndex : this.from);
|
|
4287
|
+
this.applyBeat(first, true);
|
|
4288
|
+
/* ...and the sky comes with it, NOT instantly: the evening of the
|
|
4289
|
+
poster crossfades into the film's own morning across the launch */
|
|
4290
|
+
this.applyAir(first, false);
|
|
4291
|
+
/**
|
|
4292
|
+
* THE CLICK IS ANSWERED IN THE SAME FRAME — and answered with the
|
|
4293
|
+
* biggest move in the film. The lens does not cut to the opening
|
|
4294
|
+
* shot; it FLIES there from wherever the poster's circuit had
|
|
4295
|
+
* reached, on the stiff spring, so the first thing the viewer sees
|
|
4296
|
+
* after pressing the button is the camera taking off. A door that
|
|
4297
|
+
* opens onto a still frame reads as a page that did not hear you.
|
|
4298
|
+
*/
|
|
4299
|
+
if (this.posterPose && !this.exact) {
|
|
4300
|
+
this.now = {
|
|
4301
|
+
...this.posterPose
|
|
4302
|
+
};
|
|
4303
|
+
this.mid = {
|
|
4304
|
+
...this.posterPose
|
|
4305
|
+
};
|
|
4306
|
+
this.goal = {
|
|
4307
|
+
...(this.beats[0] ?? EMPTY_BEAT).cam
|
|
4308
|
+
};
|
|
4309
|
+
this.whipUntil = performance.now() + 1200;
|
|
4310
|
+
for (const k of ['dolly', 'fx', 'fz', 'lookY', 'ox', 'pitch', 'yaw']) {
|
|
4311
|
+
this.midV[k] = 0;
|
|
4312
|
+
this.nowV[k] = 0;
|
|
4313
|
+
}
|
|
4314
|
+
} else {
|
|
4315
|
+
this.snap(first.cam, this.tailFor(0), first.ticks * TICK);
|
|
4316
|
+
}
|
|
4317
|
+
/* THE DOOR STARTS THE SCORE. `playing` gates the Sequence, and a
|
|
4318
|
+
restart leaves it false so the run ends with the door going up —
|
|
4319
|
+
so the door has to turn it back on, or the second viewing stands
|
|
4320
|
+
at its first pose forever with a play button that cannot help. */
|
|
4321
|
+
this.playing = true;
|
|
4322
|
+
this.paused = false;
|
|
4323
|
+
this.booted = true;
|
|
4324
|
+
this.wake();
|
|
4325
|
+
this.lastTick = performance.now();
|
|
4326
|
+
this.raf = requestAnimationFrame(this.frame);
|
|
4327
|
+
/**
|
|
4328
|
+
* ONE MORE PASS, on purpose. A region's first render collects no
|
|
4329
|
+
* score — `firstRender` compiles an empty tree — so the run only
|
|
4330
|
+
* exists after a SECOND pass, and that pass has to be paid for by a
|
|
4331
|
+
* tracked write somewhere. Every cut so far happened to buy one
|
|
4332
|
+
* within a second (a cue firing, a cycle stepping); a cut whose head
|
|
4333
|
+
* beat writes nothing — one quiet beat, deep-linked — never did, and
|
|
4334
|
+
* the film stood at its first pose forever. Bumping the lap edits
|
|
4335
|
+
* the sequence's name, which is a real edit the region must collect.
|
|
4336
|
+
*/
|
|
4337
|
+
window.setTimeout(() => {
|
|
4338
|
+
this.lap += 1;
|
|
4339
|
+
this.primedLap = this.lap;
|
|
4340
|
+
this.rolling = true;
|
|
4341
|
+
}, 0);
|
|
4342
|
+
};
|
|
4343
|
+
/** the whole film's running time, said the way a poster says it */
|
|
4344
|
+
get runtime() {
|
|
4345
|
+
const s = this.all.reduce((n, b) => n + b.ticks, 0) * TICK;
|
|
4346
|
+
return `${Math.floor(s / 60)} min ${s % 60 ? `${s % 60} s` : ''}`.trim();
|
|
4347
|
+
}
|
|
4348
|
+
/**
|
|
4349
|
+
* BACK IN FROM THE END CARD — a dissolve, not a flight.
|
|
4350
|
+
*
|
|
4351
|
+
* The film ends three hundred degrees around the building from where
|
|
4352
|
+
* it opened, so tweening the lens home spins it like a globe. The
|
|
4353
|
+
* ending cross-fades into the opening frame instead: the last frame
|
|
4354
|
+
* is held as a still, the camera is PLACED at the opening pose behind
|
|
4355
|
+
* it, and the still fades away.
|
|
4356
|
+
*/ /**
|
|
4357
|
+
* BACK IN FROM THE END CARD — a hard reset, not a journey.
|
|
4358
|
+
*
|
|
4359
|
+
* The film ends three hundred degrees around the building, with the
|
|
4360
|
+
* tower dissolved, the sky at golden hour, the rain gone and a chapter
|
|
4361
|
+
* numeral on screen. Tweening any of that back to the opening is a
|
|
4362
|
+
* confusing ten seconds in which nothing is either the ending or the
|
|
4363
|
+
* beginning. So the ending is CLEARED: every overlay, the model, the
|
|
4364
|
+
* weather, the world type, the traces and the lens all go back to
|
|
4365
|
+
* their opening state in one frame, and the film starts.
|
|
4366
|
+
*/ /** the end card's WATCH AGAIN is the front door, not the first beat: the
|
|
4367
|
+
* choice of sound is made there, every time */
|
|
4368
|
+
replay = () => {
|
|
4369
|
+
this.restart();
|
|
4370
|
+
};
|
|
4371
|
+
/** the chapter menu. The film keeps running behind it, blurred: a
|
|
4372
|
+
* menu that freezes the picture makes the picture feel like a file, and
|
|
4373
|
+
* this one is meant to feel like a broadcast you are stepping around in */
|
|
4374
|
+
static {
|
|
4375
|
+
g(this.prototype, "menu", [tracked], function () {
|
|
4376
|
+
return false;
|
|
4377
|
+
});
|
|
4378
|
+
}
|
|
4379
|
+
#menu = (i(this, "menu"), void 0);
|
|
4380
|
+
toc = () => {
|
|
4381
|
+
this.menu = !this.menu;
|
|
4382
|
+
};
|
|
4383
|
+
/** every chapter, with the beat it starts at and whether we are in it */
|
|
4384
|
+
get contents() {
|
|
4385
|
+
const here = this.absoluteIndex;
|
|
4386
|
+
const heads = this.chapterHeads;
|
|
4387
|
+
return contents(this.all, this.chapters).map((c, i) => ({
|
|
4388
|
+
...c,
|
|
4389
|
+
here: here >= c.head && here < (heads[i + 1] ?? this.all.length)
|
|
4390
|
+
}));
|
|
4391
|
+
}
|
|
4392
|
+
pick = head => {
|
|
4393
|
+
this.menu = false;
|
|
4394
|
+
this.cutTo(head);
|
|
4395
|
+
};
|
|
4396
|
+
static {
|
|
4397
|
+
setComponentTemplate(precompileTemplate("<div class=\"cf-page is-{{@name}}\n {{if this.embed \"is-embed\"}}\n {{if this.idle \"is-idle\"}}\">\n {{!-- The stage is the film's glass: a click plays or pauses and a double\n click goes full screen, as on any video. It holds the player's\n buttons, so it can't be a button itself; the keyboard has the same\n actions (Space or K, and F) and the player has a button for each. --}}\n <div {{!-- template-lint-disable no-invalid-interactive --}} class=\"cf-stage is-grade-{{this.grade}} {{if this.subsOn \"has-subs\"}}\" {{this.mount}} {{on \"click\" this.tap}} {{on \"dblclick\" this.tapTwice}}>\n {{!-- the live picture's own wrapper, so a rack-defocus can blur the\n scene without touching the grade riding the iframe itself --}}\n <div class=\"cf-live\" {{this.liveWrap}}>\n <iframe class=\"cf-frame\" src={{this.src}} srcdoc={{this.srcdoc}} title={{this.frameTitle}} loading=\"eager\"></iframe>\n </div>\n\n {{!-- A scrim, not a box: each setting gets a soft directional lift\n under it, which reads as light falling off and not as a panel. It is\n a layer here only for a picture that cannot take it into its own\n glass \u2014 where it can, the wash is inside the seam --}}\n {{#unless this.washInGlass}}\n <div class=\"cf-scrim cf-scrim-{{this.beat.mode}}\n {{if this.beat.bare \"is-bare\"}}\" aria-hidden=\"true\"></div>\n {{/unless}}\n\n {{!-- THE TRANSITIONS: the outgoing frame, held, and whatever the\n seam does to it --}}\n <div class=\"cf-joins {{if this.joinOver \"is-over\"}}\" {{this.joinsWrap}}>\n <Joins @kind={{this.joinKind}} @stamp={{this.joinStamp}} @freeze={{this.freeze}} @irisAt={{this.irisAt}} @dipColor={{this.dipColor}} @seekable={{this.exact}} @presentations={{this.presentations}} />\n </div>\n {{!-- THE CLOUD, driven per frame from --cf-cloud \u2014 again only for a\n picture that cannot draw it in the glass --}}\n {{#unless this.cloudInGlass}}\n <div class=\"cf-cloud\" aria-hidden=\"true\"></div>\n {{/unless}}\n\n {{!-- THE DIM: a wash that comes in WITH an annotation and lifts with\n it \u2014 dimming the plate is what a lecturer does when the slide goes\n up --}}\n {{#unless this.dimInGlass}}\n <div class=\"cf-dim\" aria-hidden=\"true\" {{this.dimEl}}></div>\n {{/unless}}\n\n {{!-- the leader, the tether and the ring \u2014 what must connect DOM to\n world --}}\n <Track @wire={{this.trackEls}} />\n\n {{#if this.booted}}\n <Plate @beat={{this.beat}} @chapterN={{this.chapter.n}} @rolling={{this.rolling}} @typeAt={{this.typeAt}} @sayAt={{this.sayAt}} @glyphFit={{this.glyphFit}} @glyphTone={{this.glyphTone}} @mount={{this.plate}} />\n\n <Stamp @step={{this.cycleStep}} @word={{this.stamp}} />\n\n {{#each this.clipRows key=\"lane\" as |row|}}\n <Clip @key={{row.key}} @spec={{row.spec}} @src={{row.src}} @freeze={{row.freeze}} @state={{row.state}} @lane={{row.lane}} @mount={{this.clipEl}} />\n {{/each}}\n\n {{#if (if this.rolling this.hasPhoto false)}}\n <Insert @beat={{this.beat}} @src={{this.photoSrc}} @missing={{this.missing}} />\n {{/if}}\n\n {{#if this.subs}}\n <Captions @line={{this.beat.vo}} />\n {{/if}}\n\n {{#if this.fault}}\n <p class=\"cf-fault\">\u26A0 {{this.fault}}</p>\n {{/if}}\n\n {{#if this.railOn}}\n <Rail @chapter={{this.chapter}} @marks={{this.transport}} @labels={{this.railLabels}} @readout={{this.railReadout}} @pick={{this.pick}} />\n {{/if}}\n {{/if}}\n\n {{!-- THE SCORE. One camera step for the whole film, and one clipped\n cue per beat \u2014 so the shot list and the script are the same object,\n and the film is a pure function of one clock. It waits for the\n scene: a rig on screen since the first pass was never inserted\n into anything, and the camera step would resolve to no subject. --}}\n {{#if this.booted}}\n <Choreo @onCamera3D={{this.shot}} @onPerform={{this.dispatch}} @camera3dFrom={{this.openPose}} as |c|>\n <i class=\"cf-rig\" {{motion id=\"rig\"}} {{this.grabScore c}} aria-hidden=\"true\"></i>\n {{#if this.scoreOn}}\n <c.Sequence @name={{this.filmName}}>\n <c.Camera3D @name=\"film\" @through={{this.path}} @duration={{this.filmSeconds}} @ease=\"linear\" @settle={{this.settle}} @tension={{0.34}} />\n {{!-- the type, the insert and the clip ride the score's clock:\n one window per beat, driven, never played --}}\n {{#each this.windows as |w|}}\n <c.Attach @region={{w.region}} @at={{at \"film\"}} @delay={{w.start}} @duration={{w.length}} @end={{w.end}} @exact={{this.exact}} />\n {{/each}}\n {{!-- an exact film derives its beats from the clock; a cut\n film is told them --}}\n {{#unless this.exact}}\n {{#each this.cues as |cue|}}\n <c.Perform @at={{at \"film\"}} @delay={{cue.delay}} @action={{cue.action}} @target={{cue.index}} />\n {{/each}}\n <c.Perform @action=\"lap\" />\n {{/unless}}\n </c.Sequence>\n {{/if}}\n </Choreo>\n {{/if}}\n\n {{#if this.gate}}\n <Gate @film={{this.handle}} as |f|>\n {{yield f to=\"gate\"}}\n </Gate>\n {{/if}}\n\n {{#if this.ended}}\n <EndCard @film={{this.handle}} as |f|>\n {{yield f to=\"end\"}}\n </EndCard>\n {{/if}}\n </div>\n\n {{#if this.menu}}\n <Menu @title={{@menuTitle}} @sub={{if @menuSub @menuSub \"chapters\"}} @contents={{this.contents}} @pick={{this.pick}} />\n {{/if}}\n\n {{!-- the confirmation glyph: a play or a pause, once, in the middle\n of the picture, gone in half a second --}}\n {{#if this.burst}}\n <Burst @kind={{this.burst}} @done={{this.burstDone}} />\n {{/if}}\n\n <Player @idle={{this.idle}} @away={{this.gate}} @live={{this.live}} @sound={{this.sound}} @loud={{this.loud}} @vol={{this.vol}} @volStyle={{this.volStyle}} @fade={{this.fade}} @full={{this.full}} @clock={{this.clock}} @duration={{this.duration}} @chapter={{this.chapter}} @subsOn={{this.subsOn}} @menu={{this.menu}} @debug={{this.debug}} @build={{this.build}} @playbar={{this.playbar}} @scrubAt={{this.scrubAt}} @tip={{this.tip}} @slider={{this.slider}} @trackWrap={{this.trackWrap}} @scrubDown={{this.scrubDown}} @scrubMove={{this.scrubMove}} @scrubUp={{this.scrubUp}} @scrubLeave={{this.scrubLeave}} @toggle={{this.toggle}} @prev={{this.prev}} @next={{this.next}} @hear={{this.hear}} @cc={{this.cc}} @toc={{this.toc}} @restart={{this.restart}} @screen={{this.screen}} />\n\n {{!-- under the stage: whatever the film puts beneath its own picture \u2014\n and its SCORE, when the film is written as a graph: the markers\n compile to the table the engine runs --}}\n {{yield this.takePicture to=\"picture\"}}\n <FilmGraph @onCompile={{this.takeGraph}}>\n {{yield this.handle}}\n </FilmGraph>\n</div>\n\n<style>\n /* THE FLAG, as an accent system. Two colours and nothing else:\n the red of the hinomaru for the one thing that is happening NOW\n \u2014 the playhead, the current chapter, the seal, the point a line\n is drawn to \u2014 and a full white for type that has to win over a\n picture. Everything else stays in the scene's own inks, so the\n red never becomes decoration; it only ever means \"here\". */\n .cf-page {\n --cf-red: #bc002d;\n --cf-white: #fffdf8;\n }\n\n .cf-page {\n /* the film's ink, replaced per chapter from the scene's own\n time-of-day palette by wear \u2014 these are the morning values,\n which is also what the page opens on. --cf-shx/y is the cast\n shadow's offset, written every frame from where the scene's key\n light actually is (see follow). */\n --cf-paper: #ecdcbc;\n --cf-paper-a: rgba(236, 220, 188, 0.94);\n --cf-paper-b: rgba(236, 220, 188, 0.76);\n --cf-paper-c: rgba(236, 220, 188, 0);\n --cf-ink: #2e2515;\n --cf-ink2: #8b7c5c;\n --cf-ink3: #3f3520;\n --cf-accent: #a8621f;\n /* THE ANNOTATION RED, and it is a different colour from the\n editorial accent on purpose. The accent is ink \u2014 it belongs to\n the page and takes the page's palette. This is a marker: it is\n not in the scene, it never was, and it should look like\n somebody drew on the photograph. Muted burnt orange over ochre\n moss reads as part of the picture, which is exactly what an\n annotation must not do. */\n --cf-mark: #ff2412;\n --cf-rule: #c2b18c;\n /* The sun, in two numbers: cf-rake is the angle its light makes\n across the frame, cf-shx/cf-shy the direction away from it.\n They dress the things that are PHYSICALLY on the frame \u2014 the\n photograph's own plate, the rake of a rule \u2014 and deliberately\n not the type. An offset shadow behind a headline is a sticker\n effect: it claims the letters are objects lying on the picture,\n which they are not, and getting the angle right does not rescue\n it. Type here earns its contrast from the scrim. */\n /* TYPE. The display face is a serif on purpose. This film is set\n beside mincho kanji and stands in front of a building, and a\n wide-tracked geometric sans fights both \u2014 it is also the first\n thing every deck reaches for, which is reason enough to leave\n it alone. A warm old-style serif sits with the kanji instead of\n arguing with it. The sans is kept for small mechanical labels\n only, and its tracking is pulled well back from the point where\n letterspaced caps start reading as a logo. */\n /* THE INFORMATION LAYER IS SET IN THE BASILICA'S OWN VOICE.\n The 2026 centenary identity is heavy grotesque capitals in flat\n colour on stone paper \u2014 the programme, the plates, the timeline\n of momentums are all cut that way, and a film about the same\n building in the same year has no business inventing a second\n typography for the same facts. Archivo is the closest open face\n to it: a grotesque with real weight at 800 and none of the\n geometric-sans blandness. The old-style serif stays for the\n quote and the caption band, where a sentence is being spoken\n rather than displayed. */\n --cf-display: Archivo, 'Helvetica Neue', Helvetica, Arial, sans-serif;\n --cf-caps: Archivo, 'Helvetica Neue', Helvetica, Arial, sans-serif;\n --cf-serif:\n 'Cormorant Garamond', 'Iowan Old Style', 'Palatino Linotype',\n Palatino, Georgia, serif;\n --cf-ui: Archivo, 'Helvetica Neue', Helvetica, Arial, sans-serif;\n /* the identity's four colours, and the chapter's own (set by wear) */\n --cf-c-red: #d8232a;\n --cf-c-blue: #1268c3;\n --cf-c-green: #0f7b62;\n --cf-c-pink: #e5308a;\n --cf-chap: #d8232a;\n --cf-shx: 2px;\n --cf-shy: 2px;\n --cf-rake: 35deg;\n\n position: relative;\n min-height: 100svh;\n background: var(--cf-paper);\n display: flex;\n flex-direction: column;\n transition: background 900ms ease;\n /* TYPE IS MEASURED AGAINST THE FILM, NOT THE WINDOW. Every size\n below was written in vw, which is right only when the film\n IS the window: put the same film in a 384px frame on a 1440px\n page and every line is sized for the page it is sitting on.\n An inline-size container makes cqw mean \"a hundredth of this\n film's own width\" wherever it is mounted \u2014 the demo well, the\n theater, a phone \u2014 so one set of numbers serves all of them.\n Inline-size only: the height stays the stage's business, and\n the stage below is its own container for exactly that. */\n container-name: film;\n container-type: inline-size;\n }\n\n /* THE STAGE NEVER GOES TALLER THAN IT IS WIDE, and on a phone that\n is the whole difference between a film and a mess.\n\n Every layout inside this box is written for a frame that is not\n portrait: the type sets in a column beside the subject, the mark\n draws a line across to it, the sky word runs off the right edge,\n the rail is a rule. Handed a 9:16 viewport none of that has\n anywhere to go \u2014 the picture crops to a slot, the column has no\n room beside anything, and the shot the score composed is not the\n shot on screen. Square is enough to hold all of it; the film\n does not need a cinema ratio, it needs to not be a column.\n\n The height is therefore capped at the width. That has to come\n from the viewport rather than a percentage, because a percentage\n max-height resolves against the parent's HEIGHT. Nothing changes\n on a landscape screen, where 100cqw is comfortably taller than\n the viewport and the cap never binds; on a phone the film plays\n as a square, letterboxed on the film's own paper. */\n .cf-stage {\n position: relative;\n height: 100svh;\n /* the 1:1 floor, measured against the film's own width rather\n than the window's \u2014 the same number, and right in a frame */\n max-height: 100cqw;\n flex: none;\n overflow: hidden;\n /* AND THE SHOT IS ITS OWN CONTAINER, on both axes. The rules\n that compress the gate and stand the rail down were written\n as max-height media queries, which ask about the WINDOW \u2014\n and the window is not the film. A square film in an 844px\n phone is 390px tall and needed every one of them; it got\n none, and the door's stack ran straight through its own\n buttons. Size containment is legal here because the height\n above is definite, and it is what lets a height query mean\n \"this shot is short\" rather than \"this phone is short\". */\n container-name: shot;\n container-type: size;\n }\n\n /* embedded there is nothing under the stage to hold it up, so the\n letterbox is split evenly rather than all falling below */\n .cf-page.is-embed {\n justify-content: center;\n }\n\n .cf-live {\n position: absolute;\n inset: 0;\n }\n\n /* THE SCENE DOES NOT TAKE THE POINTER. It is a picture: it has no\n controls of its own in film mode, and while it swallowed events\n the wheel went to the iframe's document instead of this page \u2014\n so the film filled the viewport and the wall plate underneath\n could not be reached. */\n .cf-frame {\n pointer-events: none;\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n border: 0;\n display: block;\n /* the primary: contrast, saturation and lift, per grade */\n }\n\n /* ---- the grade ------------------------------------------------- *\n Two passes, the way a colourist works: a primary on the picture\n itself (above), then a split tone laid over it \u2014 warmth into the\n highlights on one diagonal, coolness into the shadows on the\n other. Soft-light rather than overlay, because overlay crushes this scene's\n mid-tones and the whole point is to keep the moss and the plaster\n legible while moving the mood underneath them.\n ---------------------------------------------------------------- */\n\n /* the five moods. CONTEXT opens warm and open; HISTORY is archival \u2014\n desaturated, cool, contrastier, the look of a document rather than\n a day; CONSTRUCTION goes clean and bright, nearly a blueprint;\n DETAIL is rich and close; COMPARISON is a museum plate, flat and\n even, because a comparison that flatters one subject is not one. */\n .is-grade-amber {\n --cf-lut: saturate(0.9) contrast(0.94) brightness(1.2);\n --cf-warm: #ffd9a8;\n --cf-cool: #b9c8e6;\n --cf-grade-a: 0.3;\n --cf-vig-a: 0.5;\n }\n\n .is-grade-iron {\n --cf-lut: saturate(0.72) contrast(0.98) brightness(1.12) sepia(0.1);\n --cf-warm: #e8d9c2;\n --cf-cool: #9fb0c8;\n --cf-grade-a: 0.4;\n --cf-vig-a: 0.65;\n }\n\n /* MIDDAY, and it is meant to be the brightest thing in the film.\n The construction chapter is the one that has to read as\n information \u2014 you are watching a building get assembled \u2014 so it\n is pushed up and opened out until it is nearly a working\n drawing, and it earns its brightness by sitting between two\n chapters that are deliberately heavier. Contrast between\n chapters is a bigger effect than contrast inside one. */\n .is-grade-chalk {\n --cf-lut: saturate(0.88) contrast(0.96) brightness(1.34);\n --cf-warm: #fffdf6;\n --cf-cool: #cfdde8;\n --cf-grade-a: 0.2;\n --cf-vig-a: 0.22;\n }\n\n .is-grade-ink {\n --cf-lut: saturate(0.98) contrast(1) brightness(1.16);\n --cf-warm: #ffc9a1;\n --cf-cool: #8fa0c9;\n --cf-grade-a: 0.32;\n --cf-vig-a: 0.68;\n }\n\n /* THE NIGHT: the one grade that pulls the picture DOWN. Every other\n grade lifts, which is right for daylight; a lifted night is dusk. */\n .is-grade-night {\n --cf-lut: saturate(0.76) contrast(1.14) brightness(0.76);\n --cf-warm: #c9c2b4;\n --cf-cool: #8193b8;\n --cf-grade-a: 0.5;\n --cf-vig-a: 0;\n }\n\n /* WET. Rain is not simply the clear grade with drops in it: the\n light comes from a lid rather than a source, so the picture\n loses its warmth and most of its contrast, and the corners\n close in. */\n .is-grade-wet {\n --cf-lut: saturate(0.72) contrast(0.92) brightness(0.94)\n hue-rotate(-4deg);\n --cf-warm: #cfd6dd;\n --cf-cool: #7d8b9e;\n --cf-grade-a: 0.44;\n --cf-vig-a: 0.8;\n }\n\n .is-grade-plate {\n --cf-lut: saturate(0.86) contrast(0.95) brightness(1.2);\n --cf-warm: #f6e8d2;\n --cf-cool: #c3c8bd;\n --cf-grade-a: 0.18;\n --cf-vig-a: 0.36;\n }\n\n /* SIX COUNTRIES, SIX LIGHTS. The comparison holds the framing and\n the lens still so the BUILDING is the variable \u2014 but six towers\n under one identical sky read as six models on one lawn, which\n is a diorama, not a comparison. Each gets the tone its own\n country is remembered in, at a strength you feel and cannot\n quite name: the plate grade, bent a few degrees. */\n .is-grade-c-jp {\n --cf-lut: saturate(0.84) contrast(0.95) brightness(1.22);\n --cf-warm: #f6e8d2;\n --cf-cool: #bcc6bb;\n --cf-grade-a: 0.2;\n --cf-vig-a: 0.36;\n }\n\n .is-grade-c-cn {\n --cf-lut: saturate(0.95) contrast(0.97) brightness(1.16) sepia(0.06);\n --cf-warm: #ffd9a0;\n --cf-cool: #c0a68e;\n --cf-grade-a: 0.28;\n --cf-vig-a: 0.44;\n }\n\n .is-grade-c-vn {\n --cf-lut: saturate(0.92) contrast(0.94) brightness(1.18)\n hue-rotate(-6deg);\n --cf-warm: #eef0cd;\n --cf-cool: #a7bda8;\n --cf-grade-a: 0.26;\n --cf-vig-a: 0.4;\n }\n\n .is-grade-c-th {\n --cf-lut: saturate(1.02) contrast(0.93) brightness(1.3);\n --cf-warm: #ffe9ad;\n --cf-cool: #d3c6a0;\n --cf-grade-a: 0.24;\n --cf-vig-a: 0.3;\n }\n\n .is-grade-c-kh {\n --cf-lut: saturate(0.9) contrast(0.99) brightness(1.14) sepia(0.12);\n --cf-warm: #f2cfa4;\n --cf-cool: #b9a184;\n --cf-grade-a: 0.3;\n --cf-vig-a: 0.5;\n }\n\n .is-grade-c-tr {\n --cf-lut: saturate(0.8) contrast(0.96) brightness(1.26);\n --cf-warm: #fdf3e2;\n --cf-cool: #a9bacd;\n --cf-grade-a: 0.22;\n --cf-vig-a: 0.34;\n }\n\n /* the outgoing frame, swept off along the sun's diagonal behind a\n FEATHERED edge \u2014 a mask, not a clip, because a wipe with a hard\n edge is a screen transition and a wipe with a soft one is film */\n /* THE SWEEP IS TWO TRANSFORMS, NOT A MASK MOVING. Sliding\n mask-position repaints a full-resolution still every frame and\n the wipe stutters \u2014 which is what a wipe must never do. So the\n mask stays put on a sheet three frames wide, the SHEET slides\n across the picture, and the still inside slides back by the same\n amount so it never moves on screen. Both are transforms: the\n compositor rasterises the masked sheet once and only translates. */\n /**\n * THE WIPE, AS A MOVING EDGE RATHER THAN A MOVING BOX.\n *\n * It used to be a box three frames wide carrying the gradient, with\n * the image inside counter-moving so the edge appeared to cross. The\n * travel was hardcoded up-left while the gradient's angle followed\n * the SUN, so the two agreed only by luck: projected, the sweep was\n * 0.92 of its intended distance at one rake, 0.16 at another, 0 at\n * -27 degrees, and NEGATIVE past 0 \u2014 where the edge ran backwards\n * and the outgoing frame grew back over the incoming one. Sizing the\n * travel to the angle fixed the projection but not the geometry:\n * WHICH corner of the box the still starts at also depends on the\n * angle's quadrant, so at some rakes it began already uncovered.\n *\n * So nothing moves now. The element is the frame, the still fills\n * it, and the two gradient stops are swept from before the frame to\n * past it by one number. At p = 0 both stops are behind the frame,\n * so it is wholly opaque; at p = 1 both are past it, so it is wholly\n * gone. That is true at every angle, which the old one never was.\n */\n @property --cf-wipe-p {\n syntax: '<number>';\n initial-value: 0;\n inherits: false;\n }\n\n .cf-swipe {\n position: absolute;\n inset: 0;\n z-index: 0;\n pointer-events: none;\n will-change: mask-position, opacity;\n /* the band is 24% of the gradient's line: soft enough to read as\n a feathered edge, tight enough to read as an edge */\n --cf-wipe-a: calc(var(--cf-wipe-p) * 150% - 45%);\n mask-image: linear-gradient(\n calc(var(--cf-rake, 35deg) + 72deg),\n transparent var(--cf-wipe-a),\n #000 calc(var(--cf-wipe-a) + 24%)\n );\n -webkit-mask-image: linear-gradient(\n calc(var(--cf-rake, 35deg) + 72deg),\n transparent var(--cf-wipe-a),\n #000 calc(var(--cf-wipe-a) + 24%)\n );\n /* Star Wars speed: a wipe is a STATEMENT, and at two-thirds of a\n second it reads as a glitch rather than a decision */\n animation: cf-swipe 1150ms cubic-bezier(0.42, 0, 0.28, 1) forwards;\n }\n\n .cf-swipe img {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n display: block;\n }\n\n @keyframes cf-swipe {\n 0% {\n --cf-wipe-p: 0;\n opacity: 1;\n }\n\n 88% {\n opacity: 1;\n }\n\n 100% {\n --cf-wipe-p: 1;\n opacity: 0;\n }\n }\n\n .cf-melt {\n position: absolute;\n inset: 0;\n z-index: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n animation: cf-melt 1900ms cubic-bezier(0.4, 0, 0.5, 1) forwards;\n }\n\n @keyframes cf-melt {\n 0% {\n opacity: 1;\n }\n\n 22% {\n opacity: 0.92;\n }\n\n 100% {\n opacity: 0;\n }\n }\n\n .cf-blend {\n position: absolute;\n inset: 0;\n z-index: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n /* the still wears the same primary as the live frame under it,\n so the dissolve is between two graded pictures \u2014 and it is a\n PUSH dissolve: the old frame travels gently forward as it\n thins, so the transition has a direction, not just a mix */\n animation: cf-blend 520ms ease-out forwards;\n }\n\n @keyframes cf-blend {\n 0% {\n opacity: 1;\n transform: scale(1);\n }\n\n 100% {\n opacity: 0;\n transform: scale(1.055);\n }\n }\n\n /* the blur dissolve \u2014 and the freeze half of the rack-defocus */\n .cf-blurout {\n position: absolute;\n inset: 0;\n z-index: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n animation: cf-blurout 640ms ease-out forwards;\n }\n\n @keyframes cf-blurout {\n 0% {\n opacity: 1;\n filter: blur(0);\n }\n\n 100% {\n opacity: 0;\n filter: blur(13px);\n }\n }\n\n /* the optical dissolve: lighten blend, so highlights linger */\n .cf-luma {\n position: absolute;\n inset: 0;\n z-index: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n mix-blend-mode: lighten;\n animation: cf-blend 700ms ease-in forwards;\n }\n\n /* two breaths of white, no freeze \u2014 the gun-crack */\n .cf-flash {\n position: absolute;\n inset: 0;\n z-index: 4;\n pointer-events: none;\n background: #fff8ec;\n animation: cf-flash 300ms ease-out forwards;\n }\n\n @keyframes cf-flash {\n 0%,\n 22% {\n opacity: 0.92;\n }\n\n 100% {\n opacity: 0;\n }\n }\n\n /* the old shot closes in a circle onto the new shot's subject */\n .cf-iris {\n position: absolute;\n inset: 0;\n z-index: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n clip-path: circle(150% at var(--ix, 50%) var(--iy, 50%));\n animation: cf-iris 680ms cubic-bezier(0.45, 0, 0.3, 1) forwards;\n }\n\n @keyframes cf-iris {\n 0% {\n clip-path: circle(150% at var(--ix, 50%) var(--iy, 50%));\n opacity: 1;\n }\n\n 92% {\n opacity: 1;\n }\n\n 100% {\n clip-path: circle(0% at var(--ix, 50%) var(--iy, 50%));\n opacity: 0;\n }\n }\n\n .cf-dip {\n position: absolute;\n inset: 0;\n z-index: 0;\n pointer-events: none;\n }\n\n .cf-dip img {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n animation: cf-dip-frame 760ms linear forwards;\n }\n\n /* the freeze holds until the veil has fully closed, then drops\n under cover \u2014 the incoming shot is never seen before the dip */\n /* the outgoing frame is swapped for the incoming one IN THE DARK,\n inside the veil's hold, so none of the change is ever on screen */\n @keyframes cf-dip-frame {\n 0%,\n 37% {\n opacity: 1;\n }\n\n 41%,\n 100% {\n opacity: 0;\n }\n }\n\n .cf-dip i {\n position: absolute;\n inset: 0;\n opacity: 0;\n /* linear: the curve is in the stops, and it is not an ease */\n animation: cf-dip-veil 760ms linear forwards;\n }\n\n /**\n * A DIP CLOSES A SHUTTER; IT DOES NOT RAISE A BLIND.\n *\n * The veil is composited in sRGB, so an opacity of a leaves\n * (1 - a)^2.2 of the LIGHT: a veil eased evenly to black is down to\n * a fifth of the light by the halfway mark and then crawls, which is\n * why the dip read as a lurch and then a wait rather than a fade.\n * What a shutter does is take the LIGHT down smoothly, and the alpha\n * that produces a smooth fall in light is this \u2014 nearly nothing for\n * the first third, then a rush into black. The stops carry the\n * curve, so the timing function is linear.\n *\n * Close over 34%, hold black for 10% (a dip is punctuation and the\n * hold is the full stop), open over the remaining 56%: the way out\n * is longer than the way in, which is the editor's convention and\n * the reason a dip lands rather than bounces.\n */\n @keyframes cf-dip-veil {\n 0% {\n opacity: 0;\n }\n\n 6.8% {\n opacity: 0.049;\n }\n\n 13.6% {\n opacity: 0.178;\n }\n\n 17% {\n opacity: 0.27;\n }\n\n 20.4% {\n opacity: 0.378;\n }\n\n 23.8% {\n opacity: 0.502;\n }\n\n 27.2% {\n opacity: 0.643;\n }\n\n 30.6% {\n opacity: 0.803;\n }\n\n 34%,\n 44% {\n opacity: 1;\n }\n\n 49.6% {\n opacity: 0.803;\n }\n\n 55.2% {\n opacity: 0.643;\n }\n\n 60.8% {\n opacity: 0.502;\n }\n\n 66.4% {\n opacity: 0.378;\n }\n\n 72% {\n opacity: 0.27;\n }\n\n 77.6% {\n opacity: 0.178;\n }\n\n 88.8% {\n opacity: 0.049;\n }\n\n 100% {\n opacity: 0;\n }\n }\n\n /* the seams' own layer: no stacking context of its own, so a flash\n still rides above the scrim and a still stays under it */\n .cf-joins {\n position: absolute;\n inset: 0;\n pointer-events: none;\n }\n\n /**\n * A SEAM THAT COVERS THE FURNITURE (`@over=\"everything\"`). Above the\n * type, the stamp, the rail, the inserts and the clips (1 to 4);\n * level with the transport, which is later in the document and so\n * stays in front of it; below the menu, the captions, the door and\n * the fault banner (6 and up). A viewer must never lose the\n * controls to a transition \u2014 and the layer takes no pointer events\n * either way. The class is on only while the seam runs.\n */\n .cf-joins.is-over {\n z-index: 5;\n }\n\n /* ---- the lens ------------------------------------------------- */\n /* THE CLOUD IS A CAST SHADOW, not a filter over the lens. A band\n swept across the whole frame reads as somebody dimming the\n picture; what a cloud actually does is drop a soft, uneven\n shape onto the land and the building, keep its edges out of\n focus, and move. Two overlapping blobs on a wide plate,\n multiplied over the scene and travelling with the sun's rake. */\n .cf-cloud {\n position: absolute;\n /* -35%/-45% was a plate 1.7x the viewport each way, blurred at 26px\n on every frame the sun moved. The gradients are soft already; the\n blur added nothing you could see and a full-screen filter you could\n feel. Smaller, unblurred, and hidden outright when there is no\n cloud \u2014 an opacity of zero still costs a composited layer. */\n inset: -12% -16%;\n z-index: 1;\n pointer-events: none;\n /* IT WAS BLUE, AND BARCELONA IS NOT. A cloud passing over a warm\n stone site takes the warmth out of the light; it does not tip the\n whole frame to slate. These are a warm grey now, and at half the\n strength \u2014 a shadow crossing the picture rather than a filter\n laid over it. */\n opacity: calc(var(--cf-cloud, 0) * 0.32);\n background:\n radial-gradient(\n 62% 40% at 32% 44%,\n rgba(112, 100, 80, 0.4) 0%,\n rgba(120, 108, 88, 0.24) 46%,\n rgba(128, 118, 100, 0) 84%\n ),\n radial-gradient(\n 48% 32% at 68% 58%,\n rgba(106, 96, 78, 0.3) 0%,\n rgba(122, 110, 90, 0.16) 52%,\n rgba(128, 118, 100, 0) 90%\n );\n mix-blend-mode: multiply;\n transform: rotate(calc(var(--cf-rake, 35deg) * 0.4))\n translate3d(\n calc(var(--cf-cloud, 0) * -16%),\n calc(var(--cf-cloud, 0) * 7%),\n 0\n );\n }\n\n .is-cloudless .cf-cloud {\n visibility: hidden;\n }\n\n /* ---- scrims: anchored to the caption, never banded across the\n frame. A band wide enough to carry a lower third also washes out\n whatever is standing in the middle of the shot \u2014 which on this\n route is the building. ------------------------------------------ */\n .cf-scrim {\n position: absolute;\n inset: 0;\n z-index: 1;\n pointer-events: none;\n transition: background 700ms ease;\n }\n\n /* a bare beat: no scrim at all \u2014 under a walk through the hours the\n scrim's paper colour snaps with each theme, and a dish of colour\n snapping behind the type is worse than no dish */\n .cf-scrim.is-bare {\n display: none;\n }\n\n .cf-scrim-lower {\n background: radial-gradient(\n 128% 82% at 0% 112%,\n var(--cf-paper-a) 0%,\n var(--cf-paper-b) 36%,\n var(--cf-paper-c) 72%\n );\n }\n\n .cf-scrim-title {\n background: radial-gradient(\n 112% 108% at 112% 116%,\n var(--cf-paper-a) 0%,\n var(--cf-paper-b) 34%,\n var(--cf-paper-c) 70%\n );\n }\n\n .cf-scrim-plate {\n background: radial-gradient(\n 84% 104% at 110% 50%,\n var(--cf-paper-a) 0%,\n var(--cf-paper-b) 34%,\n var(--cf-paper-c) 72%\n );\n }\n\n .cf-scrim-point {\n background: radial-gradient(\n 88% 92% at -10% 26%,\n var(--cf-paper-a) 0%,\n var(--cf-paper-b) 38%,\n var(--cf-paper-c) 74%\n );\n }\n\n .cf-dim {\n position: absolute;\n inset: 0;\n z-index: 2;\n pointer-events: none;\n opacity: 0;\n background: radial-gradient(\n 76% 66% at 50% 48%,\n rgba(24, 18, 8, 0.16) 0%,\n rgba(24, 18, 8, 0.34) 100%\n );\n }\n\n /* ---- traces and callouts -------------------------------------- */\n .cf-track {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n z-index: 2;\n }\n\n /* the marker glows, and the glow is doing real work: a thin line\n over moss competes with the moss at exactly its own frequency,\n and a soft bloom gives the eye a low-frequency edge to catch\n first. (The traces on the building carry their own glow shell in\n the scene now; this dresses only what is still on the glass.) */\n /* THE GLOW COSTS A FRAME IN SAFARI. These two elements move every\n frame \u2014 the leader is re-pointed at the building on each one \u2014 and\n a stacked pair of drop-shadow filters is re-rasterised each time,\n which WebKit charges far more for than Blink does. One tight\n shadow gives the eye the same low-frequency edge to catch; the\n wide one was doing very little over a dimmed plate anyway. */\n .cf-leader,\n .cf-track circle {\n filter: drop-shadow(0 0 3px rgba(255, 36, 18, 0.9));\n }\n\n .cf-tether {\n stroke: var(--cf-accent);\n stroke-width: 1.4;\n stroke-dasharray: 3 5;\n opacity: 0;\n transition: opacity 500ms ease;\n }\n\n .cf-leader {\n stroke: var(--cf-mark);\n stroke-width: 1.8;\n stroke-dasharray: 5 4;\n stroke-linecap: round;\n opacity: 0;\n }\n\n .cf-track circle {\n fill: none;\n stroke: var(--cf-mark);\n stroke-width: 2.6;\n stroke-linecap: round;\n opacity: 0;\n }\n\n /* ================================================================\n RESPONSIVE. The film had not one media query in it: every size was\n a viewport fraction, which keeps type in proportion but does not\n change a LAYOUT. Two columns of an editorial plate do not become\n a phone layout by getting smaller \u2014 they have to become one\n column, the ghost numeral has to go, and the settings have to\n stop being a corner of the frame and become its foot.\n ================================================================ */\n\n /* a laptop, or a window shared with something else */\n\n /* a tablet, or a phone held the long way */\n\n /* a phone */\n\n /* a short window: the film is 16:9 in a letterbox and the vertical\n rhythm, not the width, is what runs out */\n\n /* someone who has asked for less movement gets the film without the\n drift, the sway and the entrances \u2014 and still gets the film */\n @media (prefers-reduced-motion: reduce) {\n .cf-mg-mark,\n .cf-mg-glyph,\n .cf-gate-ghost,\n .cf-mg-seal,\n .cf-kicker::after {\n animation-duration: 1ms !important;\n }\n }\n\n /* ---- the front layer ------------------------------------------ */\n /* THE WALL TEXT LEAVES BEFORE THE SEAM. Its own exit animation\n runs when the next beat replaces it, which means the outgoing\n setting is still on screen at the instant of the cut \u2014 so the\n edit lands on a frame with two things happening in it. Half a\n second of fade ahead of the change empties the corner first,\n and the cut is then only ever about the picture. */\n /* the ink follows the hour: the palette is swapped per theme by wear,\n and under a walk through the day the type must not snap from black\n to white \u2014 it turns with the light */\n .cf-type,\n .cf-type p,\n .cf-type span {\n transition: color 2400ms ease;\n }\n\n .cf-type {\n position: absolute;\n inset: 0;\n z-index: 3;\n opacity: var(--cf-type-a, 1);\n pointer-events: none;\n color: var(--cf-ink);\n font-family: var(--cf-display);\n }\n\n .cf-block {\n position: absolute;\n max-width: min(46cqw, 660px);\n }\n\n /* a kicker rides a rule that draws itself out of the type \u2014 the\n diagonal is the sun's, so the graphic furniture rakes the same\n way the shadows do */\n /* the eyebrow never wraps: 'THE TALLEST CHURCH ON EARTH' at this\n tracking is wider than the block, and a wrapped kicker changes\n the height of a bottom-anchored plate */\n .cf-kicker > span {\n white-space: nowrap;\n }\n\n .cf-kicker {\n font-family: var(--cf-ui);\n font-size: clamp(11px, 0.9cqw, 14px);\n font-weight: 700;\n letter-spacing: 0.11em;\n color: var(--cf-chap);\n margin: 0 0 14px;\n display: flex;\n align-items: center;\n gap: 12px;\n }\n\n /* the rule draws itself out of the kicker. It is a pseudo-element,\n so Motion cannot own it \u2014 but the whole block is rebuilt on every\n beat, which means a plain CSS animation runs from its first frame\n each time and needs no retriggering. */\n .cf-kicker::after {\n content: '';\n /* the rule takes the chapter's colour: on the centenary timeline\n the rule, the dot and the heading are one colour, and that is\n what makes it read as a system rather than as decoration */\n background: var(--cf-chap);\n /* the rule gives way to the words but never vanishes: a rule that\n collapses to nothing on a long kicker is furniture that blinks\n in and out with the string length */\n flex: 1 1 26px;\n min-width: 26px;\n height: 2px;\n transform-origin: left center;\n animation: cf-rule 760ms 300ms cubic-bezier(0.22, 1, 0.36, 1) both;\n }\n\n .is-title .cf-kicker::after {\n transform-origin: right center;\n }\n\n @keyframes cf-rule {\n from {\n transform: scaleX(0);\n }\n\n to {\n transform: scaleX(1);\n }\n }\n\n .is-point .cf-kicker::after,\n .is-lower .cf-kicker::after {\n max-width: 120px;\n }\n\n .cf-kanji {\n font-family:\n 'Hiragino Mincho ProN', 'Yu Mincho', YuMincho, 'Noto Serif JP',\n 'Songti SC', serif;\n font-size: min(132px, 8.4cqw);\n line-height: 0.94;\n letter-spacing: 0.04em;\n margin: 0 0 18px;\n }\n\n .cf-read {\n margin: 0 0 20px;\n display: flex;\n gap: 6px 16px;\n align-items: baseline;\n flex-wrap: wrap;\n }\n\n /* a flex child cannot shrink past its own content without this */\n .cf-romaji,\n .cf-gloss {\n min-width: 0;\n }\n\n /* on a marked beat the reading is the largest thing in the block,\n because the term itself is out in the scene */\n .cf-block:not(:has(.is-glyph)) .cf-romaji {\n font-size: clamp(17px, 1.5cqw, 24px);\n letter-spacing: 0.2em;\n }\n\n .cf-block:not(:has(.is-glyph)) .cf-gloss {\n font-size: clamp(15px, 1.2cqw, 19px);\n }\n\n .cf-romaji {\n font-family: var(--cf-ui);\n font-size: clamp(11px, 0.94cqw, 14px);\n font-weight: 700;\n letter-spacing: 0.11em;\n color: var(--cf-chap);\n }\n\n .cf-gloss {\n font-family: var(--cf-ui);\n font-size: clamp(12px, 0.98cqw, 15px);\n font-weight: 400;\n letter-spacing: 0.01em;\n color: var(--cf-ink3);\n }\n\n /* the planes are the host's to move; the type on them is Motion's */\n .cf-plane {\n will-change: transform;\n }\n\n /* a phrase is a HEADLINE, not body copy: it is on screen for two\n seconds and read at a glance, so it is set at the size a glance\n needs and it never runs past two lines */\n .cf-say {\n margin: 0 0 12px;\n font-family: var(--cf-display);\n /* set in the identity's capitals, but a cue is still a caption:\n at 30px in heavy caps three of them fill a frame and the\n picture becomes the background to a poster */\n font-size: clamp(14px, 1.28cqw, 22px);\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.012em;\n line-height: 1.16;\n color: var(--cf-ink);\n /* a cue is a phrase: wide enough that most set on ONE line.\n 26ch was a CJK measure and these run to 34 characters, so the\n measure comes from the longest of them \u2014 and the break is left\n to pretty, because balance moves the break around as the\n string changes, which is the flicker between a one-line and a\n two-line setting. */\n max-width: min(38ch, 34cqw);\n text-wrap: pretty;\n }\n\n .cf-say:last-child {\n margin-bottom: 0;\n }\n\n /* tall scripts (Thai, Khmer) at kanji size collide with their\n neighbours; scale the whole set box and give the marks headroom */\n .cf-kanji.is-tall {\n zoom: 0.58;\n line-height: 1.5;\n }\n\n /* ---- lines that DO what they SAY ------------------------------ *\n Behavior animations on the inner span, addressed per line by the\n film (`.sx-<beat>-<n>`). They run the length of the beat, so\n whenever the delivery reveals a line it is already mid-behavior \u2014\n never waiting, never done. Two gestures ship here: a plumb drop\n and a heavy settle. */\n .cf-sayx {\n display: inline-block;\n }\n\n @keyframes cf-plumb {\n 0% {\n transform: translateY(-16px) scaleY(1.18);\n }\n\n 100% {\n transform: translateY(0) scaleY(1);\n }\n }\n\n /* \"Flaring at the foot\" \u2014 the line itself flares, tracking wide\n from its left foot for the whole shot, the batter's own curve */\n /* (the old per-line behaviours are gone: one of them animated\n letter-spacing on an inline-block, which re-wrapped the line it\n was in halfway through a shot) */\n\n @keyframes cf-sink {\n 0% {\n transform: translateY(-14px);\n }\n\n 34% {\n transform: translateY(2px);\n }\n\n 100% {\n transform: translateY(0);\n }\n }\n\n /* the four ways a beat is set. A title is ranged right against the\n frame's edge with the tower in the other half; a lower third sits\n bottom-left; a plate is a slab in the right third; a point is a\n caption with a leader running out of it. */\n .cf-block.is-title {\n right: 6%;\n bottom: 15%;\n text-align: right;\n max-width: min(48cqw, 700px);\n /* poster size at the start, the house size once the lens has\n arrived: the same lean as every block, times the title's own\n scale, from the corner it hangs from */\n transform-origin: 100% 100%;\n transform: translate3d(\n calc(var(--cf-lx, 0) * -9px),\n calc(var(--cf-ly, 0) * -5px),\n 0\n )\n scale(calc(1.42 - 0.42 * var(--cf-title-k, 1)));\n }\n\n /* the opening's hierarchy: the name, then its reading, then the\n cues a step back in the ink \u2014 a poster, not a paragraph */\n .is-title .cf-kanji {\n margin-bottom: 26px;\n }\n\n .is-title .cf-read {\n justify-content: flex-end;\n margin-bottom: 28px;\n gap: 18px;\n }\n\n .is-title .cf-romaji {\n font-size: clamp(15px, 1.3cqw, 23px);\n letter-spacing: 0.3em;\n }\n\n .is-title .cf-gloss {\n font-size: clamp(15px, 1.25cqw, 22px);\n }\n\n .is-title .cf-say {\n max-width: 30ch;\n margin-bottom: 6px;\n color: var(--cf-ink2);\n font-size: min(34px, 1.95cqw);\n }\n\n .is-title .cf-kicker {\n flex-direction: row-reverse;\n }\n\n .is-title .cf-line {\n margin-left: auto;\n }\n\n .is-title .cf-kanji {\n font-size: min(190px, 11cqw);\n }\n\n .cf-block.is-lower {\n left: 5.5%;\n bottom: 11%;\n }\n\n /* captions take the foot of the frame, so the settings that live\n down there move up out of their way rather than sit under them */\n .has-subs .cf-block.is-lower {\n bottom: 21%;\n }\n\n /* the plate steps out of the caption band's way \u2014 and stepping is\n what it should not do, so it moves over a beat, not a frame */\n .cf-block {\n transition: bottom 420ms cubic-bezier(0.22, 1, 0.36, 1);\n }\n\n .has-subs .cf-block.is-title {\n bottom: 24%;\n }\n\n /* the plate is centred on the frame, so a caption band eats its last\n cue rather than sitting under it */\n .has-subs .cf-block.is-plate {\n top: 44%;\n }\n\n /* THE PLATE, which is the one setting that is a designed object\n rather than a caption. Two columns: the term set VERTICALLY down\n the right edge, the way it would be on a museum label or a\n hanging scroll, and the reading and the phrases in a column\n beside it. A rule between them draws itself down as the plate\n lands. Ranged type in a corner was never wrong exactly \u2014 it was\n just nothing, and this chapter's beats are the ones that hold\n longest, so they are the ones that can least afford nothing. */\n .cf-block.is-plate {\n right: 5.5%;\n top: 50%;\n transform: translateY(-50%);\n display: grid;\n /* minmax(0,\u2026) or the cue column cannot shrink and the word wins */\n grid-template-columns: minmax(0, auto) auto;\n justify-content: end;\n column-gap: min(30px, 1.8cqw);\n align-items: start;\n /* the word takes a column of its own, so the block has to be wide\n enough for both it and a cue line that is not two words per row\n \u2014 and no wider: at two thirds of the frame the cue column began\n over the subject, and a plate is a label beside the thing */\n max-width: min(50cqw, 760px);\n }\n\n .is-plate .cf-plane {\n grid-column: 1;\n }\n\n /* THE CUES RANGE AGAINST THE RULE. The block hugs the right edge and\n everything in it gathers toward the word, so the plate is one\n object at the side of the frame and the picture keeps its left. */\n .is-plate .cf-plane:not(.is-glyph) {\n display: flex;\n flex-direction: column;\n align-items: flex-end;\n text-align: right;\n }\n\n .is-plate .cf-plane:not(.is-glyph) .cf-kicker {\n align-self: stretch;\n }\n\n .is-plate .cf-read {\n justify-content: flex-end;\n }\n\n .is-plate .cf-plane.is-glyph {\n grid-column: 2;\n grid-row: 1 / -1;\n border-right: 2px solid var(--cf-accent);\n padding-right: min(26px, 1.5cqw);\n transform-origin: top center;\n animation: cf-rule-down 820ms 240ms cubic-bezier(0.22, 1, 0.36, 1) both;\n }\n\n /* the mask draws the rule DOWN; it must never clip sideways \u2014 the\n glyph plane leans with the pointer, and a mask flush with the box\n shaved the leaned strokes off the left edge as a hairline */\n @keyframes cf-rule-down {\n from {\n clip-path: inset(0 -40px 100% -40px);\n }\n\n to {\n clip-path: inset(0 -40px -4% -40px);\n }\n }\n\n .is-plate .cf-kanji {\n writing-mode: vertical-rl;\n margin: 0;\n /* the column never grows past the frame: whichever is smaller,\n the designed size or the height each glyph can have and still\n leave the set inside the picture */\n font-size: min(min(88px, 5.4cqw), calc(62svh / var(--cf-glyphs, 3)));\n letter-spacing: 0.1em;\n line-height: 1;\n }\n\n .is-plate .cf-kicker {\n margin-bottom: 18px;\n }\n\n /* the ghost is only ever behind a plate \u2014 everywhere else the frame\n is already carrying the building */\n .cf-ghost {\n display: none;\n }\n\n /* THE PLANES LEAN. Each layer takes the pointer at its own depth \u2014\n the ghost furthest, the big glyph next, the reading lines least\n \u2014 so the type stack has air in it rather than being one sheet\n of glass in front of a picture. */\n .cf-block {\n transform: translate3d(\n calc(var(--cf-lx, 0) * -9px),\n calc(var(--cf-ly, 0) * -5px),\n 0\n );\n }\n\n .cf-plate .cf-ghost {\n transform: translateY(-50%)\n translate3d(\n calc(var(--cf-lx, 0) * -26px),\n calc(var(--cf-ly, 0) * -14px),\n 0\n );\n }\n\n .cf-kanji {\n transform: translate3d(\n calc(var(--cf-lx, 0) * -15px),\n calc(var(--cf-ly, 0) * -8px),\n 0\n );\n }\n\n /* the ghost NUMERAL keeps its old treatment: a filled slab of ink\n at a whisper of opacity, behind the plate. It is a page\n furniture mark, not type in the world \u2014 the hollow treatment\n belongs to the glyphs that share air with the building. */\n .cf-plate .cf-ghost {\n display: block;\n position: absolute;\n right: 3%;\n top: 50%;\n transform: translateY(-50%);\n font-family: var(--cf-ui);\n font-size: min(460px, 30cqw);\n font-weight: 700;\n line-height: 0.8;\n letter-spacing: -0.04em;\n color: var(--cf-ink);\n opacity: calc(0.045 * var(--cf-ghost-a, 1));\n pointer-events: none;\n z-index: -1;\n }\n\n .cf-block.is-point {\n left: 5.5%;\n top: 18%;\n max-width: min(36cqw, 520px);\n }\n\n .is-point .cf-kanji {\n font-size: min(104px, 6.6cqw);\n }\n\n /* ---- clips ------------------------------------------------------ *\n a video, a still or a freeze over the picture: full frame under the\n scrim and the type, or an inset like the photograph */\n .cf-clip {\n position: absolute;\n pointer-events: none;\n }\n\n .cf-clip.is-cover {\n inset: 0;\n z-index: 1;\n }\n\n .cf-clip.is-cover figure {\n position: absolute;\n inset: 0;\n margin: 0;\n }\n\n .cf-clip.is-cover video,\n .cf-clip.is-cover img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n }\n\n .cf-clip.is-cover figcaption {\n position: absolute;\n left: 5.5%;\n bottom: 6%;\n display: flex;\n flex-direction: column;\n gap: 2px;\n font-family: var(--cf-ui);\n color: #f7f0e0;\n text-shadow: 0 1px 12px rgba(0, 0, 0, 0.5);\n }\n\n .cf-clip.is-inset {\n z-index: 4;\n }\n\n /**\n * A PIP IS A LAYER, not a photograph. No card, no rule, no caption\n * furniture \u2014 the score places it (`left/top/width` in percent of\n * the frame, written inline) and it carries nothing but its own\n * media and a corner radius. It sits under the type, so a lower\n * third still wins over it, and over the picture and its scrim.\n */\n .cf-clip.is-pip {\n z-index: 2;\n inset: 0;\n }\n\n .cf-clip.is-pip figure {\n position: absolute;\n margin: 0;\n overflow: hidden;\n line-height: 0;\n box-shadow: calc(var(--cf-shx) * 3) calc(var(--cf-shy) * 3) 30px\n rgba(20, 14, 4, 0.34);\n }\n\n .cf-clip.is-pip video,\n .cf-clip.is-pip img {\n display: block;\n width: 100%;\n height: auto;\n }\n\n .cf-clip.is-inset figure {\n position: absolute;\n right: 5.5%;\n top: 12%;\n margin: 0;\n width: min(30cqw, 340px);\n background: var(--cf-paper);\n padding: 10px 10px 8px;\n border: 1px solid var(--cf-rule);\n box-shadow: calc(var(--cf-shx) * 4) calc(var(--cf-shy) * 4) 26px\n rgba(38, 28, 10, 0.26);\n }\n\n .cf-clip.is-inset video,\n .cf-clip.is-inset img {\n display: block;\n width: 100%;\n height: auto;\n }\n\n .cf-clip.is-inset figcaption {\n display: flex;\n flex-direction: column;\n gap: 2px;\n padding-top: 8px;\n font-family: var(--cf-ui);\n }\n\n .cf-clip-cap {\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0.12em;\n }\n\n .cf-clip-cr {\n font-size: 10px;\n letter-spacing: 0.06em;\n opacity: 0.75;\n }\n\n /* ---- the photograph -------------------------------------------- */\n .cf-photo {\n position: absolute;\n z-index: 4;\n pointer-events: none;\n }\n\n .cf-photo figure.is-lower,\n .cf-photo figure.is-title,\n .cf-photo figure.is-point {\n position: absolute;\n right: 5.5%;\n top: 12%;\n }\n\n .cf-photo figure.is-plate {\n position: absolute;\n left: 5.5%;\n top: 12%;\n }\n\n .cf-photo figure {\n margin: 0;\n width: min(30cqw, 340px);\n background: var(--cf-paper);\n padding: 10px 10px 8px;\n border: 1px solid var(--cf-rule);\n box-shadow: calc(var(--cf-shx) * 4) calc(var(--cf-shy) * 4) 26px\n rgba(38, 28, 10, 0.26);\n }\n\n .cf-photo img {\n display: block;\n width: 100%;\n height: auto;\n filter: saturate(0.86) contrast(1.04);\n }\n\n .cf-photo figcaption {\n display: flex;\n flex-direction: column;\n gap: 2px;\n padding-top: 8px;\n font-family: var(--cf-ui);\n }\n\n .cf-photo-cap {\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0.12em;\n color: var(--cf-ink);\n }\n\n .cf-photo-cr {\n font-size: 10px;\n letter-spacing: 0.06em;\n color: var(--cf-ink2);\n }\n\n .is-dark .cf-subs {\n color: #1a1408;\n background: rgba(238, 228, 204, 0.82);\n }\n\n .cf-subs {\n position: absolute;\n left: 50%;\n bottom: 3%;\n transform: translateX(-50%);\n z-index: 6;\n transition: bottom 360ms ease;\n margin: 0;\n max-width: 60ch;\n text-align: center;\n font-family: var(--cf-serif);\n font-size: 17px;\n line-height: 1.5;\n color: #f7efdd;\n background: rgba(20, 15, 6, 0.66);\n padding: 9px 18px;\n }\n\n /* while the transport is up the caption sits above it, and drops\n back to the foot of the frame when the bar leaves */\n .cf-page:not(.is-idle) .cf-subs {\n bottom: 104px;\n }\n\n /* ---- the rail -------------------------------------------------- */\n /* ONE LINE: the numeral, the chapter's name and the rule, left to\n right \u2014 the rule is not a second row under the title */\n .cf-rail {\n flex-wrap: nowrap;\n position: absolute;\n left: 5.5%;\n top: 6%;\n z-index: 3;\n display: flex;\n align-items: center;\n gap: 14px;\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.17em;\n color: var(--cf-ink3);\n font-family: var(--cf-ui);\n }\n\n .cf-rail-n {\n font-size: 22px;\n font-weight: 800;\n letter-spacing: 0.02em;\n color: var(--cf-chap);\n }\n\n .cf-rail-segs {\n display: flex;\n gap: 5px;\n width: 240px;\n }\n\n /* ---- the year's own timeline ----------------------------------\n A rule the width of the chapter strip, the run so far filled in\n the chapter's colour, a dot on the head and the year riding above\n it. Tabular figures so the numeral does not jitter as it counts,\n and the whole thing is one row under the chapter line so the\n corner stays a single object. */\n /* THE RULE IS THE FILM'S LENGTH, so it should read as a long\n distance: 240px made a century and a half look like a widget.\n It runs to nearly half the frame now, and the chapter dots have\n room to sit at their own years without touching. */\n .cf-years {\n position: relative;\n flex: none;\n height: 34px;\n margin-left: 34px;\n width: min(620px, 42cqw);\n }\n\n .cf-years-rule,\n .cf-years-fill {\n position: absolute;\n left: 0;\n top: 14px;\n height: 2px;\n background: var(--cf-rule);\n }\n\n .cf-years-rule {\n right: 0;\n }\n\n .cf-years-fill {\n width: calc(5px + var(--cf-head, 0) * (100% - 10px));\n background: var(--cf-chap);\n transition: width 220ms linear;\n }\n\n .cf-years-tick {\n position: absolute;\n top: 11px;\n width: 1px;\n height: 8px;\n background: var(--cf-rule);\n }\n\n /* the chapters, as dots at the years they open. Unbuilt they are the\n rule's own colour; passed, they take the chapter's. */\n .cf-years-ch {\n position: absolute;\n top: 10px;\n z-index: 2;\n appearance: none;\n border: 0;\n padding: 0;\n width: 10px;\n height: 10px;\n margin-left: -5px;\n border-radius: 50%;\n background: var(--cf-rule);\n cursor: pointer;\n pointer-events: auto;\n transition:\n background 300ms ease,\n transform 200ms ease;\n }\n\n .cf-years-ch.is-done {\n background: var(--cf-chap);\n }\n\n .cf-years-ch.is-here {\n background: var(--cf-chap);\n transform: scale(1.34);\n }\n\n .cf-years-ch:hover {\n transform: scale(1.5);\n }\n\n .cf-years-now {\n position: absolute;\n top: 20px;\n left: calc(5px + var(--cf-head, 0) * (100% - 10px));\n transform: translateX(-50%);\n font-family: var(--cf-ui);\n font-weight: 800;\n font-size: 15px;\n letter-spacing: 0.01em;\n font-variant-numeric: tabular-nums;\n color: var(--cf-chap);\n white-space: nowrap;\n transition: left 220ms linear;\n }\n\n .cf-years-now::after {\n content: '';\n position: absolute;\n left: 50%;\n /* rule top 14px, rule 2px, dot 10px \u2192 -10 puts the dot's centre\n on the rule's centre exactly */\n top: -10px;\n width: 10px;\n height: 10px;\n margin-left: -5px;\n border-radius: 50%;\n background: var(--cf-chap);\n }\n\n .cf-years-a,\n .cf-years-b {\n position: absolute;\n top: 0;\n font-family: var(--cf-ui);\n font-style: normal;\n font-size: 9px;\n font-weight: 600;\n letter-spacing: 0.14em;\n color: var(--cf-ink2);\n }\n\n .cf-years-a {\n left: 0;\n }\n\n .cf-years-b {\n right: 0;\n }\n\n /* each segment is a 13px-tall click target drawing a hairline\n track with its fill on top; the current chapter's bar thickens */\n .cf-rail-seg {\n appearance: none;\n position: relative;\n border: 0;\n height: 13px;\n padding: 0;\n background: transparent;\n cursor: pointer;\n pointer-events: auto;\n }\n\n .cf-rail-seg::after {\n content: '';\n position: absolute;\n inset: 5px 0 auto;\n height: 3px;\n background: var(--cf-rule);\n }\n\n .cf-rail-seg i {\n position: absolute;\n left: 0;\n top: 5px;\n height: 3px;\n z-index: 1;\n background: var(--cf-chap);\n transition: width 600ms cubic-bezier(0.22, 1, 0.36, 1);\n }\n\n .cf-rail-seg.is-here i {\n top: 4px;\n height: 5px;\n }\n\n /* THE DOT ON THE RULE. The centenary programme draws its timeline as\n a line with a filled dot at each momentum; the film's chapter\n strip is the same object, so it is drawn the same way. */\n .cf-rail-seg::before {\n content: '';\n position: absolute;\n left: -1px;\n top: 2px;\n z-index: 2;\n width: 9px;\n height: 9px;\n border-radius: 50%;\n background: var(--cf-rule);\n transition: background 300ms ease;\n }\n\n .cf-rail-seg.is-here::before {\n background: var(--cf-chap);\n }\n\n .cf-rail-seg:hover::after {\n background: var(--cf-ink2);\n }\n\n .cf-fault {\n position: absolute;\n left: 5.5%;\n bottom: 4%;\n z-index: 9;\n margin: 0;\n padding: 8px 12px;\n background: #2a0a06;\n color: #ffb4a0;\n font:\n 12px/1.4 ui-monospace,\n monospace;\n border-radius: 8px;\n max-width: 80%;\n }\n\n .cf-rig {\n position: absolute;\n width: 0;\n height: 0;\n }\n\n /* ---- the disc menu --------------------------------------------- */\n /* the sheet belongs to the picture: it scrims the scene and its\n transport, and leaves the wall plate below the film alone \u2014\n a menu that dims an article is a modal, not a disc menu */\n .cf-menu {\n position: absolute;\n inset: 0 0 auto 0;\n height: 100svh;\n z-index: 6;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(28, 22, 10, 0.52);\n backdrop-filter: blur(2px);\n }\n\n .cf-menu-in {\n background: var(--cf-paper);\n border: 1px solid var(--cf-rule);\n padding: 34px 40px 26px;\n min-width: min(460px, 84cqw);\n max-width: min(640px, 92cqw);\n box-shadow: 0 30px 70px rgba(30, 22, 8, 0.34);\n font-family: var(--cf-ui);\n }\n\n .cf-menu-head {\n margin: 0;\n font-size: 13px;\n font-weight: 800;\n letter-spacing: 0.42em;\n color: var(--cf-ink);\n }\n\n .cf-menu-sub {\n margin: 4px 0 22px;\n font-size: 12px;\n color: var(--cf-ink2);\n letter-spacing: 0.06em;\n }\n\n .cf-menu-list {\n list-style: none;\n margin: 0 0 18px;\n padding: 0;\n }\n\n .cf-menu-item {\n appearance: none;\n background: transparent;\n border: 0;\n border-top: 1px solid var(--cf-rule);\n width: 100%;\n display: flex;\n align-items: baseline;\n gap: 16px;\n padding: 13px 4px;\n font: inherit;\n color: var(--cf-ink);\n cursor: pointer;\n text-align: left;\n }\n\n .cf-menu-item:hover {\n color: var(--cf-accent);\n }\n\n .cf-menu-item.is-here {\n color: var(--cf-accent);\n font-weight: 700;\n }\n\n .cf-menu-n {\n font-size: 12px;\n letter-spacing: 0.16em;\n color: var(--cf-accent);\n min-width: 2.4em;\n }\n\n .cf-menu-t {\n font-size: 17px;\n letter-spacing: 0.1em;\n font-weight: 600;\n flex: 1;\n min-width: 0;\n }\n\n .cf-menu-d {\n font-size: 11px;\n letter-spacing: 0.1em;\n color: var(--cf-ink2);\n }\n\n .cf-menu-keys {\n margin: 0;\n font-size: 11px;\n letter-spacing: 0.1em;\n color: var(--cf-ink2);\n }\n\n /* ---- controls -------------------------------------------------- */\n /* ---- the player ------------------------------------------------ *\n * A chaptered playhead in the film's own materials: gaps between\n * the chapters, seal red for what has played, and a head you can\n * take hold of. It sits ON the picture, above the transport, and\n * both stand down when the hand goes away.\n * ------------------------------------------------------------- */\n /* THE PLAYER FLOATS ON THE PICTURE, the way every player does \u2014\n a paper bar under the frame costs fifty pixels of film and\n announces that this is a document with a video in it. */\n /* THE BAR IS UNDER THE PICTURE, not on it. A player floating over\n the frame is right for a video in a page and wrong for a film\n that fills the window: it takes the bottom of every composition\n and it disappears exactly when you reach for it. Below the\n picture it costs seventy-six pixels once and is always there. */\n /* THE TRANSPORT SITS ON THE PICTURE, not under it. The source page\n runs its track as a hairline over the scene; here the strip was a\n solid band of paper that took 76px off every frame. It is a fade\n of the chapter's own paper now, so the film owns the whole\n viewport and the controls read as a ledge on the glass. */\n /* ---- the player ------------------------------------------------ */\n /* fixed, not absolute: the page runs on below the stage, so the\n page's bottom is not the frame's bottom. A dark scrim, not paper:\n white type on a gradient into black is the one thing that reads\n over every grade the film wears, and it is what the eye expects\n at the bottom of a picture that plays. */\n .cf-player {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n z-index: 5;\n padding: 26px 14px 8px;\n color: #fff;\n font-family: var(--cf-ui);\n background: linear-gradient(\n to top,\n rgba(12, 9, 4, 0.72),\n rgba(12, 9, 4, 0.38) 45%,\n rgba(12, 9, 4, 0)\n );\n transition: opacity 360ms ease;\n }\n\n /* nobody is touching it: the apparatus leaves the picture, the way\n every player made since 2010 does; a hand on the frame brings it\n back (wake) */\n .cf-player.is-idle {\n opacity: 0;\n pointer-events: none;\n }\n\n /* the transport waits outside while the front door is open */\n .cf-player.is-away {\n visibility: hidden;\n }\n\n .cf-scrub {\n position: relative;\n height: 22px;\n display: flex;\n align-items: center;\n cursor: pointer;\n touch-action: none;\n outline: none;\n }\n\n .cf-scrub-track {\n position: relative;\n display: flex;\n gap: 2px;\n width: 100%;\n height: 3px;\n transition: height 120ms ease;\n }\n\n .cf-scrub:hover .cf-scrub-track,\n .cf-scrub:focus-visible .cf-scrub-track {\n height: 5px;\n }\n\n .cf-scrub-ch {\n position: relative;\n overflow: hidden;\n background: rgba(255, 255, 255, 0.28);\n transition: transform 120ms ease;\n }\n\n /* the chapter under the pointer stands up a little, the way the\n hovered chapter on every chaptered bar does */\n .cf-scrub:hover .cf-scrub-ch:hover {\n transform: scaleY(1.7);\n }\n\n /* the ghost: a pale fill to wherever the pointer is resting, so\n the eye reads the cut it would make before the hand makes it */\n .cf-scrub-ghost {\n position: absolute;\n inset: 0 auto 0 0;\n display: block;\n background: rgba(255, 255, 255, 0.34);\n width: calc(\n clamp(0, (var(--cf-hov, 0) - var(--s0)) / var(--sl), 1) * 100%\n );\n }\n\n /* each chapter fills with the part of the whole-film playhead that\n falls inside it \u2014 one custom property, updated per frame, and no\n re-render anywhere */\n .cf-scrub-fill {\n position: absolute;\n inset: 0 auto 0 0;\n display: block;\n background: var(--cf-red);\n width: calc(\n clamp(0, (var(--cf-prog, 0) - var(--s0)) / var(--sl), 1) * 100%\n );\n }\n\n /* the knob sits on the fill's end: --cf-hpx is that end in pixels,\n from the same fractions and the measured track (see frame) */\n .cf-scrub-head {\n position: absolute;\n top: 50%;\n left: calc(var(--cf-hpx, 0) * 1px);\n width: 13px;\n height: 13px;\n margin: -6.5px 0 0 -6.5px;\n border-radius: 50%;\n background: var(--cf-red);\n transform: scale(0);\n transition: transform 120ms ease;\n }\n\n .cf-scrub:hover .cf-scrub-head,\n .cf-scrub:focus-visible .cf-scrub-head,\n .cf-scrub.is-live .cf-scrub-head {\n transform: scale(1);\n }\n\n .cf-scrub-tip {\n position: absolute;\n bottom: 18px;\n left: calc(var(--cf-tipx, 0) * 1px);\n transform: translateX(-50%);\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 2px;\n white-space: nowrap;\n background: rgba(12, 9, 4, 0.92);\n color: #fff;\n padding: 7px 11px 6px;\n border-radius: 4px;\n pointer-events: none;\n }\n\n .cf-scrub-tip-ch {\n font-size: 10px;\n letter-spacing: 0.12em;\n color: rgba(255, 255, 255, 0.72);\n }\n\n .cf-scrub-tip-t {\n font-size: 13px;\n font-weight: 600;\n font-variant-numeric: tabular-nums;\n }\n\n .cf-controls {\n display: flex;\n align-items: center;\n gap: 2px;\n height: 40px;\n }\n\n /* the round ghost button: a 40px target, the glyph 22px inside it,\n a hover that is a wash rather than an outline */\n .cf-ic {\n appearance: none;\n border: 0;\n background: transparent;\n color: #fff;\n width: 40px;\n height: 40px;\n padding: 0;\n border-radius: 50%;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: none;\n opacity: 0.92;\n transition:\n background 120ms ease,\n opacity 120ms ease;\n }\n\n .cf-ic svg {\n width: 22px;\n height: 22px;\n fill: currentColor;\n display: block;\n }\n\n .cf-ic:hover,\n .cf-ic:focus-visible {\n background: rgba(255, 255, 255, 0.14);\n opacity: 1;\n outline: none;\n }\n\n /* the label above a button \u2014 the name and its key \u2014 in the bar's\n own bubble rather than the browser's slow yellow one */\n [data-tip] {\n position: relative;\n }\n\n [data-tip]::before {\n content: attr(data-tip);\n position: absolute;\n bottom: calc(100% + 10px);\n left: 50%;\n transform: translate(-50%, 4px);\n white-space: nowrap;\n background: rgba(12, 9, 4, 0.92);\n color: #fff;\n font-family: var(--cf-ui);\n font-size: 11px;\n font-weight: 500;\n letter-spacing: 0.06em;\n text-transform: none;\n padding: 6px 10px;\n border-radius: 4px;\n opacity: 0;\n pointer-events: none;\n transition:\n opacity 120ms ease 200ms,\n transform 120ms ease 200ms;\n }\n\n [data-tip]:hover::before,\n [data-tip]:focus-visible::before {\n opacity: 1;\n transform: translate(-50%, 0);\n }\n\n /* the last button's bubble hugs the right edge instead of the\n button's centre, so it never leaves the viewport */\n .cf-ic-last::before {\n left: auto;\n right: 0;\n transform: translate(0, 4px);\n }\n\n .cf-ic-last:hover::before,\n .cf-ic-last:focus-visible::before {\n transform: translate(0, 0);\n }\n\n /* the speaker and its fader: the fader is folded away until the\n pointer is on the speaker, then slides out beside it */\n .cf-vol {\n display: inline-flex;\n align-items: center;\n }\n\n .cf-vol-range {\n appearance: none;\n -webkit-appearance: none;\n width: 0;\n height: 3px;\n margin: 0;\n opacity: 0;\n border-radius: 2px;\n background: linear-gradient(\n to right,\n #fff calc(var(--cf-vol, 1) * 100%),\n rgba(255, 255, 255, 0.3) 0\n );\n cursor: pointer;\n outline: none;\n transition:\n width 200ms ease,\n margin 200ms ease,\n opacity 200ms ease;\n }\n\n .cf-vol:hover .cf-vol-range,\n .cf-vol:focus-within .cf-vol-range {\n width: 64px;\n margin: 0 12px 0 4px;\n opacity: 1;\n }\n\n .cf-vol-range::-webkit-slider-thumb {\n -webkit-appearance: none;\n appearance: none;\n width: 12px;\n height: 12px;\n border-radius: 50%;\n background: #fff;\n border: 0;\n }\n\n .cf-vol-range::-moz-range-thumb {\n width: 12px;\n height: 12px;\n border-radius: 50%;\n background: #fff;\n border: 0;\n }\n\n /* the chapter's name, beside the clock, is the way into the list */\n .cf-chap {\n appearance: none;\n border: 0;\n background: transparent;\n color: rgba(255, 255, 255, 0.78);\n font: inherit;\n font-size: 12.5px;\n letter-spacing: 0.04em;\n display: inline-flex;\n align-items: center;\n gap: 5px;\n height: 32px;\n padding: 0 6px 0 2px;\n border-radius: 4px;\n cursor: pointer;\n white-space: nowrap;\n max-width: min(34ch, 26cqw);\n transition: color 120ms ease;\n }\n\n .cf-chap span {\n opacity: 0.6;\n padding-right: 1px;\n }\n\n .cf-chap svg {\n width: 14px;\n height: 14px;\n flex: none;\n fill: currentColor;\n opacity: 0.7;\n transition: transform 160ms ease;\n }\n\n .cf-chap:hover,\n .cf-chap:focus-visible {\n color: #fff;\n outline: none;\n }\n\n .cf-chap:hover svg {\n transform: translateX(2px);\n }\n\n /* a button with its name on it, set like the link beside it */\n .cf-word {\n appearance: none;\n border: 0;\n background: transparent;\n color: rgba(255, 255, 255, 0.82);\n font-family: var(--cf-ui);\n font-size: 11px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n display: inline-flex;\n align-items: center;\n gap: 7px;\n height: 32px;\n padding: 0 12px 0 8px;\n border-radius: 16px;\n cursor: pointer;\n white-space: nowrap;\n transition:\n background 120ms ease,\n color 120ms ease;\n }\n\n .cf-word svg {\n width: 18px;\n height: 18px;\n fill: currentColor;\n }\n\n .cf-word:hover,\n .cf-word:focus-visible {\n background: rgba(255, 255, 255, 0.14);\n color: #fff;\n outline: none;\n }\n\n /* the confirmation glyph, fixed at the middle of the picture */\n .cf-burst {\n position: fixed;\n left: 50%;\n top: 50%;\n width: 88px;\n height: 88px;\n margin: -44px 0 0 -44px;\n border-radius: 50%;\n background: rgba(12, 9, 4, 0.55);\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n z-index: 4;\n animation: cf-burst 560ms ease-out forwards;\n }\n\n .cf-burst svg {\n width: 42px;\n height: 42px;\n fill: #fff;\n }\n\n @keyframes cf-burst {\n from {\n opacity: 1;\n transform: scale(0.72);\n }\n\n to {\n opacity: 0;\n transform: scale(1.5);\n }\n }\n\n /* the hand that stopped moving takes its pointer with it */\n .cf-page.is-idle {\n cursor: none;\n }\n\n /* CC is a word, set as a badge; on, it is underlined in the film's\n red the way every captions button on earth is */\n .cf-ic-cc {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.06em;\n position: relative;\n }\n\n .cf-ic-cc.is-on::after,\n .cf-ic.is-on::after {\n content: '';\n position: absolute;\n left: 13px;\n right: 13px;\n bottom: 7px;\n height: 2px;\n background: var(--cf-red);\n border-radius: 1px;\n }\n\n .cf-ic.is-on {\n position: relative;\n }\n\n .cf-time {\n margin-left: 8px;\n font-size: 12.5px;\n letter-spacing: 0.02em;\n font-variant-numeric: tabular-nums;\n color: rgba(255, 255, 255, 0.82);\n white-space: nowrap;\n }\n\n .cf-time b {\n font-weight: 500;\n color: #fff;\n }\n\n .cf-time i {\n font-style: normal;\n padding: 0 4px;\n opacity: 0.6;\n }\n\n /* the chapter title cannot be allowed to widen the control row */\n .cf-time em {\n display: inline-block;\n max-width: min(28ch, 22cqw);\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: bottom;\n font-style: normal;\n margin-left: 6px;\n color: rgba(255, 255, 255, 0.72);\n }\n\n .cf-spacer {\n flex: 1 1 auto;\n }\n\n .cf-link {\n color: rgba(255, 255, 255, 0.82);\n text-decoration: none;\n font-size: 11px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n padding: 0 12px;\n white-space: nowrap;\n }\n\n .cf-link:hover {\n color: #fff;\n text-decoration: underline;\n text-underline-offset: 3px;\n }\n\n /* only ever under ?debug: which cut of the film the browser has */\n .cf-build {\n color: rgba(255, 255, 255, 0.55);\n font:\n 10px/1 ui-monospace,\n monospace;\n letter-spacing: 0.04em;\n padding: 0 12px;\n white-space: nowrap;\n }\n\n /* embedded, the iframe wraps the film AND its transport directly;\n the app's own chrome inside the iframe stands down instead */\n body.cf-embedded .topbar,\n body.cf-embedded .footer {\n display: none;\n }\n\n /* THE FILM ESCAPES THE APP'S PAGE CONTAINER. .page centres a\n measured column with gutters and a top offset \u2014 right for every\n demo page, and a pink-tinted mat around a film that owns its\n frame. Both film bodies flatten it; the film's own layout is the\n page. (This mattered only once cf-page left position: fixed \u2014\n fixed elements never felt the container.) */\n body.cf-film .page,\n body.cf-embedded .page {\n width: 100%;\n margin: 0;\n padding: 0;\n }\n\n /* a clear beat owns nothing on the glass, the numeral included */\n .cf-clear .cf-ghost {\n display: none;\n }\n\n /* ---- the gate --------------------------------------------------- */\n /* the card stands BESIDE the tower, never over it: the boot pose\n seats the building left of centre, so the door takes the right\n third \u2014 the same real estate the title beat's type owns \u2014 and\n its wash leans that way too instead of dimming the whole frame */\n /* THE DOOR DOES NOT BLUR THE BUILDING. A poster's job is to make\n the subject look like the reason to press play; a blur makes it\n look like a placeholder. The scene stays sharp and keeps\n turning (see the poster orbit) \u2014 the only thing over it is a\n ground for the type, raked away from the tower. */\n .cf-gate {\n position: absolute;\n inset: 0;\n z-index: 7;\n display: flex;\n align-items: center;\n justify-content: flex-end;\n background: linear-gradient(\n 100deg,\n rgba(24, 18, 8, 0) 26%,\n rgba(24, 18, 8, 0.28) 52%,\n rgba(22, 16, 7, 0.72) 82%\n );\n }\n\n /* THE DOOR SCALES, IT DOES NOT RE-LAY.\n \n A poster is a fixed composition: the wordmark sits hard right of\n the building, the rule hangs above it, the seal stamps below. At\n 735 x 551 \u2014 the size it was drawn at \u2014 every number here is a\n pixel. Smaller than that, all of them shrink together and the\n composition survives; a breakpoint would instead have re-laid it\n at two or three widths and been wrong at every width between.\n \n --gu is that one number: 1px at the design size and less below\n it, driven by whichever of the two axes is scarcer, so a short\n wide shot compresses exactly as a narrow tall one does. It never\n exceeds 1px, so the door never grows past the poster it is. */\n .cf-gate {\n --gu: min(1px, 0.136cqw, 0.181cqh);\n }\n\n .cf-gate-in {\n position: relative;\n text-align: center;\n font-family: var(--cf-ui);\n color: #f7f0e0;\n /* hard right: the poster is a two-column composition \u2014 building\n in one half, wordmark in the other \u2014 and the wordmark drifting\n toward the middle closes the gap that makes it one */\n margin-right: calc(132 * var(--gu));\n }\n\n /* the museum frame: hairline, inset like a mat, above the scene\n and under the type */\n .cf-gate-frame {\n position: absolute;\n inset: min(30px, 2.4cqw);\n border: 1px solid rgba(247, 240, 224, 0.34);\n pointer-events: none;\n animation: cf-mg-fade 1200ms ease-out both;\n }\n\n /* the spine: vertical Japanese down the inside of the frame */\n .cf-gate-vert {\n position: absolute;\n top: 50%;\n left: min(58px, 4.6cqw);\n transform: translateY(-50%);\n writing-mode: vertical-rl;\n white-space: nowrap;\n font-family: var(--cf-ui);\n font-weight: 600;\n font-size: clamp(10px, 0.95cqw, 13px);\n letter-spacing: 0.3em;\n color: rgba(247, 240, 224, 0.66);\n animation: cf-mg-fade 900ms ease-out both;\n animation-delay: 2050ms;\n }\n\n .cf-gate-k {\n position: relative;\n margin: 0;\n font-family: var(--cf-display);\n font-weight: 800;\n font-size: min(calc(260 * var(--gu)), 15cqw);\n letter-spacing: -0.03em;\n white-space: nowrap;\n line-height: 1.02;\n text-shadow: 0 8px 70px rgba(20, 14, 4, 0.55);\n }\n\n /* the ghost: the title again, enormous and hollow, standing behind\n itself \u2014 the poster's depth comes from one word at two weights */\n .cf-gate-ghost {\n position: absolute;\n left: 50%;\n top: 44%;\n transform: translate(-50%, -50%) scale(1.9);\n font-size: 1em;\n line-height: 1;\n color: transparent;\n -webkit-text-stroke: 1px rgba(247, 240, 224, 0.2);\n white-space: nowrap;\n pointer-events: none;\n animation: cf-mg-ghost 2400ms ease-out both;\n animation-delay: 700ms;\n }\n\n @keyframes cf-mg-ghost {\n 0% {\n opacity: 0;\n transform: translate(-50%, -50%) scale(2.05);\n }\n\n 100% {\n opacity: 1;\n transform: translate(-50%, -50%) scale(1.9);\n }\n }\n\n /* the index strip along the foot of the frame */\n .cf-gate-index {\n position: absolute;\n left: min(90px, 7cqw);\n right: min(90px, 7cqw);\n bottom: clamp(30px, 5.4vh, 56px);\n display: flex;\n flex-wrap: wrap;\n justify-content: center;\n gap: 6px min(36px, 2.2cqw);\n margin: 0;\n font-family: var(--cf-ui);\n font-size: clamp(9px, 0.8cqw, 11px);\n font-weight: 700;\n letter-spacing: 0.22em;\n color: rgba(247, 240, 224, 0.62);\n animation: cf-mg-fade 900ms ease-out both;\n animation-delay: 2500ms;\n }\n\n .cf-gate-index span {\n white-space: nowrap;\n }\n\n .cf-gate-t {\n margin: calc(10 * var(--gu)) 0 0;\n font-size: calc(15 * var(--gu));\n font-weight: 800;\n letter-spacing: 0.5em;\n text-indent: 0.5em;\n }\n\n .cf-gate-s {\n margin: calc(6 * var(--gu)) 0 calc(30 * var(--gu));\n font-size: calc(12 * var(--gu));\n letter-spacing: 0.14em;\n color: rgba(247, 240, 224, 0.72);\n }\n\n /* the claim under the subtitle: the display serif, italic, a step\n back in the ink; and under it the four words, tiny and wide */\n .cf-gate-live {\n margin: calc(14 * var(--gu)) 0 0;\n font-family: var(--cf-display);\n font-style: italic;\n font-size: calc(16 * var(--gu));\n line-height: 1.4;\n letter-spacing: 0.01em;\n color: rgba(247, 240, 224, 0.78);\n max-width: 40ch;\n text-wrap: balance;\n }\n\n .cf-gate-live-k {\n margin: calc(8 * var(--gu)) 0 calc(22 * var(--gu));\n font-family: var(--cf-ui);\n font-size: calc(10 * var(--gu));\n letter-spacing: 0.22em;\n text-transform: uppercase;\n color: rgba(247, 240, 224, 0.5);\n }\n\n .cf-gate-row {\n display: flex;\n gap: calc(12 * var(--gu));\n justify-content: center;\n }\n\n .cf-go {\n appearance: none;\n font: inherit;\n font-family: var(--cf-ui);\n font-size: calc(13 * var(--gu));\n font-weight: 600;\n letter-spacing: 0.14em;\n cursor: pointer;\n padding: calc(14 * var(--gu)) calc(30 * var(--gu));\n border-radius: 999px;\n border: 1px solid #f2e9d2;\n background: #f2e9d2;\n color: #2e2515;\n box-shadow: 0 10px 34px rgba(20, 14, 4, 0.35);\n transition:\n transform 220ms cubic-bezier(0.22, 1, 0.36, 1),\n box-shadow 220ms ease;\n }\n\n .cf-go:hover {\n transform: translateY(-2px);\n box-shadow: 0 16px 44px rgba(20, 14, 4, 0.42);\n }\n\n .cf-go:disabled {\n opacity: 0.45;\n cursor: default;\n }\n\n .cf-go.is-quiet {\n background: transparent;\n color: #f2e9d2;\n border-color: rgba(242, 233, 210, 0.5);\n }\n\n .cf-go.is-quiet:hover {\n border-color: #f2e9d2;\n }\n\n /* ---- the title package ---------------------------------------- *\n One motion system for front and back matter, staged like a\n broadcast title: rule, glyphs, mark, sub, seal, controls. All\n CSS \u2014 these screens live outside the score on purpose, since\n both exist precisely when the film is not running. */\n .cf-matter {\n position: relative;\n }\n\n .cf-mg-rule {\n display: block;\n width: 1px;\n height: 56px;\n margin: 0 auto 18px;\n background: rgba(247, 240, 224, 0.65);\n transform-origin: 50% 0;\n animation: cf-mg-rule 900ms cubic-bezier(0.22, 1, 0.36, 1) both;\n }\n\n @keyframes cf-mg-rule {\n 0% {\n transform: scaleY(0);\n }\n\n 100% {\n transform: scaleY(1);\n }\n }\n\n .cf-mg-g1,\n .cf-mg-g2 {\n display: inline-block;\n animation: cf-mg-glyph 1300ms cubic-bezier(0.22, 1, 0.36, 1) both;\n animation-delay: 350ms;\n }\n\n .cf-mg-g2 {\n animation-delay: 650ms;\n }\n\n @keyframes cf-mg-glyph {\n 0% {\n opacity: 0;\n filter: blur(16px);\n transform: translateY(10px);\n }\n\n 100% {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n }\n\n /* the wordmark tracks IN \u2014 from letterspaced air to its set width,\n the oldest move in broadcast titles because nothing else says\n \"this is the name\" as quietly */\n .cf-mg-mark {\n white-space: nowrap;\n transform-origin: center;\n animation: cf-mg-track 1400ms cubic-bezier(0.22, 1, 0.36, 1) both;\n animation-delay: 1050ms;\n }\n\n /* THE WORDMARK NO LONGER RE-MEASURES ITSELF.\n Tracking in from 1.1em added about 250px of laid-out width at the\n first frame \u2014 so on anything but a wide screen the mark started\n wrapped onto two lines and un-wrapped partway through its own\n animation, taking the card under it with it. The same gesture\n reads identically as a scale on the X axis, which is a composited\n transform and reflows nothing. */\n @keyframes cf-mg-track {\n 0% {\n opacity: 0;\n transform: scaleX(1.16);\n }\n\n 100% {\n opacity: 1;\n transform: scaleX(1);\n }\n }\n\n .cf-mg-sub {\n animation: cf-mg-fade 800ms ease-out both;\n animation-delay: 1650ms;\n }\n\n .cf-mg-row {\n animation: cf-mg-fade 800ms ease-out both;\n animation-delay: 2250ms;\n }\n\n /* centred as a block AND line by line: the shorthand margin used to\n cancel the auto inline margins, so the column sat at the card's\n left edge with its lines centred inside a box narrower than its\n first line */\n .cf-mg-credits {\n display: flex;\n flex-direction: column;\n align-items: center;\n white-space: nowrap;\n max-width: 88cqw;\n gap: 4px;\n margin: 0 auto 22px;\n font-size: 11px;\n letter-spacing: 0.14em;\n color: rgba(247, 240, 224, 0.6);\n animation: cf-mg-fade 900ms ease-out both;\n animation-delay: 2050ms;\n }\n\n @keyframes cf-mg-fade {\n 0% {\n opacity: 0;\n transform: translateY(6px);\n }\n\n 100% {\n opacity: 1;\n transform: translateY(0);\n }\n }\n\n /* the seal: the lineup's stamp, miniature \u2014 it lands hard and\n late, canted the way a hand cants it */\n /* on the end card the seal belongs IN the card: hung off the right\n edge it floated in the corner of the frame with nothing to sit on */\n .cf-end-in .cf-mg-seal {\n position: static;\n margin: 0 auto 16px;\n }\n\n /* the open end of the date. A dash at the numeral's own weight is a\n slab as wide as a digit and reads as a fifth figure. */\n .cf-end-dash {\n font-style: normal;\n font-size: 0.4em;\n font-weight: 600;\n vertical-align: 0.5em;\n margin-left: 0.08em;\n opacity: 0.7;\n }\n\n /* THE SEAL IS A DISC AND THE WORD SITS ON ITS CENTRE.\n It was a vertical badge with letterspaced Latin running down it,\n and letter-spacing adds its space AFTER the last letter \u2014 so the\n word was always sitting one space above the middle of its own\n box, which is exactly the kind of thing you cannot stop seeing.\n Horizontal, tracked from the centre (text-indent pays the\n trailing space back), on a round stamp. */\n /* the stamp is part of the poster, so it scales with the poster \u2014\n every number here is in the door's own unit (see --gu) */\n .cf-mg-seal {\n position: absolute;\n top: calc(4 * var(--gu, 1px));\n right: calc(-76 * var(--gu, 1px));\n display: grid;\n place-items: center;\n width: calc(64 * var(--gu, 1px));\n height: calc(64 * var(--gu, 1px));\n font-family: var(--cf-ui);\n font-weight: 700;\n font-size: calc(15 * var(--gu, 1px));\n letter-spacing: 0.12em;\n text-indent: 0.12em;\n text-transform: uppercase;\n color: #f7f0e0;\n background: var(--cf-mark);\n border-radius: 50%;\n animation: cf-mg-seal 500ms cubic-bezier(0.16, 1.2, 0.3, 1) both;\n animation-delay: 1900ms;\n }\n\n @keyframes cf-mg-seal {\n 0% {\n opacity: 0;\n transform: rotate(-4deg) scale(1.6);\n }\n\n 30% {\n opacity: 1;\n transform: rotate(-4deg) scale(0.97);\n }\n\n 100% {\n opacity: 0.94;\n transform: rotate(-4deg) scale(1);\n }\n }\n\n /* ---- the end card ---------------------------------------------- */\n .cf-end {\n position: absolute;\n inset: 0;\n z-index: 6;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(28, 22, 10, 0.58);\n backdrop-filter: blur(1.5px);\n }\n\n .cf-end-in {\n text-align: center;\n font-family: var(--cf-ui);\n color: #f7f0e0;\n }\n\n .cf-end-k {\n margin: 0;\n font-family: var(--cf-display);\n font-weight: 800;\n /* '1882 \u2014' is six characters, not four: at 19cqw it was six times\n the viewport wide and it set the width of the whole card */\n font-size: min(150px, min(15vh, 9cqw));\n letter-spacing: -0.03em;\n white-space: nowrap;\n line-height: 1;\n text-shadow: 0 6px 60px rgba(20, 14, 4, 0.55);\n }\n\n .cf-end-t {\n margin: 12px 0 0;\n font-size: 14px;\n font-weight: 800;\n letter-spacing: 0.5em;\n text-indent: 0.5em;\n }\n\n .cf-end-s {\n margin: 6px 0 26px;\n font-size: 12px;\n letter-spacing: 0.14em;\n color: rgba(247, 240, 224, 0.7);\n }\n\n /* ---- the stamp -------------------------------------------------- *\n A hanko, not a title: it strikes, settles at once, and is simply\n replaced by the next. Seal-red on purpose \u2014 it shares the\n annotation's ink because it IS an annotation, pressed over each\n tower as the lineup calls the roll. */\n .cf-stamp {\n position: absolute;\n inset: 0;\n z-index: 3;\n display: flex;\n align-items: center;\n justify-content: center;\n margin: 0;\n pointer-events: none;\n font-family: var(--cf-display);\n font-weight: 700;\n font-size: min(24vh, 16cqw);\n letter-spacing: 0.04em;\n color: var(--cf-mark);\n mix-blend-mode: multiply;\n animation: cf-stamp 700ms cubic-bezier(0.16, 1.3, 0.3, 1) both;\n }\n\n @keyframes cf-stamp {\n 0% {\n opacity: 0;\n transform: rotate(-2.5deg) scale(1.5);\n }\n\n 16% {\n opacity: 0.92;\n transform: rotate(-2.5deg) scale(0.99);\n }\n\n 100% {\n opacity: 0.88;\n transform: rotate(-2.5deg) scale(1);\n }\n }\n\n /* the app's own chrome, gone: this route is a frame, not a page */\n body.cf-film .topbar,\n body.cf-film .footer {\n display: none;\n }\n\n /* A ROMAN WORD IS NOT TWO GLYPHS.\n The block was cut for a two-character CJK setting, where the type\n size and the column width have nothing to do with each other. A\n Latin word's width IS its letter count, so 'Evangelistes' set at\n the size that suited 'Obra' runs off the frame \u2014 and a word that\n wraps to two lines lands the whole plate at a different height.\n So: never wrap, and take the size from the count the plane\n already carries (--cf-glyphs), capped by the design size. 0.56em\n is about the average advance of this face. */\n .cf-kanji.is-latin {\n font-family: var(--cf-display);\n font-weight: 800;\n text-transform: uppercase;\n color: var(--cf-chap);\n writing-mode: horizontal-tb;\n white-space: nowrap;\n font-size: min(\n min(104px, 6.2cqw),\n calc(var(--cf-fit, 42cqw) / (var(--cf-glyphs, 4) * 0.56))\n );\n letter-spacing: -0.02em;\n line-height: 0.92;\n }\n .is-title .cf-kanji.is-latin {\n --cf-fit: 46cqw;\n font-size: min(\n min(142px, 8.6cqw),\n calc(var(--cf-fit) / (var(--cf-glyphs, 4) * 0.56))\n );\n }\n .is-plate .cf-kanji.is-latin {\n --cf-fit: 24cqw;\n writing-mode: horizontal-tb;\n font-size: min(\n min(76px, 4.8cqw),\n calc(var(--cf-fit) / (var(--cf-glyphs, 4) * 0.56))\n );\n letter-spacing: -0.01em;\n max-width: 26cqw;\n }\n .is-point .cf-kanji.is-latin {\n --cf-fit: 32cqw;\n font-size: min(\n min(82px, 5.2cqw),\n calc(var(--cf-fit) / (var(--cf-glyphs, 4) * 0.56))\n );\n }\n /* ---- THE RESPONSIVE OVERRIDES, LAST ON PURPOSE ------------- *\n\n These are overrides, and an override that appears before the\n rule it overrides does nothing at equal specificity. They sat\n a thousand lines ABOVE the gate and the rail they were written\n to compress, so none of them has ever applied: the door kept\n its full stack at every width and ran through its own buttons\n on anything narrow. Moving them to the foot of the sheet is\n the whole fix \u2014 nothing here changed except where it is.\n\n They ask the FILM and the SHOT rather than the window, so a\n film in a small frame gets the same compact layout a phone\n does. See the container declarations on .cf-page and\n .cf-stage above. */\n\n @container film (max-width: 1180px) {\n .cf-block {\n max-width: min(38ch, 56cqw);\n }\n\n .cf-block.is-plate {\n max-width: min(46ch, 52cqw);\n }\n\n .is-plate .cf-kanji.is-latin {\n --cf-fit: 28cqw;\n max-width: 30cqw;\n }\n\n .cf-say {\n max-width: min(28ch, 42cqw);\n }\n }\n\n @container film (max-width: 860px) {\n /* the plate stops being two columns: the word goes over the cues,\n and the rule that separated them becomes the rule under it */\n .cf-block.is-plate {\n top: auto;\n bottom: 12%;\n right: 5.5%;\n left: 5.5%;\n display: block;\n transform: none;\n max-width: none;\n }\n\n .is-plate .cf-plane.is-glyph {\n border-right: 0;\n border-bottom: 2px solid var(--cf-chap);\n padding-right: 0;\n padding-bottom: 8px;\n margin-bottom: 14px;\n }\n\n /* stacked under the word, the cues read from the left again */\n .is-plate .cf-plane:not(.is-glyph) {\n align-items: flex-start;\n text-align: left;\n }\n\n .is-plate .cf-read {\n justify-content: flex-start;\n }\n\n .is-plate .cf-kanji.is-latin {\n --cf-fit: 84cqw;\n max-width: none;\n }\n\n .cf-block.is-title,\n .cf-block.is-lower,\n .cf-block.is-point {\n left: 5.5%;\n right: 5.5%;\n top: auto;\n bottom: 13%;\n max-width: none;\n text-align: left;\n }\n\n .is-title .cf-read {\n justify-content: flex-start;\n }\n\n .is-title .cf-kicker {\n flex-direction: row;\n }\n\n .cf-kanji.is-latin,\n .is-title .cf-kanji.is-latin,\n .is-point .cf-kanji.is-latin {\n --cf-fit: 84cqw;\n }\n\n .cf-say {\n max-width: min(32ch, 84cqw);\n }\n\n /* a numeral three-quarters of the screen wide behind two columns\n of type is a texture; behind one column it is a mess */\n .cf-ghost,\n .cf-plate .cf-ghost {\n display: none;\n }\n\n .cf-photo figure {\n width: min(46cqw, 260px);\n }\n\n /* a narrow frame: the rule drops under the title again */\n .cf-rail {\n flex-wrap: wrap;\n }\n\n .cf-years {\n flex-basis: 100%;\n width: min(420px, 56cqw);\n margin-left: 0;\n margin-top: 8px;\n }\n\n .cf-subs {\n font-size: 15px;\n max-width: 88cqw;\n }\n }\n\n @container film (max-width: 560px) {\n .cf-kicker {\n font-size: 10px;\n letter-spacing: 0.09em;\n }\n\n .cf-say {\n font-size: 15px;\n margin-bottom: 8px;\n }\n\n .cf-read {\n margin-bottom: 12px;\n }\n\n .cf-kanji {\n margin-bottom: 12px;\n }\n\n .cf-gate-index,\n .cf-rail {\n display: none;\n }\n\n .cf-gate-in.cf-matter {\n margin-right: 0;\n padding-inline: 20px;\n }\n\n .cf-gate-vert {\n display: none;\n }\n\n .cf-mg-seal {\n position: static;\n margin: 14px auto 0;\n }\n\n .cf-photo {\n display: none;\n }\n\n .cf-subs {\n font-size: 14px;\n bottom: 10%;\n }\n }\n\n @container shot (max-height: 620px) {\n .cf-kanji.is-latin {\n font-size: min(\n clamp(30px, 8vh, 64px),\n calc(var(--cf-fit, 42cqw) / (var(--cf-glyphs, 4) * 0.56))\n );\n }\n\n .cf-say {\n font-size: clamp(13px, 2.4vh, 18px);\n margin-bottom: 7px;\n }\n\n .cf-block.is-lower,\n .cf-block.is-title {\n bottom: 8%;\n }\n\n .has-subs .cf-block.is-lower,\n .has-subs .cf-block.is-title {\n bottom: 17%;\n }\n }\n /* Sagrada: typography belongs to the shot, not the phone viewport.\n Keep a compact editorial corner in square/portrait containers;\n never turn the architecture into the backdrop for a full-width card. */\n @container shot (max-aspect-ratio: 4/3) or ((max-width: 900px) and (max-height: 500px)) {\n .is-sagrada .cf-type {\n --cf-lx: 0;\n --cf-ly: 0;\n }\n .is-sagrada .cf-block,\n .is-sagrada .cf-block.is-title,\n .is-sagrada .cf-block.is-lower,\n .is-sagrada .cf-block.is-point,\n .is-sagrada .cf-block.is-plate {\n left: 5.5%;\n right: auto;\n top: auto;\n bottom: 12%;\n width: 48cqw;\n max-width: 48cqw;\n display: block;\n text-align: left;\n }\n .is-sagrada .cf-kanji.is-latin,\n .is-sagrada .is-title .cf-kanji.is-latin,\n .is-sagrada .is-point .cf-kanji.is-latin,\n .is-sagrada .is-plate .cf-kanji.is-latin {\n --cf-fit: 46cqw;\n font-size: min(\n 5.6cqw,\n 6cqh,\n calc(46cqw / (var(--cf-glyphs, 4) * 0.56))\n );\n max-width: 46cqw;\n margin-bottom: 1.5cqw;\n }\n .is-sagrada .cf-kicker {\n font-size: clamp(7px, 1.65cqw, 12px);\n gap: 1.5cqw;\n margin-bottom: 1.5cqw;\n letter-spacing: 0.07em;\n }\n .is-sagrada .cf-kicker > span {\n white-space: normal;\n }\n .is-sagrada .cf-kicker::after {\n min-width: 2cqw;\n height: 1px;\n }\n .is-sagrada .cf-read {\n gap: 0.8cqw 1.2cqw;\n margin-bottom: 2cqw;\n justify-content: flex-start;\n }\n .is-sagrada .cf-romaji,\n .is-sagrada .cf-block:not(:has(.is-glyph)) .cf-romaji {\n font-size: clamp(8px, 1.9cqw, 13px);\n letter-spacing: 0.06em;\n }\n .is-sagrada .cf-gloss,\n .is-sagrada .cf-block:not(:has(.is-glyph)) .cf-gloss {\n font-size: clamp(8px, 1.9cqw, 13px);\n }\n .is-sagrada .cf-say {\n font-size: clamp(9px, 2.1cqw, 15px);\n line-height: 1.2;\n max-width: 48cqw;\n margin-bottom: 1cqw;\n }\n .is-sagrada .is-plate .cf-plane.is-glyph {\n padding: 0 0 1.2cqw;\n margin-bottom: 1.5cqw;\n border-width: 1px;\n }\n .is-sagrada .cf-subs {\n font-size: clamp(10px, 2.4cqw, 15px);\n max-width: 80cqw;\n bottom: 5%;\n }\n .is-sagrada .cf-ghost {\n display: none;\n }\n }\n</style>", {
|
|
4398
|
+
strictMode: true,
|
|
4399
|
+
scope: () => ({
|
|
4400
|
+
on,
|
|
4401
|
+
Joins,
|
|
4402
|
+
Track,
|
|
4403
|
+
Plate,
|
|
4404
|
+
Stamp,
|
|
4405
|
+
Clip,
|
|
4406
|
+
Insert,
|
|
4407
|
+
Captions,
|
|
4408
|
+
Rail,
|
|
4409
|
+
Choreo,
|
|
4410
|
+
motion,
|
|
4411
|
+
at,
|
|
4412
|
+
Gate,
|
|
4413
|
+
EndCard,
|
|
4414
|
+
Menu,
|
|
4415
|
+
Burst,
|
|
4416
|
+
Player,
|
|
4417
|
+
FilmGraph
|
|
4418
|
+
})
|
|
4419
|
+
}), this);
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
|
|
4423
|
+
export { Film, TICK };
|
|
4424
|
+
//# sourceMappingURL=film.js.map
|