rvlib-shipkit 2.2.0 → 2.4.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 (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -1,52 +1,131 @@
1
+ import { MOVES, type Move, type MoveKind } from './motion.ts'
2
+ import { type LineWord, type Moment, slotText, type Typed, typedFilm, type Words } from './words.ts'
3
+
1
4
  // the trailer language, its core words: one text file → the shots, their films, titles, sounds and cuts. Pure: no
2
5
  // file, no process; a film word that is not a core kind is looked up in the project CLI's film rows the caller
3
6
  // passes in. Every error carries its line, all at once, and a file with one error never builds
4
7
 
5
- export type TitleStyle = 'logo' | 'big' | 'caption' | 'corner' | 'line'
6
- export const TITLE_STYLES: readonly TitleStyle[] = ['logo', 'big', 'caption', 'corner', 'line']
7
- export const CUT_KINDS = [
8
- 'hard',
9
- 'fade',
10
- 'dip-black',
11
- 'dip-white',
12
- 'slide-left',
13
- 'slide-right',
14
- 'slide-up',
15
- 'slide-down',
16
- 'whip-left',
17
- 'whip-right',
18
- 'wipe-left',
19
- 'wipe-right',
20
- 'wipe-up',
21
- 'wipe-down',
22
- 'circle',
23
- 'pixel',
24
- ] as const
25
- export type CutKind = (typeof CUT_KINDS)[number]
26
- export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url'] as const
8
+ export type TitleStyle = 'logo' | 'big' | 'caption' | 'corner' | 'line' | 'short' | 'progress'
9
+ /** the styles a title line names; `progress` is its own line's */
10
+ export const TITLE_STYLES: readonly TitleStyle[] = ['logo', 'big', 'caption', 'corner', 'line', 'short']
11
+ /** the ways a cut moves, as its words spell them: `slide-left` moves the picture left */
12
+ const DIRS = ['left', 'right', 'up', 'down'] as const
13
+ /** every cut, its word and what draws it: an ffmpeg xfade transition by name, or `drawn` by the core (graph.ts) */
14
+ export const CUTS = {
15
+ hard: null,
16
+ fade: 'fade',
17
+ 'fade-fast': 'fadefast',
18
+ 'fade-slow': 'fadeslow',
19
+ dissolve: 'dissolve',
20
+ grays: 'fadegrays',
21
+ blur: 'hblur',
22
+ 'dip-black': 'fadeblack',
23
+ 'dip-white': 'fadewhite',
24
+ ...Object.fromEntries(DIRS.flatMap((d) => [
25
+ [`slide-${d}`, `slide${d}`],
26
+ [`cover-${d}`, `cover${d}`],
27
+ [`reveal-${d}`, `reveal${d}`],
28
+ [`wipe-${d}`, `wipe${d}`],
29
+ [`smooth-${d}`, `smooth${d}`],
30
+ [`slice-${d}`, { left: 'hlslice', right: 'hrslice', up: 'vuslice', down: 'vdslice' }[d]],
31
+ [`wind-${d}`, { left: 'hlwind', right: 'hrwind', up: 'vuwind', down: 'vdwind' }[d]],
32
+ [`whip-${d}`, 'drawn'],
33
+ ])),
34
+ 'squeeze-h': 'squeezeh',
35
+ 'squeeze-v': 'squeezev',
36
+ zoom: 'zoomin',
37
+ ...Object.fromEntries(['tl', 'tr', 'bl', 'br'].flatMap((c) => [
38
+ [`wipe-${c}`, `wipe${c}`],
39
+ [`diag-${c}`, `diag${c}`],
40
+ ])),
41
+ radial: 'radial',
42
+ 'open-h': 'horzopen',
43
+ 'close-h': 'horzclose',
44
+ 'open-v': 'vertopen',
45
+ 'close-v': 'vertclose',
46
+ circle: 'circleopen',
47
+ 'circle-close': 'circleclose',
48
+ 'circle-crop': 'circlecrop',
49
+ 'rect-crop': 'rectcrop',
50
+ pixel: 'pixelize',
51
+ glitch: 'drawn',
52
+ leak: 'drawn',
53
+ } as Readonly<Record<CutKind, string | null>>
54
+ export const CUT_KINDS = Object.keys(CUTS) as readonly CutKind[]
55
+ export type CutKind = 'hard' | 'fade' | 'fade-fast' | 'fade-slow' | 'dissolve' | 'grays' | 'blur' | 'dip-black' | 'dip-white'
56
+ | `${'slide' | 'cover' | 'reveal' | 'wipe' | 'smooth' | 'slice' | 'wind' | 'whip'}-${(typeof DIRS)[number]}`
57
+ | 'squeeze-h' | 'squeeze-v' | 'zoom' | `${'wipe' | 'diag'}-${'tl' | 'tr' | 'bl' | 'br'}` | 'radial'
58
+ | 'open-h' | 'close-h' | 'open-v' | 'close-v' | 'circle' | 'circle-close' | 'circle-crop' | 'rect-crop' | 'pixel' | 'glitch' | 'leak'
59
+ /** a drawn cut's length when its line names none: a whip snaps, a leak burns slowly */
60
+ export const CUT_SECONDS: Partial<Record<CutKind, number>> = { 'whip-left': 0.35, 'whip-right': 0.35, 'whip-up': 0.35, 'whip-down': 0.35, glitch: 0.3, leak: 0.8 }
61
+ export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url', 'page', 'grid', 'split'] as const
27
62
 
28
63
  /** the most frames a second a cut runs at */
29
64
  export const FPS_MAX = 60
30
65
  const DEFAULT_CUT_S = 0.5
31
66
 
67
+ /** how long a take films: what the shot shows rounded up to 2 s (`steps`, the default), exactly that (`exact`), or as
68
+ * long as it plays (`own`: no --seconds, the key holds none) */
69
+ export type TakeLength = 'steps' | 'exact' | 'own'
70
+
32
71
  /** a film row of the project CLI: a still writes one image at `--out`, a clip a video of `--seconds` */
33
72
  export interface FilmVerb {
34
73
  path: string[]
35
74
  film: 'still' | 'clip'
75
+ /** the words the row takes and their types (words.ts): every word checked at its line, values handed plain */
76
+ words?: Words
77
+ /** the paths the row's takes film: their world, over the unit's `stamp` */
78
+ stamp?: string[]
79
+ /** the row's take version: a bump films every take of the row again */
80
+ version?: number | string
81
+ /** `own`: the row films as long as it plays */
82
+ length?: 'own'
36
83
  description?: string
37
84
  }
38
85
 
86
+ /** a grid holds at most this many cells, 3 by 3 */
87
+ export const GRID_MAX = 9
88
+ /** a growing grid eases out over this long before each settle */
89
+ export const GROW_S = 1
90
+ /** a split's seams lean this far from upright by default: two panes top left and bottom right, three slanted columns */
91
+ export const SPLIT_ANGLE: Readonly<Record<number, number>> = { 2: 56, 3: 14 }
92
+ /** a split's panes slide in this long by default */
93
+ export const SPLIT_SLIDE = 0.3
94
+
39
95
  export type Film =
40
96
  | { kind: 'clip'; line: number; path: string }
41
97
  | { kind: 'image'; line: number; path: string }
42
98
  | { kind: 'card'; line: number }
43
- /** any command that writes the take at `{out}`; `still`: it writes an image, held for the shot */
44
- | { kind: 'run'; line: number; cmd: string; still?: true }
45
- /** a film row of the project CLI, its words as written: `<cli> <argv> --out <file> [--seconds <n>]` */
46
- | { kind: 'cli'; line: number; argv: string[]; output: 'still' | 'clip' }
99
+ /** any command that writes the take at `{out}`; `still`: it writes an image, held for the shot; `reads`: the files
100
+ * the command reads, their content part of the take's key */
101
+ | { kind: 'run'; line: number; cmd: string; still?: true; reads?: string[] }
102
+ /** a film row of the project CLI, its words as written: `<cli> <argv> --out <file> [--seconds <n>]`; its row's take
103
+ * version and length when the row has them */
104
+ | {
105
+ kind: 'cli'
106
+ line: number
107
+ argv: string[]
108
+ output: 'still' | 'clip'
109
+ version?: number | string
110
+ length?: 'own'
111
+ /** the row declares its words: argv holds them plain */
112
+ typed?: true
113
+ /** argv slots that are files as written: the build finds them and keys their content */
114
+ files?: number[]
115
+ /** the paths the row's takes film */
116
+ stamp?: string[]
117
+ }
47
118
  /** a page of the repo's dev url (a route) or any http url, through the house shot service; `viewport` its css
48
119
  * size, `wait` before it films; `still`: a png, else a clip of the page's largest canvas */
49
120
  | { kind: 'url'; line: number; route: string; viewport?: [number, number]; wait?: number; still?: true }
121
+ /** a page filmed frame by frame through the house shot service: `window[step](t)` before each frame, then its ready
122
+ * mark; `query` its words (`key value`, cues made seconds of the take) as the url's query, in the order written */
123
+ | { kind: 'page'; line: number; route: string; query: string[]; flags?: string[]; step: string; viewport?: [number, number]; wait?: number; still?: true; at?: number }
124
+ /** other shots' or footage blocks' takes tiled, each cell playing live; `pop`: cell i pops in at i times it; `grow`:
125
+ * nine cells growing out of the first, settled on the 2 by 2 block then the whole at these seconds */
126
+ | { kind: 'grid'; line: number; cells: string[]; pop: number; grow?: [number, number] }
127
+ /** two or three takes side by side, cut by slanted seams `angle` degrees from upright, each pane sliding in */
128
+ | { kind: 'split'; line: number; cells: string[]; angle: number; slide: number }
50
129
 
51
130
  /** the vertical short: a lockup over every frame, a watermark in a bottom corner, then the outro and the end card,
52
131
  * each drawn from the brand's `trailer.vertical` data (vertical.ts) */
@@ -72,12 +151,61 @@ export interface Title {
72
151
  place?: 'top' | 'bottom'
73
152
  /** knocked off the frame from this second of its shot: it tips and flies out */
74
153
  knock?: number
154
+ /** a `progress` line's step: the i-th of n notches filled under its name */
155
+ step?: { i: number; n: number }
156
+ /** the brand style it wears (`trailer.styles`); its `style` is the built in one under it */
157
+ named?: string
158
+ /** its look over its style's own */
159
+ fx?: TitleFx
160
+ /** how it comes in and goes out, over its style's own */
161
+ in?: Move
162
+ out?: Move
163
+ }
164
+
165
+ /** where a title stands: as `place` says; a short's caption in the frame's middle, a progress bar at the top; else
166
+ * the bottom */
167
+ export type TitlePlace = 'top' | 'middle' | 'bottom'
168
+ export const placeOf = (ti: Pick<Title, 'place' | 'style'>): TitlePlace => ti.place ?? (ti.style === 'short' ? 'middle' : ti.style === 'progress' ? 'top' : 'bottom')
169
+
170
+ /** a colour as a line writes it: `#rgb`, `#rrggbb`, or one of the look's (titles.ts resolves it) */
171
+ export const LOOK_COLOURS = ['light', 'edge', 'accent'] as const
172
+
173
+ /** a title's look over its style's: its face (a font file or a Google Fonts family), weight, size in pixels of a frame
174
+ * 1080 tall, fill, and its outline, shadow and glow, each a size in those pixels and a colour */
175
+ export interface TitleFx {
176
+ font?: string
177
+ weight?: number
178
+ size?: number
179
+ color?: string
180
+ gradient?: string[]
181
+ outline?: { px: number; color?: string }
182
+ shadow?: { px: number; color?: string }
183
+ glow?: { px: number; color?: string }
184
+ }
185
+
186
+ /** how a made sound is made (design call 9): its seed, the voice or engine it is made `by`, its length (`for`), an sfx
187
+ * trimmed to its first `hit` */
188
+ export interface SoundGen {
189
+ seed?: number
190
+ by?: string
191
+ for?: number
192
+ hit?: true
193
+ }
194
+
195
+ /** a sound a command writes at `{out}` (marks `{out}`, `{seconds}`), and the files it reads, keyed by their content */
196
+ export interface SoundRun {
197
+ cmd: string
198
+ reads?: string[]
75
199
  }
76
200
 
77
201
  export interface Sfx {
78
202
  line: number
79
- /** a file, or an id the repo's sound resolver knows */
203
+ /** a file, an id the repo's sound resolver knows, or a prompt (`prompt`); `run` for a run's sound */
80
204
  id: string
205
+ /** the id is a quoted prompt the house makes from */
206
+ prompt?: true
207
+ gen?: SoundGen
208
+ run?: SoundRun
81
209
  at: number
82
210
  volume: number
83
211
  }
@@ -87,6 +215,8 @@ export interface Voice {
87
215
  /** a line the repo's voice resolver finds by its text, or a recorded file */
88
216
  text?: string
89
217
  file?: string
218
+ gen?: SoundGen
219
+ run?: SoundRun
90
220
  at: number
91
221
  volume: number
92
222
  }
@@ -119,8 +249,13 @@ export interface SectionRef {
119
249
 
120
250
  export interface Music {
121
251
  line: number
122
- /** a file, `none`, or an id the repo's sound resolver knows */
252
+ /** a file, `none`, an id the repo's sound resolver knows, or a prompt (`prompt`); `run` for a run's music */
123
253
  song: string
254
+ prompt?: true
255
+ gen?: SoundGen
256
+ run?: SoundRun
257
+ /** it plays only in this music variant (`sk trailer --variant <name>`) */
258
+ variant?: string
124
259
  take?: string
125
260
  from: number
126
261
  fadeIn: number
@@ -159,6 +294,8 @@ export interface Shot {
159
294
  music: Music | null
160
295
  /** its own line failed: nothing else is checked against it */
161
296
  broken?: true
297
+ /** a `footage` block: filmed, never cut in */
298
+ offcut?: true
162
299
  }
163
300
 
164
301
  /** the tempo a line counts bars and beats in */
@@ -177,7 +314,8 @@ export interface Mark {
177
314
  at: number
178
315
  }
179
316
 
180
- /** a part of the timeline in bars: its shots land on its start, and the stitch caches it as one piece */
317
+ /** a part of the timeline in bars: its shots land on its start, and the stitch caches it as one piece. Under a
318
+ * score, it is also a part of the music: what each track plays over it */
181
319
  export interface Section {
182
320
  line: number
183
321
  name: string
@@ -186,10 +324,48 @@ export interface Section {
186
324
  at: number
187
325
  seconds: number
188
326
  marks: Mark[]
327
+ /** `defaults <words>`: what every declared film line of the section starts from */
328
+ defaults?: Word[]
329
+ /** its drum letters over the score's */
330
+ drums: Record<string, string>
331
+ plays: Play[]
332
+ }
333
+
334
+ /** `play <track> <expression>`: what a track plays over its section, TypeScript; `*` a record of tracks */
335
+ export interface Play {
336
+ line: number
337
+ track: string
338
+ expr: string
339
+ }
340
+
341
+ /** the cut's own music, written in the file (score/score.ts compiles it, score/synth.ts renders it) */
342
+ export interface Score {
343
+ line: number
344
+ /** the first section's clock: the score line's own */
345
+ clock: Clock
346
+ reverb?: { seconds: number; mix: number }
347
+ fadeIn: number
348
+ volume: number
349
+ tracks: { line: number; name: string; instrument: string; volume: number }[]
350
+ /** drum letter → instrument, every section */
351
+ drums: Record<string, string>
352
+ /** the `code` block, its lines at their own line numbers (the others blank) */
353
+ code: { line: number; text: string } | null
354
+ /** `stop at <time>`: the score and its room cut dead there, seconds from the first section's start */
355
+ stop?: { line: number; at: number }
189
356
  }
190
357
 
358
+ /** the voices a score holds at most */
359
+ export const TRACKS_MAX = 16
360
+ /** the music a score starts with: the render's lead, so its first note is never cut */
361
+ export const SCORE_LEAD = 0.05
362
+ /** the score in the cut's music cues */
363
+ export const SCORE = '@score'
364
+
191
365
  export interface Trailer {
192
366
  name: string
367
+ /** `footage` blocks: filmed like shots, never cut in; a grid or a split reads them */
368
+ footage: Shot[]
193
369
  size: [number, number]
194
370
  fps: number
195
371
  music: Music | null
@@ -200,12 +376,17 @@ export interface Trailer {
200
376
  fadeIn: number
201
377
  shots: Shot[]
202
378
  sections: Section[]
203
- /** where the timeline of the sections starts on the cut: the first landed shot's start less its moment */
379
+ /** the cut's own music; null: none */
380
+ score: Score | null
381
+ /** where the timeline of the sections (and the score) starts on the cut: the first landed shot's start less its moment */
204
382
  timelineAt: number | null
205
383
  /** the cut is a vertical short (`vertical`), finished with the brand's lockup, watermark and end card */
206
384
  vertical: Vertical | null
207
385
  /** the global process whose url a `film url` route resolves against (`base <id>`); null: the repo's one */
208
386
  base: string | null
387
+ /** the music variants the file names, and the one it was read as (null: it names none) */
388
+ variants: string[]
389
+ variant: string | null
209
390
  }
210
391
 
211
392
  export interface TrailerError {
@@ -216,9 +397,15 @@ export interface TrailerError {
216
397
  interface Word {
217
398
  text: string
218
399
  quoted: boolean
400
+ /** an option line's word keeps its own line */
401
+ line?: number
219
402
  }
220
403
 
221
- /** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line */
404
+ /** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line. Only double quotes quote, as in
405
+ * the full language, so a word opening on a single quote is refused: it would reach a command with the quotes inside */
406
+ /** a hex colour at `k` (`#rgb`, `#rrggbb`, then the word's end or a comma): a word, never a comment */
407
+ const colourAt = (line: string, k: number): boolean => /^#([0-9a-f]{3}|[0-9a-f]{6})(?=[\s,"]|$)/i.test(line.slice(k))
408
+
222
409
  export function tokens(line: string): { words: Word[] } | { error: string } {
223
410
  const words: Word[] = []
224
411
  let i = 0
@@ -228,7 +415,12 @@ export function tokens(line: string): { words: Word[] } | { error: string } {
228
415
  i++
229
416
  continue
230
417
  }
231
- if (c === '#') break
418
+ if (c === '#' && !colourAt(line, i)) break
419
+ if (c === "'") {
420
+ const end = line.indexOf("'", i + 1)
421
+ const word = end < 0 ? line.slice(i).split(/[\s#]/)[0]! : line.slice(i, end + 1)
422
+ return { error: `${word} opens on a single quote: the language quotes with double quotes only, write "${word.slice(1, end < 0 ? undefined : -1)}"` }
423
+ }
232
424
  if (c === '"') {
233
425
  const end = line.indexOf('"', i + 1)
234
426
  if (end < 0) return { error: 'a quote is not closed' }
@@ -237,7 +429,7 @@ export function tokens(line: string): { words: Word[] } | { error: string } {
237
429
  continue
238
430
  }
239
431
  let j = i
240
- while (j < line.length && line[j] !== ' ' && line[j] !== '\t' && line[j] !== '"' && line[j] !== '#') j++
432
+ while (j < line.length && line[j] !== ' ' && line[j] !== '\t' && line[j] !== '"' && (line[j] !== '#' || (j === i || line[j - 1] === ',') && colourAt(line, j))) j++
241
433
  words.push({ text: line.slice(i, j), quoted: false })
242
434
  i = j
243
435
  }
@@ -319,6 +511,131 @@ const sizeWord = (what: string, w: Word | undefined, lo: number, hi: number): [n
319
511
  return [x, y]
320
512
  }
321
513
 
514
+ /** a colour word: `#rgb`, `#rrggbb`, or the look's light, edge or accent */
515
+ const colour = (k: string, text: string | undefined): string => {
516
+ if (!text || !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|light|edge|accent)$/i.test(text)) return bad(`${k} wants a colour: #rgb, #rrggbb, ${LOOK_COLOURS.join(', ')}${text ? `, got ${text}` : ''}`)
517
+ return text
518
+ }
519
+ /** `<px>[,<colour>]`: an outline, a shadow or a glow, its size in pixels of a frame 1080 tall */
520
+ const sized = (w: Word | undefined, k: string): { px: number; color?: string } => {
521
+ if (!w || w.quoted) return bad(`${k} wants <px>[,<colour>] (10, or 10,#2a1020)`)
522
+ const [a, c, more] = w.text.split(',')
523
+ const px = Number(a)
524
+ if (!Number.isFinite(px) || px < 0 || px > 300 || more !== undefined) return bad(`${k} wants <px>[,<colour>], its size 0 to 300, got ${w.text}`)
525
+ return { px, ...(c !== undefined ? { color: colour(k, c) } : {}) }
526
+ }
527
+ /** a title's look words, in TitleFx */
528
+ const FX_WORDS = ['font', 'weight', 'size', 'color', 'gradient', 'outline', 'shadow', 'glow'] as const
529
+ const TITLE_WORDS: Record<string, (w: Word | undefined, k: string) => unknown> = {
530
+ style: (w, k) => word(k, w, /^[a-z][a-z0-9-]*$/),
531
+ at: when,
532
+ for: (w, k) => time(k, w),
533
+ place: (w, k) => {
534
+ const pl = word(k, w)
535
+ return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
536
+ },
537
+ knock: (w, k) => time(k, w),
538
+ font: (w, k) => pathWord(k, w),
539
+ weight: (w, k) => {
540
+ const v = num(k, w, 100, 1000)
541
+ return Number.isInteger(v) ? v : bad(`${k} wants a whole number from 100 to 1000, got ${w?.text}`)
542
+ },
543
+ size: (w, k) => num(k, w, 6, 600),
544
+ color: (w, k) => colour(k, w && !w.quoted ? w.text : undefined),
545
+ gradient: (w, k) => {
546
+ const cs = w && !w.quoted ? w.text.split(',') : []
547
+ if (cs.length < 2 || cs.length > 4) bad(`${k} wants two to four colours, top to bottom: light,accent`)
548
+ return cs.map((c) => colour(k, c))
549
+ },
550
+ outline: sized,
551
+ shadow: sized,
552
+ glow: sized,
553
+ }
554
+
555
+ /** a title's words, in order, each `+<preset>` in place; `in` and `out` take a move and, when the next word is a
556
+ * time, its length. A word the line itself writes twice is an error; a preset's word gives way to a later one */
557
+ function readTitleWords(raw: Word[], presets: ReadonlyMap<string, Word[]>, what = 'title'): Record<string, unknown> {
558
+ type Own = Word & { own?: true }
559
+ const words: Own[] = raw.flatMap((w): Own[] => {
560
+ if (w.quoted || !/^\+[a-z][a-z0-9-]*$/.test(w.text)) return [{ ...w, own: true }]
561
+ return presets.get(w.text.slice(1)) ?? bad(`${what}: no preset ${w.text.slice(1)} (preset <name> <words> in the header${presets.size ? `: ${[...presets.keys()].join(', ')}` : ''})`)
562
+ })
563
+ const values: Record<string, unknown> = {}
564
+ const written = new Set<string>()
565
+ for (let i = 0; i < words.length; i++) {
566
+ const w = words[i]!
567
+ if (w.quoted) bad(`${what}: a quoted text "${w.text}" where a word was expected`)
568
+ if (w.own) {
569
+ if (written.has(w.text)) bad(`${what}: ${w.text} given twice`)
570
+ written.add(w.text)
571
+ }
572
+ if (w.text === 'in' || w.text === 'out') {
573
+ const kind = word(`${what} ${w.text}`, words[i + 1])
574
+ if (!(MOVES as readonly string[]).includes(kind)) bad(`${what} ${w.text} wants ${MOVES.join('|')}, got ${kind}`)
575
+ i++
576
+ const next = words[i + 1]
577
+ const seconds = next && !next.quoted && /^\d/.test(next.text) ? time(`${what} ${w.text} ${kind}`, next) : undefined
578
+ if (seconds !== undefined) i++
579
+ values[w.text] = { kind: kind as MoveKind, ...(seconds !== undefined ? { seconds } : {}) }
580
+ continue
581
+ }
582
+ const f = TITLE_WORDS[w.text]
583
+ if (!f) bad(`${what}: unknown word ${w.text} (title takes ${[...Object.keys(TITLE_WORDS), 'in', 'out', '+<preset>'].join(', ')})`)
584
+ values[w.text] = f!(words[++i], `${what} ${w.text}`)
585
+ }
586
+ return values
587
+ }
588
+
589
+ /** a title's look words out of read values */
590
+ const fxOf = (v: Record<string, unknown>): TitleFx => Object.fromEntries(FX_WORDS.filter((k) => v[k] !== undefined).map((k) => [k, v[k]])) as TitleFx
591
+ const builtInStyle = (st: string): st is TitleStyle => (TITLE_STYLES as readonly string[]).includes(st)
592
+
593
+ /** the brand's text styles (`trailer.styles`, each a title's words by name) laid under each title: the defaults of
594
+ * its built in style, then the brand style it names, then its own words, a later one winning. A brand style names
595
+ * the built in style under it (default caption) and holds look words, `place`, `in` and `out` only. Run again, it
596
+ * gives the same titles. Every error at the title's line */
597
+ export function resolveStyles(t: Pick<Trailer, 'shots'>, styles: Readonly<Record<string, string>>): TrailerError[] {
598
+ const errors: TrailerError[] = []
599
+ const read = (name: string): Record<string, unknown> => {
600
+ const tk = tokens(styles[name]!)
601
+ if ('error' in tk) return bad(`the brand's style ${name}: ${tk.error}`)
602
+ const v = readTitleWords(tk.words, new Map(), `the brand's style ${name}`)
603
+ const odd = Object.keys(v).filter((k) => !['style', 'place', 'in', 'out', ...FX_WORDS].includes(k))
604
+ if (odd.length > 0) bad(`the brand's style ${name} holds look words, place, in and out, never ${odd.join(', ')}`)
605
+ return v
606
+ }
607
+ for (const s of t.shots)
608
+ for (const ti of s.titles) {
609
+ if (ti.style === 'progress') continue
610
+ try {
611
+ let base: string = ti.style
612
+ let named: Record<string, unknown> = {}
613
+ if (ti.named) {
614
+ if (styles[ti.named] === undefined) bad(`title style wants ${[...TITLE_STYLES, ...Object.keys(styles).filter((k) => !builtInStyle(k))].join('|')}, got ${ti.named}`)
615
+ named = read(ti.named)
616
+ base = (named.style as string | undefined) ?? 'caption'
617
+ if (!builtInStyle(base)) bad(`the brand's style ${ti.named} is over ${base}, which is no built in style (${TITLE_STYLES.join('|')})`)
618
+ }
619
+ const defaults = styles[base] !== undefined ? read(base) : {}
620
+ if (defaults.style !== undefined) bad(`the brand's style ${base} gives the defaults of the built in ${base}: it names no style of its own`)
621
+ const under = { ...defaults, ...named }
622
+ ti.style = base as TitleStyle
623
+ const fx = { ...fxOf(under), ...ti.fx }
624
+ if (Object.keys(fx).length > 0) ti.fx = fx
625
+ if (under.place && !ti.place) ti.place = under.place as 'top' | 'bottom'
626
+ if (under.in && !ti.in) ti.in = under.in as Move
627
+ if (under.out && !ti.out && ti.knock === undefined) ti.out = under.out as Move
628
+ } catch (e) {
629
+ if (!(e instanceof LineError)) throw e
630
+ errors.push({ line: ti.line, message: e.message })
631
+ }
632
+ }
633
+ return errors
634
+ }
635
+
636
+ /** the brand styles a cut names: a title's style word that is no built in style */
637
+ export const namedStyles = (t: Pick<Trailer, 'shots'>): string[] => [...new Set(t.shots.flatMap((s) => s.titles.flatMap((ti) => (ti.named ? [ti.named] : []))))]
638
+
322
639
  /** the `key value` pairs after a line's fixed words; `flags` take no value */
323
640
  function pairs(
324
641
  what: string,
@@ -356,8 +673,79 @@ const filmWords = (verbs: readonly FilmVerb[] | null): string =>
356
673
 
357
674
  /** a film line: a core kind when its words fit that kind's form (`clip <video file>`), else a film row of the project
358
675
  * CLI (its `clip <scene>` row), else the core kind's error */
359
- function filmLine(ws: Word[], line: number, verbs: readonly FilmVerb[] | null): Film {
676
+ /** what a film line of a declared row reads past its words: its section's defaults, the header's presets, a time */
677
+ interface FilmContext {
678
+ defaults: Word[]
679
+ presets: ReadonlyMap<string, Word[]>
680
+ time: (text: string) => Moment | null
681
+ /** a slot that waits on a moment, resolved once the shots have landed */
682
+ later: (f: { argv: string[] }, slot: Typed['times'][number]) => void
683
+ /** the words a `film page` takes, when the repo declares them (its trailer unit's `page`) */
684
+ pageWords?: Words
685
+ errors: TrailerError[]
686
+ }
687
+
688
+ /** a film line's words with every `+<preset>` put in place */
689
+ function expandPresets(ws: Word[], line: number, presets: ReadonlyMap<string, Word[]>): Word[] {
690
+ return ws.flatMap((w) => {
691
+ if (w.quoted || !/^\+[a-z][a-z0-9-]*$/.test(w.text)) return [w]
692
+ const p = presets.get(w.text.slice(1)) ?? bad(`film: no preset ${w.text.slice(1)} (preset <name> <words> in the header${presets.size ? `: ${[...presets.keys()].join(', ')}` : ''})`)
693
+ return p.map((x) => ({ ...x, line: w.line ?? line }))
694
+ })
695
+ }
696
+
697
+ /** `film page <route> [viewport WxH] [wait t] [step <hook>] [still] <words>`: the words a query, typed when the repo
698
+ * declares them, else `key value` (a value of cues, `name@<time>` words, made seconds of the take) */
699
+ function pageFilm(ws: Word[], line: number, c: FilmContext): Extract<Film, { kind: 'page' }> {
700
+ const route = pathWord('film page', ws[1])
701
+ if (!/^(\/|https?:\/\/)/.test(route)) bad(`film page wants a route of the dev url (/look) or an http url, got ${route}`)
702
+ const own: Record<string, unknown> = {}
703
+ const rest: Word[] = []
704
+ const RESERVED: Record<string, (w: Word | undefined, k: string) => unknown> = { viewport: (w, k) => sizeWord(k, w, 16, 7680), wait: at, step: (w, k) => word(k, w, /^[A-Za-z_$][A-Za-z0-9_$]*$/) }
705
+ for (let i = 2; i < ws.length; i++) {
706
+ const w = ws[i]!
707
+ if (!w.quoted && w.text === 'still') own.still = true
708
+ else if (!w.quoted && RESERVED[w.text]) {
709
+ if (w.text in own) bad(`film page: ${w.text} given twice`)
710
+ own[w.text] = RESERVED[w.text]!(ws[++i], `film page ${w.text}`)
711
+ } else rest.push(w)
712
+ }
713
+ // a page's words are its query, key=value: a positional has no key to write
714
+ for (const [k, w] of Object.entries(c.pageWords ?? {})) if (w.arg !== undefined) bad(`film page: ${k} declares arg ${w.arg}, and a page word has a key (it is the url's query): drop its arg`)
715
+ // undeclared words: a key, then one value, or cue words while they come
716
+ const words: Words = c.pageWords ?? {}
717
+ if (!c.pageWords)
718
+ for (let i = 0; i < rest.length; i++) {
719
+ const k = rest[i]!
720
+ if (k.quoted || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(k.text)) bad(`film page: ${k.quoted ? `"${k.text}"` : k.text} is no word of the query: write key value`)
721
+ const cue = (w: Word | undefined): boolean => !!w && !w.quoted && /^[^@\s]+@\S+$/.test(w.text)
722
+ words[k.text] = { type: cue(rest[i + 1]) ? 'cues' : 'text' }
723
+ if (cue(rest[i + 1])) while (cue(rest[i + 1])) i++
724
+ else if (!rest[++i]) bad(`film page ${k.text} wants a value`)
725
+ }
726
+ const at2 = (w: Word): LineWord => ({ text: w.text, quoted: w.quoted, line: w.line ?? line })
727
+ const typed = typedFilm(['page'], words, rest.map(at2), c.pageWords ? c.defaults.map(at2) : [], c.time)
728
+ c.errors.push(...typed.errors)
729
+ const f: Extract<Film, { kind: 'page' }> = {
730
+ kind: 'page',
731
+ line,
732
+ route,
733
+ query: typed.argv.slice(1),
734
+ ...(Object.entries(words).some(([, s]) => s.type === 'flag') ? { flags: Object.entries(words).filter(([, s]) => s.type === 'flag').map(([k]) => k) } : {}),
735
+ step: (own.step as string | undefined) ?? '__trailerStep',
736
+ ...(own.viewport ? { viewport: own.viewport as [number, number] } : {}),
737
+ ...(own.wait !== undefined ? { wait: own.wait as number } : {}),
738
+ ...(own.still ? { still: true as const } : {}),
739
+ }
740
+ // the query's slots sit one word to the left of the typed argv's (its `page` path word dropped)
741
+ for (const slot of typed.times) c.later({ argv: f.query }, { ...slot, index: slot.index - 1 })
742
+ return f
743
+ }
744
+
745
+ function filmLine(raw: Word[], line: number, verbs: readonly FilmVerb[] | null, c?: FilmContext): Film {
746
+ const ws = c ? expandPresets(raw, line, c.presets) : raw
360
747
  const kind = ws[0] && !ws[0].quoted ? ws[0].text : ''
748
+ if (kind === 'page' && c) return pageFilm(ws, line, c)
361
749
  const argv = ws.map((w) => w.text)
362
750
  const v = verbs && verbOf(argv, verbs)
363
751
  if ((CORE_FILMS as readonly string[]).includes(kind)) {
@@ -369,7 +757,23 @@ function filmLine(ws: Word[], line: number, verbs: readonly FilmVerb[] | null):
369
757
  }
370
758
  if (!v) return bad(`film: unknown kind ${kind || '(none)'} (${filmWords(verbs)})`)
371
759
  if (argv.some((a) => a === '--out' || a.startsWith('--out=') || a === '--seconds' || a.startsWith('--seconds='))) bad(`film ${v.path.join(' ')}: the trailer gives --out and --seconds itself`)
372
- return { kind: 'cli', line, argv, output: v.film }
760
+ const marks = { ...(v.version !== undefined ? { version: v.version } : {}), ...(v.length ? { length: v.length } : {}), ...(v.stamp ? { stamp: v.stamp } : {}) }
761
+ if (!v.words || !c) return { kind: 'cli', line, argv, output: v.film, ...marks }
762
+ const at = (w: Word): LineWord => ({ text: w.text, quoted: w.quoted, line: w.line ?? line })
763
+ const typed = typedFilm(v.path, v.words, ws.slice(v.path.length).map(at), c.defaults.map(at), c.time)
764
+ c.errors.push(...typed.errors)
765
+ const f: Extract<Film, { kind: 'cli' }> = { kind: 'cli', line, argv: typed.argv, output: v.film, typed: true, ...(typed.files.length ? { files: typed.files } : {}), ...marks }
766
+ for (const slot of typed.times) c.later(f, slot)
767
+ return f
768
+ }
769
+
770
+ /** a list of `min` to `max` different shot or footage names */
771
+ function cellList(what: string, w: Word | undefined, min: number, max: number): string[] {
772
+ const list = word(what, w, /^[a-z0-9][a-z0-9-]*(?:,[a-z0-9][a-z0-9-]*)*$/)
773
+ const cells = list.split(',')
774
+ if (cells.length < min || cells.length > max) bad(`${what}: ${min} to ${max} cells, got ${cells.length}`)
775
+ if (new Set(cells).size !== cells.length) bad(`${what}: a cell named twice in ${list}`)
776
+ return cells
373
777
  }
374
778
 
375
779
  function coreFilm(kind: string, ws: Word[], line: number): Film {
@@ -392,8 +796,8 @@ function coreFilm(kind: string, ws: Word[], line: number): Film {
392
796
  case 'run': {
393
797
  const cmd = quoted('film run', ws[1])
394
798
  if (!cmd.includes('{out}')) bad('film run wants {out} in its command: the file the command writes the take to')
395
- const p = pairs('film run', ws.slice(2), {}, ['still'])
396
- return { kind, line, cmd, ...(p.still ? { still: true as const } : {}) }
799
+ const p = pairs('film run', ws.slice(2), { reads: (w, k) => pathWord(k, w).split(',') }, ['still'])
800
+ return { kind, line, cmd, ...(p.still ? { still: true as const } : {}), ...(p.reads ? { reads: p.reads as string[] } : {}) }
397
801
  }
398
802
  case 'url': {
399
803
  const route = pathWord('film url', ws[1])
@@ -408,6 +812,25 @@ function coreFilm(kind: string, ws: Word[], line: number): Film {
408
812
  ...(p.still ? { still: true as const } : {}),
409
813
  }
410
814
  }
815
+ case 'grid': {
816
+ const cells = cellList('film grid', ws[1], 1, GRID_MAX)
817
+ const p = pairs(`film grid ${cells.join(',')}`, ws.slice(2), {
818
+ pop: at,
819
+ grow: (w, k) => {
820
+ const t = word(k, w, /^\d+(?:\.\d+)?s,\d+(?:\.\d+)?s$/)
821
+ const v = t.split(',').map((x) => Number(x.slice(0, -1)))
822
+ return v[0]! >= GROW_S && v[1]! >= v[0]! + GROW_S ? v : bad(`${k} wants two settles at least ${GROW_S}s apart, the first at ${GROW_S}s or later, got ${t}`)
823
+ },
824
+ })
825
+ if (p.grow && cells.length !== 9) bad(`film grid … grow: it grows out of one cell into four, then nine: it wants nine cells, got ${cells.length}`)
826
+ if (p.grow && p.pop) bad('film grid: grow or pop, not both')
827
+ return { kind, line, cells, pop: (p.pop as number) ?? 0, ...(p.grow ? { grow: p.grow as [number, number] } : {}) }
828
+ }
829
+ case 'split': {
830
+ const cells = cellList('film split', ws[1], 2, 3)
831
+ const p = pairs(`film split ${cells.join(',')}`, ws.slice(2), { angle: (w, k) => num(k, w, 0, 70), slide: at })
832
+ return { kind, line, cells, angle: (p.angle as number) ?? SPLIT_ANGLE[cells.length] ?? 0, slide: (p.slide as number) ?? SPLIT_SLIDE }
833
+ }
411
834
  }
412
835
  return bad(`film: unknown kind ${kind}`)
413
836
  }
@@ -422,18 +845,55 @@ export const NO_MUSIC = 'none'
422
845
  export const OPENING_FADE_IN = 0.25
423
846
 
424
847
  /** `music <file|id|none> [take k] [from t] [fade-in t] [fade-out t] [volume v]`; `opening`: the cut's own */
425
- function musicLine(rest: Word[], n: number, opening = false): Music {
426
- const song = pathWord('music', rest[0])
427
- const p = pairs(`music ${song}`, rest.slice(1), {
848
+ /** `<kind> run "<cmd {out}>" [reads <file>,…]`: the run form of a sound line, and the words after it; null when the
849
+ * line is no run */
850
+ function soundRun(kind: string, rest: Word[]): { run: SoundRun; rest: Word[] } | null {
851
+ if (!(rest[0] && !rest[0].quoted && rest[0].text === 'run' && rest[1]?.quoted)) return null
852
+ const cmd = quoted(`${kind} run`, rest[1])
853
+ if (!cmd.includes('{out}')) bad(`${kind} run wants {out} in its command: the file the command writes the sound to`)
854
+ let more = rest.slice(2)
855
+ let reads: string[] | undefined
856
+ if (more[0]?.text === 'reads' && !more[0].quoted) {
857
+ reads = pathWord(`${kind} run reads`, more[1]).split(',')
858
+ more = more.slice(2)
859
+ }
860
+ return { run: { cmd, ...(reads ? { reads } : {}) }, rest: more }
861
+ }
862
+
863
+ /** the words a made sound takes, read off a line's pairs */
864
+ const genOf = (p: Record<string, unknown>): { gen?: SoundGen } => {
865
+ const g: SoundGen = { ...(p.seed !== undefined ? { seed: p.seed as number } : {}), ...(p.by ? { by: p.by as string } : {}), ...(p.for !== undefined ? { for: p.for as number } : {}), ...(p.hit ? { hit: true as const } : {}) }
866
+ return Object.keys(g).length ? { gen: g } : {}
867
+ }
868
+ const seedWord = (w: Word | undefined, k: string): number => {
869
+ const v = num(k, w, 0, 2 ** 31 - 1)
870
+ return Number.isInteger(v) ? v : bad(`${k} wants a whole number, got ${w?.text}`)
871
+ }
872
+ const byWord = (w: Word | undefined, k: string): string => word(k, w, /^[a-zA-Z0-9][a-zA-Z0-9:_./-]*$/)
873
+
874
+ function musicLine(raw: Word[], n: number, opening = false): Music {
875
+ const r = soundRun('music', raw)
876
+ const rest = r ? [{ text: 'run', quoted: false }, ...r.rest] : raw
877
+ const prompt = !r && rest[0]?.quoted === true
878
+ const song = r ? 'run' : prompt ? quoted('music', rest[0]) : pathWord('music', rest[0])
879
+ const p = pairs(`music ${r ? 'run' : song}`, rest.slice(1), {
428
880
  take: (w, k) => word(k, w, /^[a-z0-9][a-z0-9-]*$/),
429
881
  from: at,
430
882
  'fade-in': at,
431
883
  'fade-out': at,
432
884
  volume,
885
+ by: byWord,
886
+ for: at,
887
+ seed: seedWord,
888
+ variant: (w, k) => word(k, w, /^[a-z0-9][a-z0-9-]*$/),
433
889
  })
434
890
  return {
435
891
  line: n,
436
892
  song,
893
+ ...(prompt ? { prompt: true as const } : {}),
894
+ ...(r ? { run: r.run } : {}),
895
+ ...genOf(p),
896
+ ...(p.variant ? { variant: p.variant as string } : {}),
437
897
  ...(p.take ? { take: p.take as string } : {}),
438
898
  from: (p.from as number) ?? 0,
439
899
  fadeIn: (p['fade-in'] as number | undefined) ?? (opening && ((p.from as number | undefined) ?? 0) > 0 ? OPENING_FADE_IN : 0),
@@ -442,63 +902,129 @@ function musicLine(rest: Word[], n: number, opening = false): Music {
442
902
  }
443
903
  }
444
904
 
445
- /** words of the full trailer language a repo adds through its own words, not the core's */
446
- const NOT_CORE: Record<string, string> = {
447
- score: 'a score the repo synthesizes',
448
- track: 'a voice of a score',
449
- drums: 'a score\'s drum letters',
450
- code: 'a score\'s code block',
451
- play: 'what a score track plays',
452
- progress: 'a progress bar title',
453
- footage: 'a footage block a grid reads',
454
- }
455
- const notCore = (w: string): string => `${w} is ${NOT_CORE[w]}: a word of the full trailer language the core does not read yet`
456
-
457
905
  /**
458
- * the score's TypeScript (a margin `code` line and its indented block, a `play` line and the deeper lines that continue
459
- * it) blanked before the words are read, each block reported once at its first line: every line keeps its number
906
+ * the lines the language reads as TypeScript, taken out before the words are read (their lines blank, so every line
907
+ * keeps its number): the `code` block (its indented lines under a margin `code` line) and each `play` line with the
908
+ * deeper indented lines that continue it. A line whose first mark is `#` is off in both.
460
909
  */
461
- export function scoreBlocks(text: string): { lines: string[]; errors: TrailerError[] } {
910
+ export function scoreLines(text: string): { lines: string[]; code: Score['code']; plays: (Play & { section: number })[]; errors: TrailerError[] } {
462
911
  const lines = text.split('\n')
463
912
  const errors: TrailerError[] = []
913
+ const plays: (Play & { section: number })[] = []
914
+ let code: Score['code'] = null
915
+ let section = -1
464
916
  const indent = (l: string): number => l.length - l.trimStart().length
465
917
  const off = (l: string): boolean => l.trimStart().startsWith('#')
466
918
  for (let i = 0; i < lines.length; i++) {
467
919
  const l = lines[i]!
468
- const code = /^code\s*(#.*)?$/.test(l)
469
- const play = /^(\s*)play(\s|$)/.exec(l)
470
- if ((!code && !play) || off(l)) continue
471
- errors.push({ line: i + 1, message: notCore(code ? 'code' : 'play') })
920
+ if (/^section\s/.test(l)) section++
921
+ if (/^code\s*(#.*)?$/.test(l)) {
922
+ const head = i + 1
923
+ if (code) errors.push({ line: head, message: `code is already written on line ${code.line}: one code block a file` })
924
+ const body: string[] = Array.from({ length: head }, () => '')
925
+ lines[i] = ''
926
+ for (let j = i + 1; j < lines.length; j++) {
927
+ const b = lines[j]!
928
+ if (b.trim() !== '' && !off(b) && indent(b) === 0) break
929
+ body.push(off(b) ? '' : b.replace(/^( {2}|\t)/, ''))
930
+ lines[j] = ''
931
+ i = j
932
+ }
933
+ code ??= { line: head, text: body.join('\n') }
934
+ continue
935
+ }
936
+ const m = /^(\s*)play(?:\s+(\S+))?(?:\s+(.*))?$/.exec(l)
937
+ if (!m || off(l)) continue
938
+ const at = i + 1
939
+ const parts = [m[3] ?? '']
472
940
  lines[i] = ''
473
- const base = code ? 0 : play![1]!.length
474
941
  for (let j = i + 1; j < lines.length; j++) {
475
942
  const b = lines[j]!
476
- const close = !code && indent(b) === base && /^[)\]}]/.test(b.trimStart())
477
- if (b.trim() !== '' && !off(b) && indent(b) <= base && !close) break
943
+ // deeper lines continue the expression; a closing bracket at the play's own indent ends it, and is its
944
+ const close = indent(b) === (m[1] ?? '').length && /^[)\]}]/.test(b.trimStart())
945
+ if (b.trim() !== '' && !off(b) && indent(b) <= (m[1] ?? '').length && !close) break
946
+ if (!off(b)) parts.push(b)
947
+ if (close) {
948
+ lines[j] = ''
949
+ i = j
950
+ break
951
+ }
478
952
  lines[j] = ''
479
953
  i = j
480
- if (close) break
481
954
  }
955
+ const track = m[2] ?? ''
956
+ const expr = parts.join('\n').trim()
957
+ if (!/^(\*|[a-zA-Z][a-zA-Z0-9]*)$/.test(track)) errors.push({ line: at, message: `play wants a track and what it plays: play <track> <expression>${track ? `, got ${track}` : ''}` })
958
+ else if (expr === '') errors.push({ line: at, message: `play ${track}: nothing to play` })
959
+ else if (section < 0) errors.push({ line: at, message: `play ${track}: a play belongs to a section, write it under one` })
960
+ else plays.push({ line: at, track, expr, section })
482
961
  }
483
- return { lines, errors }
962
+ return { lines, code, plays, errors }
484
963
  }
485
964
 
486
965
  export interface ParseOptions {
487
966
  /** the project CLI's film rows; null when the repo has no project CLI */
488
967
  verbs?: readonly FilmVerb[] | null
968
+ /** the words a `film page` takes, when the repo's trailer unit declares them */
969
+ pageWords?: Words
970
+ /** the music variant the cut is read as; default the first the file names */
971
+ variant?: string
972
+ }
973
+
974
+ /** the music variants a file names, in order: its music lines' `variant <name>` words */
975
+ export function variantsOf(text: string): string[] {
976
+ const out: string[] = []
977
+ for (const l of text.split('\n')) {
978
+ const m = /^\s*music\s.*\svariant\s+([a-z0-9][a-z0-9-]*)\b/.exec(l.replace(/"[^"]*"/g, '""'))
979
+ if (m && !out.includes(m[1]!)) out.push(m[1]!)
980
+ }
981
+ return out
489
982
  }
490
983
 
491
984
  /** the file as a trailer, and every error with its line */
985
+ /** a film line's option lines: the lines under it indented deeper, each `key value`, taken out before the words are
986
+ * read (their lines blank, so every line keeps its number) and kept by the film line's number */
987
+ export function filmOptionLines(lines: string[]): Map<number, { line: number; text: string }[]> {
988
+ const out = new Map<number, { line: number; text: string }[]>()
989
+ const indent = (l: string): number => l.length - l.trimStart().length
990
+ for (let i = 0; i < lines.length; i++) {
991
+ const m = /^(\s*)film(\s|$)/.exec(lines[i]!)
992
+ if (!m) continue
993
+ const head = i + 1
994
+ const own: { line: number; text: string }[] = []
995
+ for (let j = i + 1; j < lines.length; j++) {
996
+ const l = lines[j]!
997
+ if (l.trim() === '' || l.trimStart().startsWith('#')) continue
998
+ if (indent(l) <= m[1]!.length) break
999
+ own.push({ line: j + 1, text: l })
1000
+ lines[j] = ''
1001
+ i = j
1002
+ }
1003
+ if (own.length) out.set(head, own)
1004
+ }
1005
+ return out
1006
+ }
1007
+
492
1008
  export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Trailer; errors: TrailerError[] } {
493
1009
  const verbs = o.verbs ?? null
494
- const t: Trailer = { name: '', size: [1920, 1080], fps: 30, music: null, duck: -10, duckSfx: null, fadeIn: 0, shots: [], sections: [], timelineAt: null, vertical: null, base: null }
1010
+ const variants = variantsOf(text)
1011
+ const variant = o.variant ?? variants[0] ?? null
1012
+ const t: Trailer = { name: '', footage: [], size: [1920, 1080], fps: 30, music: null, duck: -10, duckSfx: null, fadeIn: 0, shots: [], sections: [], score: null, timelineAt: null, vertical: null, base: null, variants, variant }
1013
+ /** a music line of another variant plays elsewhere; one of this variant takes the place of the line with none */
1014
+ const mine = (m: Music, had: Music | null, what: string): boolean => {
1015
+ if (m.variant !== undefined && m.variant !== variant) return false
1016
+ // the variant's line came first: the plain one under it stands aside, whatever their order
1017
+ if (had && had.variant !== undefined && m.variant === undefined) return false
1018
+ if (had && (had.variant !== undefined || m.variant === undefined)) bad(`${what} already ${had.variant ? `plays variant ${had.variant}` : 'plays its music'} on line ${had.line}`)
1019
+ return true
1020
+ }
495
1021
  const errors: TrailerError[] = []
1022
+ /** a section's defaults that some film of it cannot read, by the defaults line */
1023
+ const unread: { line: number; section: string; films: string[] }[] = []
496
1024
  const seen = new Set<string>()
497
1025
  let shot: Shot | null = null
498
1026
  let pending: SectionRef | null = null
499
1027
  let current: Section | null = null
500
- let scoreClock: Clock | null = null
501
- let skipping = false
502
1028
  /** the `at`s that name a mark, set once every shot has landed */
503
1029
  const refs: { line: number; shot: Shot; ref: MarkAt; set: (at: number) => void }[] = []
504
1030
  const landAt = (v: unknown, line: number, s: Shot, set: (at: number) => void): number => {
@@ -507,13 +1033,27 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
507
1033
  return 0
508
1034
  }
509
1035
  clock = null
510
- const pre = scoreBlocks(text)
1036
+ const pre = scoreLines(text)
511
1037
  errors.push(...pre.errors)
512
1038
  const lines = pre.lines
1039
+ const options = filmOptionLines(lines)
1040
+ const presets = new Map<string, Word[]>()
1041
+ /** the film slots that wait on a moment: resolved once the shots have landed, each plus its shot's `from` */
1042
+ const slots: { shot: Shot; film: { argv: string[] }; slot: Typed['times'][number] }[] = []
1043
+ /** a time as a declared word reads it, at the clock of its line; null when it is none */
1044
+ const momentWord = (w: string): Moment | null => {
1045
+ const m = new RegExp(`^(${MOMENT})(?:([+-])(\\d.*))?$`).exec(w)
1046
+ // a bare number is seconds, as a cue of a take counts them
1047
+ if (!m) return /^\d+(\.\d+)?$/.test(w) ? Number(w) : parseTime(w, clock)
1048
+ const off = m[3] === undefined ? 0 : parseTime(m[3], clock)
1049
+ return off === null ? null : { mark: m[1]!, offset: m[2] === '-' ? -off : off }
1050
+ }
513
1051
  for (let n = 1; n <= lines.length; n++) {
514
1052
  const tk = tokens(lines[n - 1]!)
515
1053
  if ('error' in tk) {
516
1054
  errors.push({ line: n, message: tk.error })
1055
+ // its own line failed: the shot is not also told it films nothing
1056
+ if (shot && /^\s*film(\s|$)/.test(lines[n - 1]!)) shot.broken = true
517
1057
  continue
518
1058
  }
519
1059
  const ws = tk.words
@@ -524,25 +1064,8 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
524
1064
  if (seen.has(key)) bad(`${key} given twice`)
525
1065
  seen.add(key)
526
1066
  }
527
- // a footage block's own lines belong to it: skipped with it, its one error at its line
528
- if (skipping && !['shot', 'footage', 'section'].includes(head.text)) continue
529
- skipping = false
530
1067
  try {
531
1068
  if (head.quoted) bad(`a line starts with a word, got "${head.text}"`)
532
- if (head.text === 'footage') skipping = true
533
- if (head.text in NOT_CORE) {
534
- // a score's clock still counts the sections' bars, so one error stands for the score, not one per section
535
- if (head.text === 'score') {
536
- const v = (k: string): number | undefined => {
537
- const i = rest.findIndex((w) => w.text === k)
538
- const x = i >= 0 ? Number(rest[i + 1]?.text) : Number.NaN
539
- return Number.isFinite(x) ? x : undefined
540
- }
541
- const bpm = v('tempo')
542
- if (bpm) scoreClock = { bpm, meter: v('meter') ?? 4, grid: v('grid') ?? 4 }
543
- }
544
- bad(notCore(head.text))
545
- }
546
1069
  if (head.text === 'section') {
547
1070
  const name = word('section', rest[0], /^[a-z0-9][a-z0-9-]*$/)
548
1071
  const last = t.sections.at(-1)
@@ -552,17 +1075,44 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
552
1075
  meter: (w, k) => num(k, w, 1, 16),
553
1076
  grid: (w, k) => num(k, w, 1, 32),
554
1077
  })
555
- const base = last?.clock ?? scoreClock
1078
+ const base = last?.clock ?? t.score?.clock ?? null
556
1079
  if (!base && !p.tempo && (p.meter || p.grid)) bad(`section ${name}: meter and grid count in a tempo, give it tempo <bpm>`)
557
1080
  clock = p.tempo || base ? { ...(base ?? { bpm: 120, meter: 4, grid: 4 }), ...(p.tempo ? { bpm: p.tempo as number } : {}), ...(p.meter ? { meter: p.meter as number } : {}), ...(p.grid ? { grid: p.grid as number } : {}) } : null
558
1081
  const seconds = +time(`section ${name}`, rest[1]).toFixed(6)
559
1082
  if (seconds <= 0) bad(`section ${name}: its length must be above 0`)
560
1083
  const at = last ? +(last.at + last.seconds).toFixed(6) : 0
561
- current = { line: n, name, clock, at, seconds, marks: [] }
1084
+ current = { line: n, name, clock, at, seconds, marks: [], drums: {}, plays: [] }
562
1085
  t.sections.push(current)
563
1086
  pending = { line: n, name, at }
564
1087
  continue
565
1088
  }
1089
+ if (head.text === 'drums') {
1090
+ const into = current?.drums ?? (shot === null && t.score ? t.score.drums : bad('drums names the score\'s drum letters: write it after the score line, or under a section'))
1091
+ if (rest.length === 0) bad('drums wants <letter>=<instrument> …')
1092
+ for (const w of rest) {
1093
+ const m = !w.quoted ? /^([A-Za-z])=([a-zA-Z][a-zA-Z0-9]*(?::[a-z0-9-]+)?)$/.exec(w.text) : null
1094
+ if (!m) bad(`drums wants <letter>=<instrument>, got ${w.text}`)
1095
+ into[m![1]!] = m![2]!
1096
+ }
1097
+ continue
1098
+ }
1099
+ if (head.text === 'stop') {
1100
+ const sec = current ?? bad('a stop is a moment of a section: write it under one')
1101
+ const sc = t.score ?? bad('stop cuts the score dead: write score tempo <bpm> in the header')
1102
+ if (sc.stop) bad(`the score already stops on line ${sc.stop.line}`)
1103
+ if (rest[0]?.text !== 'at' || rest.length !== 2) bad('stop wants at <time>, from its section\'s start')
1104
+ const at = time('stop', rest[1])
1105
+ if (at > sec.seconds) bad(`stop at ${+at.toFixed(3)} s is past section ${sec.name}'s ${+sec.seconds.toFixed(3)} s`)
1106
+ sc.stop = { line: n, at: +(sec.at + at).toFixed(6) }
1107
+ continue
1108
+ }
1109
+ if (head.text === 'defaults') {
1110
+ const sec = current ?? bad('defaults are the film words of a section: write it under one')
1111
+ if (sec.defaults) bad(`section ${sec.name} already has its defaults`)
1112
+ if (rest.length === 0) bad('defaults wants film words: defaults <word> <value> …')
1113
+ sec.defaults = rest.map((w) => ({ ...w, line: n }))
1114
+ continue
1115
+ }
566
1116
  if (head.text === 'mark') {
567
1117
  const sec = current ?? bad('a mark is a moment of a section: write it under one')
568
1118
  const name = word('mark', rest[0], new RegExp(`^${MOMENT}$`))
@@ -573,13 +1123,28 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
573
1123
  sec.marks.push({ line: n, name, at })
574
1124
  continue
575
1125
  }
1126
+ if (head.text === 'footage') {
1127
+ // a block filmed like a shot and never cut in: only a grid or a split reads it
1128
+ const open: Shot = { line: n, name: rest[0]?.text ?? '', length: 0, film: null, titles: [], sfx: [], voices: [], cut: { line: n, kind: 'hard', seconds: 0 }, from: 0, push: null, frame: null, shakes: [], flashes: [], music: null, broken: true, offcut: true }
1129
+ shot = open
1130
+ t.footage.push(open)
1131
+ const name = word('footage', rest[0], /^[a-z0-9][a-z0-9-]*$/)
1132
+ if ([...t.shots, ...t.footage].some((s) => s !== open && s.name === name)) bad(`footage ${name}: ${name} is already a shot or footage of this file`)
1133
+ const length = time(`footage ${name}`, rest[1])
1134
+ if (length <= 0) bad(`footage ${name}: its length must be above 0`)
1135
+ if (rest.length > 2) bad(`footage ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a footage line is footage <name> <length>)`)
1136
+ open.name = name
1137
+ open.length = length
1138
+ delete open.broken
1139
+ continue
1140
+ }
576
1141
  if (head.text === 'shot') {
577
1142
  // a bad shot line still opens a shot, so its own lines never read as the header's or the shot before's
578
1143
  const open: Shot = { line: n, name: rest[0]?.text ?? '', length: 0, film: null, titles: [], sfx: [], voices: [], cut: { line: n, kind: 'hard', seconds: 0 }, from: 0, push: null, frame: null, shakes: [], flashes: [], music: null, broken: true }
579
1144
  shot = open
580
1145
  t.shots.push(open)
581
1146
  const name = word('shot', rest[0], /^[a-z0-9][a-z0-9-]*$/)
582
- if (t.shots.some((s) => s !== open && s.name === name)) bad(`shot ${name} is already a shot of this file`)
1147
+ if ([...t.shots, ...t.footage].some((s) => s !== open && s.name === name)) bad(`shot ${name} is already a shot or footage of this file`)
583
1148
  const length = time(`shot ${name}`, rest[1])
584
1149
  if (length <= 0) bad(`shot ${name}: its length must be above 0`)
585
1150
  if (rest.length > 2) bad(`shot ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a shot line is shot <name> <length>)`)
@@ -612,11 +1177,12 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
612
1177
  t.fps = num('fps', rest[0], 1, FPS_MAX)
613
1178
  if (!Number.isInteger(t.fps)) bad(`fps wants a whole number, got ${rest[0]?.text}`)
614
1179
  break
615
- case 'music':
616
- once('music')
617
- t.music = musicLine(rest, n, true)
618
- if (t.music.song === NO_MUSIC) bad('music none is a shot\'s cue, the header names the song the cut opens on')
1180
+ case 'music': {
1181
+ const m = musicLine(rest, n, true)
1182
+ if (m.song === NO_MUSIC) bad('music none is a shot\'s cue, the header names the song the cut opens on')
1183
+ if (mine(m, t.music, 'the header')) t.music = m
619
1184
  break
1185
+ }
620
1186
  case 'duck': {
621
1187
  once('duck')
622
1188
  const drop = (w: Word | undefined, what: string): number => {
@@ -653,65 +1219,154 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
653
1219
  t.vertical = { line: n, outro: (p.outro as Vertical['outro'] | undefined) ?? 'card', ...(p.mark ? { mark: p.mark as 'left' | 'right' } : {}) }
654
1220
  break
655
1221
  }
1222
+ case 'preset': {
1223
+ const name = word('preset', rest[0], /^[a-z][a-z0-9-]*$/)
1224
+ if (presets.has(name)) bad(`preset ${name} is already a preset of this file`)
1225
+ const words = rest.slice(1)[0]?.text === '=' && !rest[1]!.quoted ? rest.slice(2) : rest.slice(1)
1226
+ if (words.length === 0) bad(`preset ${name} wants film words: preset ${name} <word> <value> …`)
1227
+ presets.set(name, words.map((w) => ({ ...w, line: n })))
1228
+ break
1229
+ }
656
1230
  case 'base':
657
1231
  once('base')
658
1232
  t.base = word('base', rest[0], /^[a-z0-9][a-z0-9-]*$/)
659
1233
  if (rest.length > 1) bad(`base ${t.base}: unknown word ${rest[1]?.text} (base <the id of a global process of this repo>)`)
660
1234
  break
1235
+ case 'score': {
1236
+ once('score')
1237
+ if (t.sections.length > 0) bad('score opens the music the sections play: write it before the first section')
1238
+ const sc: Score = { line: n, clock: { bpm: 120, meter: 4, grid: 4 }, fadeIn: 0, volume: 1, tracks: [], drums: {}, code: pre.code }
1239
+ // reverb takes two values, so the words are read here rather than by pairs
1240
+ const p: Record<string, number> = {}
1241
+ for (let i = 0; i < rest.length; i++) {
1242
+ const k = rest[i]!.text
1243
+ if (rest[i]!.quoted || !['tempo', 'meter', 'grid', 'reverb', 'fade-in', 'volume'].includes(k))
1244
+ bad(`score: unknown word ${k} (score takes tempo, meter, grid, reverb, fade-in, volume)`)
1245
+ if (k in p) bad(`score: ${k} given twice`)
1246
+ if (k === 'reverb') {
1247
+ sc.reverb = { seconds: num('score reverb <seconds> <mix>', rest[i + 1], 0.1, 10), mix: num('score reverb <seconds> <mix>', rest[i + 2], 0, 1) }
1248
+ p[k] = 1
1249
+ i += 2
1250
+ } else if (k === 'fade-in') p[k] = time('score fade-in', rest[++i])
1251
+ else p[k] = num(`score ${k}`, rest[++i], k === 'tempo' ? 20 : k === 'volume' ? 0 : 1, k === 'tempo' ? 400 : k === 'grid' ? 32 : k === 'volume' ? 4 : 16)
1252
+ }
1253
+ if (p.tempo === undefined) bad('score wants its tempo: score tempo <bpm>')
1254
+ sc.clock = { bpm: p.tempo!, meter: p.meter ?? 4, grid: p.grid ?? 4 }
1255
+ sc.fadeIn = p['fade-in'] ?? 0
1256
+ sc.volume = p.volume ?? 1
1257
+ t.score = sc
1258
+ clock = sc.clock
1259
+ break
1260
+ }
1261
+ case 'track': {
1262
+ const sc = t.score ?? bad('a track is a voice of the score: write score tempo <bpm> first')
1263
+ const name = word('track', rest[0], /^[a-zA-Z][a-zA-Z0-9]*$/)
1264
+ const instrument = word(`track ${name}`, rest[1], /^[a-zA-Z][a-zA-Z0-9]*(:[a-z0-9-]+)?$/)
1265
+ if (sc.tracks.some((x) => x.name === name)) bad(`track ${name} is already a track of the score`)
1266
+ if (sc.tracks.length >= TRACKS_MAX) bad(`track ${name}: the score holds ${TRACKS_MAX} tracks at most`)
1267
+ const p = pairs(`track ${name}`, rest.slice(2), { volume })
1268
+ sc.tracks.push({ line: n, name, instrument, volume: (p.volume as number) ?? 1 })
1269
+ break
1270
+ }
661
1271
  default:
662
- bad(`unknown word ${head.text} before the first shot (trailer, size, fps, music, duck, fade-in, vertical, base, section, shot)`)
1272
+ bad(`unknown word ${head.text} before the first shot (trailer, size, fps, music, duck, fade-in, vertical, base, preset, score, track, drums, section, defaults, shot)`)
663
1273
  }
664
1274
  continue
665
1275
  }
666
1276
  const s: Shot = shot
1277
+ if (s.offcut && !['film', 'from', 'frame'].includes(head.text)) bad(`footage ${s.name} is never cut in: it takes film, from and frame, never ${head.text}`)
667
1278
  switch (head.text) {
668
1279
  case 'film':
669
1280
  if (s.film) bad(`shot ${s.name} already films on line ${s.film.line}: one film line a shot`)
670
- s.film = filmLine(rest, n, verbs)
1281
+ {
1282
+ // its option lines, each word keeping its own line
1283
+ const more: Word[] = []
1284
+ for (const o of options.get(n) ?? []) {
1285
+ const tk = tokens(o.text)
1286
+ if ('error' in tk) errors.push({ line: o.line, message: tk.error })
1287
+ else more.push(...tk.words.map((w) => ({ ...w, line: o.line })))
1288
+ }
1289
+ const f = (s.film = filmLine([...rest.map((w) => ({ ...w, line: n })), ...more], n, verbs, {
1290
+ defaults: s.offcut ? [] : (current?.defaults ?? []),
1291
+ presets,
1292
+ time: momentWord,
1293
+ later: (film, slot) => slots.push({ shot: s, film, slot }),
1294
+ ...(o.pageWords ? { pageWords: o.pageWords } : {}),
1295
+ errors,
1296
+ }))
1297
+ // a film that reads no declared words drops the section's defaults: said once, at the defaults line
1298
+ const takes = f.kind === 'cli' ? f.typed === true : f.kind === 'page' && o.pageWords !== undefined
1299
+ if (!s.offcut && current?.defaults && !takes) {
1300
+ const at = current.defaults[0]!.line ?? n
1301
+ const what = f.kind === 'cli' ? `film ${f.argv[0]} declares no words` : f.kind === 'page' ? 'film page declares no words (the trailer unit\'s page)' : `film ${f.kind} is a core film`
1302
+ if (!unread.some((u) => u.line === at)) unread.push({ line: at, section: current.name, films: [] })
1303
+ const u = unread.find((x) => x.line === at)!
1304
+ if (!u.films.includes(what)) u.films.push(what)
1305
+ }
1306
+ }
671
1307
  break
672
1308
  case 'title': {
673
1309
  const text = quoted('title', rest[0])
674
- const p = pairs('title', rest.slice(1), {
675
- at: when,
676
- for: at,
677
- style: (w, k) => {
678
- const st = word(k, w)
679
- return (TITLE_STYLES as readonly string[]).includes(st) ? st : bad(`${k} wants ${TITLE_STYLES.join('|')}, got ${st}`)
680
- },
681
- place: (w, k) => {
682
- const pl = word(k, w)
683
- return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
684
- },
685
- knock: at,
686
- })
1310
+ const p = readTitleWords(rest.slice(1), presets)
1311
+ const st = p.style === undefined ? 'caption' : word('title style', { text: p.style as string, quoted: false }, /^[a-z][a-z0-9-]*$/)
687
1312
  const ti: Title = {
688
1313
  line: n,
689
1314
  text,
690
1315
  at: 0,
691
1316
  for: (p.for as number) ?? null,
692
- style: (p.style as TitleStyle) ?? 'caption',
1317
+ // a brand style is laid in by resolveStyles, once the brand is read
1318
+ style: builtInStyle(st) ? st : 'caption',
1319
+ ...(builtInStyle(st) ? {} : { named: st }),
693
1320
  ...(p.place ? { place: p.place as 'top' | 'bottom' } : {}),
694
1321
  ...(p.knock !== undefined ? { knock: p.knock as number } : {}),
1322
+ ...(p.in ? { in: p.in as Move } : {}),
1323
+ ...(p.out ? { out: p.out as Move } : {}),
695
1324
  }
1325
+ const fx = fxOf(p)
1326
+ if (Object.keys(fx).length > 0) ti.fx = fx
1327
+ if (ti.knock !== undefined && ti.out) bad('title: knock is its exit, give out or knock, not both')
696
1328
  ti.at = landAt(p.at, n, s, (v) => (ti.at = v))
697
1329
  if (ti.knock !== undefined && (ti.knock <= ti.at || (ti.for !== null && ti.knock >= ti.at + ti.for)))
698
1330
  bad(`title knock ${ti.knock}s: inside the title's own span, after its at`)
699
1331
  s.titles.push(ti)
700
1332
  break
701
1333
  }
1334
+ case 'progress': {
1335
+ const names = quoted('progress', rest[0]).split('|').map((x) => x.trim())
1336
+ if (names.length < 2 || names.some((x) => x === '')) bad('progress wants two or more step names, "<a>|<b>|…"')
1337
+ const p = pairs('progress', rest.slice(1), {
1338
+ at: when,
1339
+ every: at,
1340
+ place: (w, k) => {
1341
+ const pl = word(k, w)
1342
+ return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
1343
+ },
1344
+ })
1345
+ const every = (p.every as number | undefined) ?? bad('progress wants every <time>: how long each step stands')
1346
+ names.forEach((text, k) => {
1347
+ const ti: Title = { line: n, text, at: 0, for: every, style: 'progress', step: { i: k + 1, n: names.length }, ...(p.place ? { place: p.place as 'top' | 'bottom' } : {}) }
1348
+ ti.at = landAt(p.at, n, s, (v) => (ti.at = v + k * every)) + k * every
1349
+ s.titles.push(ti)
1350
+ })
1351
+ break
1352
+ }
702
1353
  case 'sfx': {
703
- const id = pathWord('sfx', rest[0])
704
- const p = pairs(`sfx ${id}`, rest.slice(1), { at: when, volume })
705
- const x: Sfx = { line: n, id, at: 0, volume: (p.volume as number) ?? 1 }
1354
+ const r = soundRun('sfx', rest)
1355
+ const words = r ? r.rest : rest.slice(1)
1356
+ const prompt = !r && rest[0]?.quoted === true
1357
+ const id = r ? 'run' : prompt ? quoted('sfx', rest[0]) : pathWord('sfx', rest[0])
1358
+ const p = pairs(`sfx ${r ? 'run' : id}`, words, { at: when, volume, for: at, seed: seedWord }, ['hit'])
1359
+ const x: Sfx = { line: n, id, ...(prompt ? { prompt: true as const } : {}), ...(r ? { run: r.run } : {}), ...genOf(p), at: 0, volume: (p.volume as number) ?? 1 }
706
1360
  x.at = landAt(p.at, n, s, (v) => (x.at = v))
707
1361
  s.sfx.push(x)
708
1362
  break
709
1363
  }
710
1364
  case 'voice': {
711
- const fileForm = rest[0] && !rest[0].quoted && rest[0].text === 'file'
712
- const src = fileForm ? { file: pathWord('voice file', rest[1]) } : { text: quoted('voice', rest[0]) }
713
- const p = pairs('voice', rest.slice(fileForm ? 2 : 1), { at: when, volume })
714
- const v: Voice = { line: n, ...src, at: 0, volume: (p.volume as number) ?? 1 }
1365
+ const r = soundRun('voice', rest)
1366
+ const fileForm = !r && rest[0] && !rest[0].quoted && rest[0].text === 'file'
1367
+ const src = r ? { run: r.run } : fileForm ? { file: pathWord('voice file', rest[1]) } : { text: quoted('voice', rest[0]) }
1368
+ const p = pairs('voice', r ? r.rest : rest.slice(fileForm ? 2 : 1), { at: when, volume, ...(fileForm || r ? {} : { by: byWord, seed: seedWord }) })
1369
+ const v: Voice = { line: n, ...src, ...genOf(p), at: 0, volume: (p.volume as number) ?? 1 }
715
1370
  v.at = landAt(p.at, n, s, (x) => (v.at = x))
716
1371
  s.voices.push(v)
717
1372
  break
@@ -720,16 +1375,18 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
720
1375
  if (s.cut.line !== s.line) bad(`shot ${s.name} already cuts on line ${s.cut.line}`)
721
1376
  const k = word('cut', rest[0])
722
1377
  if (!(CUT_KINDS as readonly string[]).includes(k)) bad(`cut: unknown kind ${k} (${CUT_KINDS.join(', ')})`)
723
- const seconds = k === 'hard' ? 0 : rest[1] ? time(`cut ${k}`, rest[1]) : DEFAULT_CUT_S
1378
+ const seconds = k === 'hard' ? 0 : rest[1] ? time(`cut ${k}`, rest[1]) : (CUT_SECONDS[k as CutKind] ?? DEFAULT_CUT_S)
724
1379
  if (k === 'hard' && rest[1]) bad('cut hard takes no time')
725
1380
  if (rest.length > 2) bad(`cut ${k}: unknown word ${rest[2]?.text}`)
726
1381
  s.cut = { line: n, kind: k as CutKind, seconds }
727
1382
  break
728
1383
  }
729
- case 'music':
730
- if (s.music) bad(`shot ${s.name} already changes the music on line ${s.music.line}`)
731
- s.music = musicLine(rest, n)
1384
+ case 'music': {
1385
+ if (t.score && t.sections.length > 0) bad('music: the score plays from its first section on; a song plays only before it')
1386
+ const m = musicLine(rest, n)
1387
+ if (mine(m, s.music, `shot ${s.name}`)) s.music = m
732
1388
  break
1389
+ }
733
1390
  case 'starts': {
734
1391
  if (s.lands) bad(`shot ${s.name} already lands on line ${s.lands.line}`)
735
1392
  if (rest[0]?.text !== 'on' || !rest[1] || rest.length > 3) bad('starts wants on <section|mark> [+|-<time>]')
@@ -777,7 +1434,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
777
1434
  break
778
1435
  }
779
1436
  default:
780
- bad(`unknown word ${head.text} in shot ${s.name} (film, title, sfx, voice, music, starts, cut, from, push, frame, lift, shake, flash, shot)`)
1437
+ bad(`unknown word ${head.text} in shot ${s.name} (film, title, progress, sfx, voice, music, starts, cut, from, push, frame, lift, shake, flash, shot)`)
781
1438
  }
782
1439
  } catch (e) {
783
1440
  if (!(e instanceof LineError)) throw e
@@ -786,19 +1443,60 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
786
1443
  }
787
1444
  }
788
1445
  if (!seen.has('trailer')) errors.push({ line: 1, message: 'the file names its trailer first: trailer <name>' })
1446
+ if (o.variant !== undefined && !variants.includes(o.variant)) errors.push({ line: 1, message: `--variant ${o.variant}: this cut names ${variants.length ? `the variants ${variants.join(', ')}` : 'no music variant (music … variant <name>)'}` })
1447
+ // a variant other than the first renders beside it: out/<name>-<variant>.mp4
1448
+ if (variant !== null && variant !== variants[0]) t.name = `${t.name}-${variant}`
789
1449
  if (t.shots.length === 0) errors.push({ line: lines.length, message: 'no shot: a trailer has at least one shot <name> <length>' })
790
1450
  if (t.vertical && !seen.has('size')) t.size = [...VERTICAL_SIZE]
791
1451
  else if (t.vertical && t.size[0] >= t.size[1]) errors.push({ line: t.vertical.line, message: `vertical: a short is filmed upright, its size taller than wide (1080x1920), not ${t.size.join('x')}` })
792
1452
  clock = null
1453
+ for (const pl of pre.plays) {
1454
+ const sec = t.sections[pl.section]
1455
+ if (!sec) continue
1456
+ if (!t.score) errors.push({ line: pl.line, message: `play ${pl.track}: a play is the score's: write score tempo <bpm> in the header` })
1457
+ else if (pl.track !== '*' && !t.score.tracks.some((x) => x.name === pl.track))
1458
+ errors.push({ line: pl.line, message: `play ${pl.track}: no track ${pl.track} (${t.score.tracks.map((x) => x.name).join(', ') || 'the score has no track: track <name> <instrument>'})` })
1459
+ else if (sec.plays.some((x) => x.track === pl.track)) errors.push({ line: pl.line, message: `play ${pl.track}: section ${sec.name} already plays it` })
1460
+ else sec.plays.push({ line: pl.line, track: pl.track, expr: pl.expr })
1461
+ }
1462
+ if (pre.code && !t.score) errors.push({ line: pre.code.line, message: 'code is the score\'s: write score tempo <bpm> in the header' })
1463
+ if (t.score && t.sections.length === 0) errors.push({ line: t.score.line, message: 'the score has no section: section <name> <length> under the header, its plays under it' })
793
1464
  errors.push(...landShots(t))
1465
+ if (t.score && t.sections.length > 0 && t.timelineAt === null && !t.shots.some((s) => s.broken))
1466
+ errors.push({ line: t.score.line, message: 'no shot lands on a section, so the score never plays: put a shot under a section, or starts on <section>' })
794
1467
  for (const r of refs) {
795
1468
  const i = t.shots.indexOf(r.shot)
796
1469
  const m = momentOf(t, r.ref.mark)
797
1470
  if (m === null || t.timelineAt === null) errors.push({ line: r.line, message: `at ${r.ref.mark}: no section or mark ${r.ref.mark} in this file` })
798
1471
  else r.set(+(t.timelineAt + m + r.ref.offset - (starts(t)[i] ?? 0)).toFixed(4))
799
1472
  }
800
- errors.push(...structure(t))
801
- return { trailer: t, errors: errors.sort((a, b) => a.line - b.line) }
1473
+ // a page still is the page stepped to its shot's from: the moment is part of its take
1474
+ for (const s of t.shots) if (s.film?.kind === 'page' && s.film.still) s.film.at = s.from
1475
+ for (const p of slots) {
1476
+ const i = t.shots.indexOf(p.shot)
1477
+ const parts = p.slot.parts.map((x) => {
1478
+ if (typeof x.at === 'number') return { name: x.name, seconds: x.at + p.shot.from }
1479
+ const m = momentOf(t, x.at.mark)
1480
+ if (i < 0) errors.push({ line: x.line, message: `film: ${x.at.mark} is a moment of the cut, and footage ${p.shot.name} is never cut in: write seconds` })
1481
+ else if (m === null || t.timelineAt === null) errors.push({ line: x.line, message: `film: no section or mark ${x.at.mark} in this file` })
1482
+ else return { name: x.name, seconds: +(t.timelineAt + m + x.at.offset - (starts(t)[i] ?? 0) + p.shot.from).toFixed(4) }
1483
+ return { name: x.name, seconds: 0 }
1484
+ })
1485
+ // a moment of the take lies in its footage: from 0 to where the shot stops showing it
1486
+ const end = p.shot.from + p.shot.length
1487
+ p.slot.parts.forEach((x, k) => {
1488
+ const sec = parts[k]!.seconds
1489
+ if (sec < -1e-4 || sec > end + 1e-4)
1490
+ errors.push({ line: x.line, message: `film: ${x.name ? `${x.name}@` : ''}${+sec.toFixed(4)}s of the footage is outside the take (0 to ${+end.toFixed(4)}s, its from plus its length)` })
1491
+ })
1492
+ p.film.argv[p.slot.index] = slotText(parts)
1493
+ }
1494
+ for (const u of unread) errors.push({ line: u.line, message: `defaults of section ${u.section}: ${u.films.join(', ')}, so it drops them; give its words on its film line` })
1495
+ errors.push(...structure(t), ...grids(t))
1496
+ // a section's defaults read by each of its film lines say a wrong word once, at its line
1497
+ const said = new Set<string>()
1498
+ const once = errors.filter((e) => !said.has(`${e.line} ${e.message}`) && said.add(`${e.line} ${e.message}`))
1499
+ return { trailer: t, errors: once.sort((a, b) => a.line - b.line) }
802
1500
  }
803
1501
 
804
1502
  /** the least a fitted shot keeps past its cut out */
@@ -853,7 +1551,7 @@ function landShots(t: Trailer): TrailerError[] {
853
1551
 
854
1552
  /** a film that is one picture held for the shot */
855
1553
  export const isStill = (f: Film | null): boolean =>
856
- f?.kind === 'card' || f?.kind === 'image' || ((f?.kind === 'run' || f?.kind === 'url') && f.still === true) || (f?.kind === 'cli' && f.output === 'still')
1554
+ f?.kind === 'card' || f?.kind === 'image' || ((f?.kind === 'run' || f?.kind === 'url' || f?.kind === 'page') && f.still === true) || (f?.kind === 'cli' && f.output === 'still')
857
1555
 
858
1556
  /** what the lines say together: a film per shot, titles inside their shot, cuts shorter than what they join */
859
1557
  function structure(t: Trailer): TrailerError[] {
@@ -861,11 +1559,13 @@ function structure(t: Trailer): TrailerError[] {
861
1559
  t.shots.forEach((s, i) => {
862
1560
  if (s.broken) return
863
1561
  if (!s.film) out.push({ line: s.line, message: `shot ${s.name} films nothing: give it a film line` })
864
- if (isStill(s.film) && s.from > 0) out.push({ line: s.line, message: `shot ${s.name}: its film is a still image, no footage to skip (from)` })
1562
+ if (isStill(s.film) && s.film?.kind !== 'page' && s.from > 0) out.push({ line: s.line, message: `shot ${s.name}: its film is a still image, no footage to skip (from)` })
1563
+ if ((s.film?.kind === 'grid' || s.film?.kind === 'split') && s.from > 0) out.push({ line: s.line, message: `shot ${s.name}: a ${s.film.kind} has no footage of its own to skip; each cell's from skips its own` })
865
1564
  // a title may run on over the shots after its own (a caption over a montage), never past the cut's end
866
1565
  const left = total(t) - (starts(t)[i] ?? 0)
867
1566
  for (const ti of s.titles) {
868
- if (ti.at >= s.length) out.push({ line: ti.line, message: `title at ${ti.at} s starts past shot ${s.name}'s ${s.length} s` })
1567
+ // a progress line's later steps run on over the next shots, as a `for` past its shot does
1568
+ if (ti.at >= s.length && !(ti.step && ti.step.i > 1)) out.push({ line: ti.line, message: `title at ${ti.at} s starts past shot ${s.name}'s ${s.length} s` })
869
1569
  else if (ti.for !== null && ti.at + ti.for > left + 1e-9) out.push({ line: ti.line, message: `title ends ${+(ti.at + ti.for - left).toFixed(2)} s past the cut's end` })
870
1570
  }
871
1571
  for (const x of [...s.sfx, ...s.voices]) if (x.at >= s.length) out.push({ line: x.line, message: `${'id' in x ? 'sfx' : 'voice'} at ${x.at} s starts past shot ${s.name}'s ${s.length} s` })
@@ -885,6 +1585,38 @@ function structure(t: Trailer): TrailerError[] {
885
1585
  return out
886
1586
  }
887
1587
 
1588
+ /** the shots and footage blocks a grid's or a split's cells name */
1589
+ export function gridCells(t: Pick<Trailer, 'shots' | 'footage'>, cells: readonly string[]): (Shot | undefined)[] {
1590
+ return cells.map((c) => [...t.footage, ...t.shots].find((s) => s.name === c))
1591
+ }
1592
+
1593
+ /** a cell plays filmed footage: a take that is a clip, never a grid, a split, a card, an image or a still */
1594
+ export const isCellFilm = (f: Film | null): boolean => (f?.kind === 'run' || f?.kind === 'url' || f?.kind === 'page' || f?.kind === 'cli') && !isStill(f)
1595
+
1596
+ /** what a grid or a split names, and what a footage block films */
1597
+ function grids(t: Trailer): TrailerError[] {
1598
+ const out: TrailerError[] = []
1599
+ for (const s of [...t.shots, ...t.footage]) {
1600
+ if (s.broken || (s.film?.kind !== 'grid' && s.film?.kind !== 'split')) continue
1601
+ const f = s.film
1602
+ if (s.offcut) {
1603
+ out.push({ line: f.line, message: `footage ${s.name}: a ${f.kind} is laid by the stitch, never a footage block` })
1604
+ continue
1605
+ }
1606
+ gridCells(t, f.cells).forEach((c, i) => {
1607
+ const name = f.cells[i]
1608
+ if (!c) out.push({ line: f.line, message: `film ${f.kind}: no shot or footage ${name} in this file` })
1609
+ else if (c.film && !isCellFilm(c.film)) out.push({ line: f.line, message: `film ${f.kind}: ${name} is a ${c.film.kind === 'cli' || c.film.kind === 'run' || c.film.kind === 'url' ? 'still' : c.film.kind}, a cell plays filmed footage` })
1610
+ })
1611
+ }
1612
+ for (const s of t.footage) {
1613
+ if (s.broken) continue
1614
+ if (!s.film) out.push({ line: s.line, message: `footage ${s.name} films nothing: give it a film line` })
1615
+ else if (s.film.kind !== 'grid' && s.film.kind !== 'split' && !isCellFilm(s.film)) out.push({ line: s.film.line, message: `footage ${s.name}: a ${s.film.kind === 'cli' || s.film.kind === 'run' || s.film.kind === 'url' ? 'still' : s.film.kind} is no footage a grid plays` })
1616
+ }
1617
+ return out
1618
+ }
1619
+
888
1620
  /** each shot's start on the cut: a cut overlaps the two shots it joins */
889
1621
  export function starts(t: Pick<Trailer, 'shots'>): number[] {
890
1622
  const out: number[] = []
@@ -905,9 +1637,14 @@ export function total(t: Pick<Trailer, 'shots'>): number {
905
1637
 
906
1638
  /** the music of the cut, cue by cue: each its start on the cut and its end, a crossfade into the next over the next's
907
1639
  * fade-in; a `none` cue is silence and carries no song */
908
- export function musicCues(t: Pick<Trailer, 'music' | 'shots'>): { cue: Music; at: number; end: number }[] {
1640
+ export function musicCues(t: Pick<Trailer, 'music' | 'shots' | 'score' | 'timelineAt'>): { cue: Music; at: number; end: number }[] {
909
1641
  const st = starts(t)
910
- const raw: { cue: Music; at: number }[] = [...(t.music ? [{ cue: t.music, at: 0 }] : []), ...t.shots.flatMap((s, i) => (s.music ? [{ cue: s.music, at: st[i] ?? 0 }] : []))].sort((a, b) => a.at - b.at)
1642
+ const sc = t.score
1643
+ const raw: { cue: Music; at: number }[] = [
1644
+ ...(t.music ? [{ cue: t.music, at: 0 }] : []),
1645
+ ...t.shots.flatMap((s, i) => (s.music ? [{ cue: s.music, at: st[i] ?? 0 }] : [])),
1646
+ ...(sc && t.timelineAt !== null ? [{ cue: { line: sc.line, song: SCORE, from: SCORE_LEAD, fadeIn: sc.fadeIn, fadeOut: 0, volume: sc.volume }, at: t.timelineAt }] : []),
1647
+ ].sort((a, b) => a.at - b.at)
911
1648
  const T = total(t)
912
1649
  return raw
913
1650
  .map((r, i) => ({ ...r, end: Math.min(T, (raw[i + 1]?.at ?? T) + (raw[i + 1]?.cue.fadeIn ?? 0)) }))