@cardstack/choreo 0.0.0 → 0.1.0-unstable.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -6
  4. package/addon-main.cjs +4 -0
  5. package/declarations/anchors.d.ts +26 -0
  6. package/declarations/anchors.d.ts.map +1 -0
  7. package/declarations/arming.d.ts +33 -0
  8. package/declarations/arming.d.ts.map +1 -0
  9. package/declarations/beacon.d.ts +9 -0
  10. package/declarations/beacon.d.ts.map +1 -0
  11. package/declarations/beacons.d.ts +23 -0
  12. package/declarations/beacons.d.ts.map +1 -0
  13. package/declarations/changeset.d.ts +46 -0
  14. package/declarations/changeset.d.ts.map +1 -0
  15. package/declarations/choreo.d.ts +261 -0
  16. package/declarations/choreo.d.ts.map +1 -0
  17. package/declarations/compile.d.ts +45 -0
  18. package/declarations/compile.d.ts.map +1 -0
  19. package/declarations/deliver.d.ts +44 -0
  20. package/declarations/deliver.d.ts.map +1 -0
  21. package/declarations/easings.d.ts +20 -0
  22. package/declarations/easings.d.ts.map +1 -0
  23. package/declarations/far.d.ts +33 -0
  24. package/declarations/far.d.ts.map +1 -0
  25. package/declarations/film/clip.d.ts +51 -0
  26. package/declarations/film/clip.d.ts.map +1 -0
  27. package/declarations/film/clips.d.ts +155 -0
  28. package/declarations/film/clips.d.ts.map +1 -0
  29. package/declarations/film/film.d.ts +1208 -0
  30. package/declarations/film/film.d.ts.map +1 -0
  31. package/declarations/film/graph/adjust.d.ts +112 -0
  32. package/declarations/film/graph/adjust.d.ts.map +1 -0
  33. package/declarations/film/graph/compile.d.ts +140 -0
  34. package/declarations/film/graph/compile.d.ts.map +1 -0
  35. package/declarations/film/graph/host.d.ts +67 -0
  36. package/declarations/film/graph/host.d.ts.map +1 -0
  37. package/declarations/film/graph/nodes.d.ts +287 -0
  38. package/declarations/film/graph/nodes.d.ts.map +1 -0
  39. package/declarations/film/index.d.ts +24 -0
  40. package/declarations/film/index.d.ts.map +1 -0
  41. package/declarations/film/joins.d.ts +143 -0
  42. package/declarations/film/joins.d.ts.map +1 -0
  43. package/declarations/film/math.d.ts +15 -0
  44. package/declarations/film/math.d.ts.map +1 -0
  45. package/declarations/film/overlays.d.ts +54 -0
  46. package/declarations/film/overlays.d.ts.map +1 -0
  47. package/declarations/film/picture.d.ts +104 -0
  48. package/declarations/film/picture.d.ts.map +1 -0
  49. package/declarations/film/plate.d.ts +37 -0
  50. package/declarations/film/plate.d.ts.map +1 -0
  51. package/declarations/film/player.d.ts +120 -0
  52. package/declarations/film/player.d.ts.map +1 -0
  53. package/declarations/film/rail.d.ts +40 -0
  54. package/declarations/film/rail.d.ts.map +1 -0
  55. package/declarations/film/schedule.d.ts +93 -0
  56. package/declarations/film/schedule.d.ts.map +1 -0
  57. package/declarations/film/seam.d.ts +38 -0
  58. package/declarations/film/seam.d.ts.map +1 -0
  59. package/declarations/film/titles.d.ts +37 -0
  60. package/declarations/film/titles.d.ts.map +1 -0
  61. package/declarations/film/types.d.ts +544 -0
  62. package/declarations/film/types.d.ts.map +1 -0
  63. package/declarations/film.d.ts +3 -0
  64. package/declarations/film.d.ts.map +1 -0
  65. package/declarations/gesture.d.ts +33 -0
  66. package/declarations/gesture.d.ts.map +1 -0
  67. package/declarations/index.d.ts +26 -0
  68. package/declarations/index.d.ts.map +1 -0
  69. package/declarations/measure.d.ts +29 -0
  70. package/declarations/measure.d.ts.map +1 -0
  71. package/declarations/path.d.ts +73 -0
  72. package/declarations/path.d.ts.map +1 -0
  73. package/declarations/registry.d.ts +46 -0
  74. package/declarations/registry.d.ts.map +1 -0
  75. package/declarations/run.d.ts +406 -0
  76. package/declarations/run.d.ts.map +1 -0
  77. package/declarations/space.d.ts +31 -0
  78. package/declarations/space.d.ts.map +1 -0
  79. package/declarations/steps.d.ts +479 -0
  80. package/declarations/steps.d.ts.map +1 -0
  81. package/declarations/test-support/index.d.ts +30 -0
  82. package/declarations/test-support/index.d.ts.map +1 -0
  83. package/declarations/types.d.ts +761 -0
  84. package/declarations/types.d.ts.map +1 -0
  85. package/dist/anchors.js +34 -0
  86. package/dist/anchors.js.map +1 -0
  87. package/dist/arming.js +120 -0
  88. package/dist/arming.js.map +1 -0
  89. package/dist/beacon.js +25 -0
  90. package/dist/beacon.js.map +1 -0
  91. package/dist/beacons.js +77 -0
  92. package/dist/beacons.js.map +1 -0
  93. package/dist/changeset.js +129 -0
  94. package/dist/changeset.js.map +1 -0
  95. package/dist/choreo.js +1026 -0
  96. package/dist/choreo.js.map +1 -0
  97. package/dist/compile.js +1403 -0
  98. package/dist/compile.js.map +1 -0
  99. package/dist/deliver.js +326 -0
  100. package/dist/deliver.js.map +1 -0
  101. package/dist/easings.js +39 -0
  102. package/dist/easings.js.map +1 -0
  103. package/dist/far.js +147 -0
  104. package/dist/far.js.map +1 -0
  105. package/dist/film/clip.js +100 -0
  106. package/dist/film/clip.js.map +1 -0
  107. package/dist/film/clips.js +147 -0
  108. package/dist/film/clips.js.map +1 -0
  109. package/dist/film/film.js +4424 -0
  110. package/dist/film/film.js.map +1 -0
  111. package/dist/film/graph/adjust.js +160 -0
  112. package/dist/film/graph/adjust.js.map +1 -0
  113. package/dist/film/graph/compile.js +225 -0
  114. package/dist/film/graph/compile.js.map +1 -0
  115. package/dist/film/graph/host.js +77 -0
  116. package/dist/film/graph/host.js.map +1 -0
  117. package/dist/film/graph/nodes.js +500 -0
  118. package/dist/film/graph/nodes.js.map +1 -0
  119. package/dist/film/index.js +18 -0
  120. package/dist/film/index.js.map +1 -0
  121. package/dist/film/joins.js +260 -0
  122. package/dist/film/joins.js.map +1 -0
  123. package/dist/film/math.js +49 -0
  124. package/dist/film/math.js.map +1 -0
  125. package/dist/film/overlays.js +66 -0
  126. package/dist/film/overlays.js.map +1 -0
  127. package/dist/film/picture.js +58 -0
  128. package/dist/film/picture.js.map +1 -0
  129. package/dist/film/plate.js +36 -0
  130. package/dist/film/plate.js.map +1 -0
  131. package/dist/film/player.js +79 -0
  132. package/dist/film/player.js.map +1 -0
  133. package/dist/film/rail.js +38 -0
  134. package/dist/film/rail.js.map +1 -0
  135. package/dist/film/schedule.js +238 -0
  136. package/dist/film/schedule.js.map +1 -0
  137. package/dist/film/seam.js +64 -0
  138. package/dist/film/seam.js.map +1 -0
  139. package/dist/film/titles.js +45 -0
  140. package/dist/film/titles.js.map +1 -0
  141. package/dist/film/types.js +2 -0
  142. package/dist/film/types.js.map +1 -0
  143. package/dist/film.js +18 -0
  144. package/dist/film.js.map +1 -0
  145. package/dist/gesture.js +111 -0
  146. package/dist/gesture.js.map +1 -0
  147. package/dist/index.js +9 -0
  148. package/dist/index.js.map +1 -0
  149. package/dist/measure.js +99 -0
  150. package/dist/measure.js.map +1 -0
  151. package/dist/path.js +272 -0
  152. package/dist/path.js.map +1 -0
  153. package/dist/registry.js +55 -0
  154. package/dist/registry.js.map +1 -0
  155. package/dist/run.js +2423 -0
  156. package/dist/run.js.map +1 -0
  157. package/dist/space.js +57 -0
  158. package/dist/space.js.map +1 -0
  159. package/dist/steps.js +831 -0
  160. package/dist/steps.js.map +1 -0
  161. package/dist/test-support/index.js +150 -0
  162. package/dist/test-support/index.js.map +1 -0
  163. package/dist/types.js +2 -0
  164. package/dist/types.js.map +1 -0
  165. package/package.json +202 -6
@@ -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