rvlib-shipkit 2.1.1 → 2.3.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -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 +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,1290 @@
1
+ import { type LineWord, type Moment, slotText, type Typed, typedFilm, type Words } from './words.ts'
2
+
3
+ // the trailer language, its core words: one text file → the shots, their films, titles, sounds and cuts. Pure: no
4
+ // file, no process; a film word that is not a core kind is looked up in the project CLI's film rows the caller
5
+ // passes in. Every error carries its line, all at once, and a file with one error never builds
6
+
7
+ export type TitleStyle = 'logo' | 'big' | 'caption' | 'corner' | 'line' | 'short' | 'progress'
8
+ /** the styles a title line names; `progress` is its own line's */
9
+ export const TITLE_STYLES: readonly TitleStyle[] = ['logo', 'big', 'caption', 'corner', 'line', 'short']
10
+ export const CUT_KINDS = [
11
+ 'hard',
12
+ 'fade',
13
+ 'dip-black',
14
+ 'dip-white',
15
+ 'slide-left',
16
+ 'slide-right',
17
+ 'slide-up',
18
+ 'slide-down',
19
+ 'whip-left',
20
+ 'whip-right',
21
+ 'wipe-left',
22
+ 'wipe-right',
23
+ 'wipe-up',
24
+ 'wipe-down',
25
+ 'circle',
26
+ 'pixel',
27
+ ] as const
28
+ export type CutKind = (typeof CUT_KINDS)[number]
29
+ export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url', 'grid', 'split'] as const
30
+
31
+ /** the most frames a second a cut runs at */
32
+ export const FPS_MAX = 60
33
+ const DEFAULT_CUT_S = 0.5
34
+
35
+ /** how long a take films: what the shot shows rounded up to 2 s (`steps`, the default), exactly that (`exact`), or as
36
+ * long as it plays (`own`: no --seconds, the key holds none) */
37
+ export type TakeLength = 'steps' | 'exact' | 'own'
38
+
39
+ /** a film row of the project CLI: a still writes one image at `--out`, a clip a video of `--seconds` */
40
+ export interface FilmVerb {
41
+ path: string[]
42
+ film: 'still' | 'clip'
43
+ /** the words the row takes and their types (words.ts): every word checked at its line, values handed plain */
44
+ words?: Words
45
+ /** the paths the row's takes film: their world, over the unit's `stamp` */
46
+ stamp?: string[]
47
+ /** the row's take version: a bump films every take of the row again */
48
+ version?: number | string
49
+ /** `own`: the row films as long as it plays */
50
+ length?: 'own'
51
+ description?: string
52
+ }
53
+
54
+ /** a grid holds at most this many cells, 3 by 3 */
55
+ export const GRID_MAX = 9
56
+ /** a growing grid eases out over this long before each settle */
57
+ export const GROW_S = 1
58
+ /** a split's seams lean this far from upright by default: two panes top left and bottom right, three slanted columns */
59
+ export const SPLIT_ANGLE: Readonly<Record<number, number>> = { 2: 56, 3: 14 }
60
+ /** a split's panes slide in this long by default */
61
+ export const SPLIT_SLIDE = 0.3
62
+
63
+ export type Film =
64
+ | { kind: 'clip'; line: number; path: string }
65
+ | { kind: 'image'; line: number; path: string }
66
+ | { kind: 'card'; line: number }
67
+ /** any command that writes the take at `{out}`; `still`: it writes an image, held for the shot; `reads`: the files
68
+ * the command reads, their content part of the take's key */
69
+ | { kind: 'run'; line: number; cmd: string; still?: true; reads?: string[] }
70
+ /** a film row of the project CLI, its words as written: `<cli> <argv> --out <file> [--seconds <n>]`; its row's take
71
+ * version and length when the row has them */
72
+ | {
73
+ kind: 'cli'
74
+ line: number
75
+ argv: string[]
76
+ output: 'still' | 'clip'
77
+ version?: number | string
78
+ length?: 'own'
79
+ /** the row declares its words: argv holds them plain */
80
+ typed?: true
81
+ /** argv slots that are files as written: the build finds them and keys their content */
82
+ files?: number[]
83
+ /** the paths the row's takes film */
84
+ stamp?: string[]
85
+ }
86
+ /** a page of the repo's dev url (a route) or any http url, through the house shot service; `viewport` its css
87
+ * size, `wait` before it films; `still`: a png, else a clip of the page's largest canvas */
88
+ | { kind: 'url'; line: number; route: string; viewport?: [number, number]; wait?: number; still?: true }
89
+ /** other shots' or footage blocks' takes tiled, each cell playing live; `pop`: cell i pops in at i times it; `grow`:
90
+ * nine cells growing out of the first, settled on the 2 by 2 block then the whole at these seconds */
91
+ | { kind: 'grid'; line: number; cells: string[]; pop: number; grow?: [number, number] }
92
+ /** two or three takes side by side, cut by slanted seams `angle` degrees from upright, each pane sliding in */
93
+ | { kind: 'split'; line: number; cells: string[]; angle: number; slide: number }
94
+
95
+ /** the vertical short: a lockup over every frame, a watermark in a bottom corner, then the outro and the end card,
96
+ * each drawn from the brand's `trailer.vertical` data (vertical.ts) */
97
+ export interface Vertical {
98
+ line: number
99
+ /** the watermark's corner, over the brand's own */
100
+ mark?: 'left' | 'right'
101
+ /** what follows the short: the end card (the default), a clip then the end card, or nothing */
102
+ outro: 'card' | 'none' | { clip: string }
103
+ }
104
+
105
+ /** the size a vertical short films at when the file names none */
106
+ export const VERTICAL_SIZE: [number, number] = [1080, 1920]
107
+
108
+ export interface Title {
109
+ line: number
110
+ text: string
111
+ at: number
112
+ /** null: to the shot's end */
113
+ for: number | null
114
+ style: TitleStyle
115
+ /** where a caption sits: the bottom by default */
116
+ place?: 'top' | 'bottom'
117
+ /** knocked off the frame from this second of its shot: it tips and flies out */
118
+ knock?: number
119
+ /** a `progress` line's step: the i-th of n notches filled under its name */
120
+ step?: { i: number; n: number }
121
+ }
122
+
123
+ export interface Sfx {
124
+ line: number
125
+ /** a file, or an id the repo's sound resolver knows */
126
+ id: string
127
+ at: number
128
+ volume: number
129
+ }
130
+
131
+ export interface Voice {
132
+ line: number
133
+ /** a line the repo's voice resolver finds by its text, or a recorded file */
134
+ text?: string
135
+ file?: string
136
+ at: number
137
+ volume: number
138
+ }
139
+
140
+ export interface Shake {
141
+ line: number
142
+ at: number
143
+ for: number
144
+ strength: number
145
+ }
146
+
147
+ export interface Flash {
148
+ line: number
149
+ at: number
150
+ for: number
151
+ }
152
+
153
+ export interface Cut {
154
+ line: number
155
+ kind: CutKind
156
+ seconds: number
157
+ }
158
+
159
+ /** a section as its first shot sees it: the shot starts when the timeline reaches `at` */
160
+ export interface SectionRef {
161
+ line: number
162
+ name: string
163
+ at: number
164
+ }
165
+
166
+ export interface Music {
167
+ line: number
168
+ /** a file, `none`, or an id the repo's sound resolver knows */
169
+ song: string
170
+ take?: string
171
+ from: number
172
+ fadeIn: number
173
+ fadeOut: number
174
+ volume: number
175
+ }
176
+
177
+ export interface Shot {
178
+ line: number
179
+ name: string
180
+ length: number
181
+ /** the shot opens this section: the shot before it is fitted so it lands on its start */
182
+ section?: SectionRef
183
+ /** `starts on`: the moment it lands on, a section or a mark, and a time from it */
184
+ lands?: { line: number; on: string; offset: number }
185
+ /** the length the file wrote, when a section fitted this shot to another */
186
+ written?: number
187
+ film: Film | null
188
+ titles: Title[]
189
+ sfx: Sfx[]
190
+ voices: Voice[]
191
+ cut: Cut
192
+ /** seconds of footage skipped at its start */
193
+ from: number
194
+ /** a slow push in to this zoom over the shot */
195
+ push: number | null
196
+ /** the footage cropped about its middle by this factor */
197
+ frame: number | null
198
+ /** the shot's dark tones lifted by this gamma, 1 to 2 */
199
+ lift?: number
200
+ /** the point the crop is taken about, 0 to 1 across and down the footage; absent: its middle */
201
+ frameAt?: [number, number]
202
+ shakes: Shake[]
203
+ flashes: Flash[]
204
+ /** the music changes as the shot starts: a crossfade over its fade-in, `none` for silence */
205
+ music: Music | null
206
+ /** its own line failed: nothing else is checked against it */
207
+ broken?: true
208
+ /** a `footage` block: filmed, never cut in */
209
+ offcut?: true
210
+ }
211
+
212
+ /** the tempo a line counts bars and beats in */
213
+ export interface Clock {
214
+ bpm: number
215
+ /** beats in a bar */
216
+ meter: number
217
+ /** rows a beat is written in */
218
+ grid: number
219
+ }
220
+
221
+ export interface Mark {
222
+ line: number
223
+ name: string
224
+ /** seconds from its section's start */
225
+ at: number
226
+ }
227
+
228
+ /** a part of the timeline in bars: its shots land on its start, and the stitch caches it as one piece. Under a
229
+ * score, it is also a part of the music: what each track plays over it */
230
+ export interface Section {
231
+ line: number
232
+ name: string
233
+ clock: Clock | null
234
+ /** where it starts on the timeline, seconds */
235
+ at: number
236
+ seconds: number
237
+ marks: Mark[]
238
+ /** `defaults <words>`: what every declared film line of the section starts from */
239
+ defaults?: Word[]
240
+ /** its drum letters over the score's */
241
+ drums: Record<string, string>
242
+ plays: Play[]
243
+ }
244
+
245
+ /** `play <track> <expression>`: what a track plays over its section, TypeScript; `*` a record of tracks */
246
+ export interface Play {
247
+ line: number
248
+ track: string
249
+ expr: string
250
+ }
251
+
252
+ /** the cut's own music, written in the file (score/score.ts compiles it, score/synth.ts renders it) */
253
+ export interface Score {
254
+ line: number
255
+ /** the first section's clock: the score line's own */
256
+ clock: Clock
257
+ reverb?: { seconds: number; mix: number }
258
+ fadeIn: number
259
+ volume: number
260
+ tracks: { line: number; name: string; instrument: string; volume: number }[]
261
+ /** drum letter → instrument, every section */
262
+ drums: Record<string, string>
263
+ /** the `code` block, its lines at their own line numbers (the others blank) */
264
+ code: { line: number; text: string } | null
265
+ /** `stop at <time>`: the score and its room cut dead there, seconds from the first section's start */
266
+ stop?: { line: number; at: number }
267
+ }
268
+
269
+ /** the voices a score holds at most */
270
+ export const TRACKS_MAX = 16
271
+ /** the music a score starts with: the render's lead, so its first note is never cut */
272
+ export const SCORE_LEAD = 0.05
273
+ /** the score in the cut's music cues */
274
+ export const SCORE = '@score'
275
+
276
+ export interface Trailer {
277
+ name: string
278
+ /** `footage` blocks: filmed like shots, never cut in; a grid or a split reads them */
279
+ footage: Shot[]
280
+ size: [number, number]
281
+ fps: number
282
+ music: Music | null
283
+ /** dB the music drops under a voice line, negative */
284
+ duck: number
285
+ /** dB the music drops under each sfx, negative; null: none */
286
+ duckSfx: number | null
287
+ fadeIn: number
288
+ shots: Shot[]
289
+ sections: Section[]
290
+ /** the cut's own music; null: none */
291
+ score: Score | null
292
+ /** where the timeline of the sections (and the score) starts on the cut: the first landed shot's start less its moment */
293
+ timelineAt: number | null
294
+ /** the cut is a vertical short (`vertical`), finished with the brand's lockup, watermark and end card */
295
+ vertical: Vertical | null
296
+ /** the global process whose url a `film url` route resolves against (`base <id>`); null: the repo's one */
297
+ base: string | null
298
+ }
299
+
300
+ export interface TrailerError {
301
+ line: number
302
+ message: string
303
+ }
304
+
305
+ interface Word {
306
+ text: string
307
+ quoted: boolean
308
+ /** an option line's word keeps its own line */
309
+ line?: number
310
+ }
311
+
312
+ /** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line. Only double quotes quote, as in
313
+ * the full language, so a word opening on a single quote is refused: it would reach a command with the quotes inside */
314
+ export function tokens(line: string): { words: Word[] } | { error: string } {
315
+ const words: Word[] = []
316
+ let i = 0
317
+ while (i < line.length) {
318
+ const c = line[i]!
319
+ if (c === ' ' || c === '\t') {
320
+ i++
321
+ continue
322
+ }
323
+ if (c === '#') break
324
+ if (c === "'") {
325
+ const end = line.indexOf("'", i + 1)
326
+ const word = end < 0 ? line.slice(i).split(/[\s#]/)[0]! : line.slice(i, end + 1)
327
+ return { error: `${word} opens on a single quote: the language quotes with double quotes only, write "${word.slice(1, end < 0 ? undefined : -1)}"` }
328
+ }
329
+ if (c === '"') {
330
+ const end = line.indexOf('"', i + 1)
331
+ if (end < 0) return { error: 'a quote is not closed' }
332
+ words.push({ text: line.slice(i + 1, end), quoted: true })
333
+ i = end + 1
334
+ continue
335
+ }
336
+ let j = i
337
+ while (j < line.length && line[j] !== ' ' && line[j] !== '\t' && line[j] !== '"' && line[j] !== '#') j++
338
+ words.push({ text: line.slice(i, j), quoted: false })
339
+ i = j
340
+ }
341
+ return { words }
342
+ }
343
+
344
+ class LineError extends Error {}
345
+ const bad = (message: string): never => {
346
+ throw new LineError(message)
347
+ }
348
+
349
+ /** the clock the line being read counts bars and beats in: each section's, from its line on */
350
+ let clock: Clock | null = null
351
+
352
+ /** `1.5s`, `400ms`, `m:ss(.f)`, and at a clock `2bars`, `1beat`, as seconds; terms add up with `+` (`1bar+0.5beat`) */
353
+ export function parseTime(s: string, c: Clock | null = null): number | null {
354
+ let total = 0
355
+ for (const term of s.split('+')) {
356
+ let m = /^(\d+(?:\.\d+)?)s$/.exec(term)
357
+ if (m) total += Number(m[1])
358
+ else if ((m = /^(\d+)ms$/.exec(term))) total += Number(m[1]) / 1000
359
+ else if ((m = /^(\d+):([0-5]\d(?:\.\d+)?)$/.exec(term))) total += Number(m[1]) * 60 + Number(m[2])
360
+ else if ((m = /^(\d+(?:\.\d+)?)(bars?|beats?)$/.exec(term)) && c) total += (Number(m[1]) * (m[2]!.startsWith('bar') ? c.meter : 1) * 60) / c.bpm
361
+ else return null
362
+ }
363
+ return total
364
+ }
365
+
366
+ const TIMES = '1.5s, 400ms, 0:04, 2bars, 1beat, 1bar+2beats'
367
+ const time = (what: string, w: Word | undefined): number => {
368
+ if (!w || w.quoted) return bad(`${what} wants a time (${TIMES})`)
369
+ const v = parseTime(w.text, clock)
370
+ if (v !== null) return v
371
+ if (/bar|beat/.test(w.text) && !clock) return bad(`${what}: ${w.text} counts bars and beats, which need a tempo (section <name> <length> tempo <bpm>)`)
372
+ return bad(`${what} wants a time (${TIMES}), got ${w.text}`)
373
+ }
374
+
375
+ /** a section's or a mark's name as an `at` reads it: words of a letter and more, joined by `-` */
376
+ const MOMENT = '[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*'
377
+
378
+ /** a moment named on the timeline, and a time from it */
379
+ interface MarkAt {
380
+ mark: string
381
+ offset: number
382
+ }
383
+
384
+ /** an `at`: a time, or a section or mark with an offset (`drop`, `drop-1beat`, `title+0.5s`) */
385
+ const when = (w: Word | undefined, k: string): number | MarkAt => {
386
+ const m = w && !w.quoted ? new RegExp(`^(${MOMENT})(?:([+-])(\\d.*))?$`).exec(w.text) : null
387
+ if (!m) return time(k, w)
388
+ const off = m[3] === undefined ? 0 : (parseTime(m[3], clock) ?? bad(`${k}: ${m[3]} after ${m[1]} is no time (${TIMES})`))
389
+ return { mark: m[1]!, offset: m[2] === '-' ? -off : off }
390
+ }
391
+ const num = (what: string, w: Word | undefined, lo: number, hi: number): number => {
392
+ const n = w && !w.quoted ? Number(w.text) : Number.NaN
393
+ if (!Number.isFinite(n) || n < lo || n > hi) return bad(`${what} wants a number from ${lo} to ${hi}${w ? `, got ${w.text}` : ''}`)
394
+ return n
395
+ }
396
+ const word = (what: string, w: Word | undefined, re = /^[a-z0-9][a-z0-9:_.,=+-]*$/i): string => {
397
+ if (!w || w.quoted || !re.test(w.text)) return bad(`${what} wants a word${w ? `, got ${w.quoted ? `"${w.text}"` : w.text}` : ''}`)
398
+ return w.text
399
+ }
400
+ const quoted = (what: string, w: Word | undefined): string => {
401
+ if (!w || !w.quoted) return bad(`${what} wants a quoted text${w ? `, got ${w.text}` : ''}`)
402
+ if (w.text.trim() === '') return bad(`${what}: the text is empty`)
403
+ return w.text
404
+ }
405
+ /** a file path or an id, bare or quoted */
406
+ const pathWord = (what: string, w: Word | undefined): string => {
407
+ if (!w || w.text.trim() === '' || (!w.quoted && /\s/.test(w.text))) return bad(`${what} wants a file${w ? `, got ${w.text}` : ''}`)
408
+ return w.text
409
+ }
410
+
411
+ /** `WxH` in pixels, each side from `lo` to `hi` */
412
+ const sizeWord = (what: string, w: Word | undefined, lo: number, hi: number): [number, number] => {
413
+ const m = w && !w.quoted ? /^(\d+)x(\d+)$/.exec(w.text) : null
414
+ const [x, y] = [Number(m?.[1]), Number(m?.[2])]
415
+ if (!m || x < lo || y < lo || x > hi || y > hi) return bad(`${what} wants WxH, from ${lo} to ${hi} a side${w ? `, got ${w.text}` : ''}`)
416
+ return [x, y]
417
+ }
418
+
419
+ /** the `key value` pairs after a line's fixed words; `flags` take no value */
420
+ function pairs(
421
+ what: string,
422
+ rest: Word[],
423
+ valued: Record<string, (w: Word | undefined, key: string) => unknown>,
424
+ flags: readonly string[] = [],
425
+ ): Record<string, unknown> {
426
+ const out: Record<string, unknown> = {}
427
+ for (let i = 0; i < rest.length; i++) {
428
+ const k = rest[i]!
429
+ if (k.quoted) bad(`${what}: a quoted text "${k.text}" where a word was expected`)
430
+ if (k.text in out) bad(`${what}: ${k.text} given twice`)
431
+ if (flags.includes(k.text)) {
432
+ out[k.text] = true
433
+ continue
434
+ }
435
+ const f = valued[k.text]
436
+ if (!f) bad(`${what}: unknown word ${k.text} (${what} takes ${[...Object.keys(valued), ...flags].join(', ') || 'nothing more'})`)
437
+ out[k.text] = f!(rest[++i], `${what} ${k.text}`)
438
+ }
439
+ return out
440
+ }
441
+
442
+ /** the film row a line's words start with: the longest row path at their head */
443
+ export function verbOf(words: readonly string[], verbs: readonly FilmVerb[]): FilmVerb | null {
444
+ let best: FilmVerb | null = null
445
+ for (const v of verbs)
446
+ if (v.path.length <= words.length && v.path.every((p, i) => words[i] === p) && v.path.length > (best?.path.length ?? 0)) best = v
447
+ return best
448
+ }
449
+
450
+ /** what a film line may start with in this repo, for its errors */
451
+ const filmWords = (verbs: readonly FilmVerb[] | null): string =>
452
+ `${CORE_FILMS.join(', ')}${verbs === null ? '; no project CLI answers `<cli> commands --json` (the `cli` script of package.json)' : verbs.length === 0 ? '; the project CLI has no film row' : `, or a film row of the project CLI: ${verbs.map((v) => v.path.join(' ')).join(', ')}`}`
453
+
454
+ /** a film line: a core kind when its words fit that kind's form (`clip <video file>`), else a film row of the project
455
+ * CLI (its `clip <scene>` row), else the core kind's error */
456
+ /** what a film line of a declared row reads past its words: its section's defaults, the header's presets, a time */
457
+ interface FilmContext {
458
+ defaults: Word[]
459
+ presets: ReadonlyMap<string, Word[]>
460
+ time: (text: string) => Moment | null
461
+ /** a slot that waits on a moment, resolved once the shots have landed */
462
+ later: (f: Extract<Film, { kind: 'cli' }>, slot: Typed['times'][number]) => void
463
+ errors: TrailerError[]
464
+ }
465
+
466
+ /** a film line's words with every `+<preset>` put in place */
467
+ function expandPresets(ws: Word[], line: number, presets: ReadonlyMap<string, Word[]>): Word[] {
468
+ return ws.flatMap((w) => {
469
+ if (w.quoted || !/^\+[a-z][a-z0-9-]*$/.test(w.text)) return [w]
470
+ 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(', ')}` : ''})`)
471
+ return p.map((x) => ({ ...x, line: w.line ?? line }))
472
+ })
473
+ }
474
+
475
+ function filmLine(raw: Word[], line: number, verbs: readonly FilmVerb[] | null, c?: FilmContext): Film {
476
+ const ws = c ? expandPresets(raw, line, c.presets) : raw
477
+ const kind = ws[0] && !ws[0].quoted ? ws[0].text : ''
478
+ const argv = ws.map((w) => w.text)
479
+ const v = verbs && verbOf(argv, verbs)
480
+ if ((CORE_FILMS as readonly string[]).includes(kind)) {
481
+ try {
482
+ return coreFilm(kind, ws, line)
483
+ } catch (e) {
484
+ if (!(e instanceof LineError) || !v) throw e
485
+ }
486
+ }
487
+ if (!v) return bad(`film: unknown kind ${kind || '(none)'} (${filmWords(verbs)})`)
488
+ 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`)
489
+ const marks = { ...(v.version !== undefined ? { version: v.version } : {}), ...(v.length ? { length: v.length } : {}), ...(v.stamp ? { stamp: v.stamp } : {}) }
490
+ if (!v.words || !c) return { kind: 'cli', line, argv, output: v.film, ...marks }
491
+ const at = (w: Word): LineWord => ({ text: w.text, quoted: w.quoted, line: w.line ?? line })
492
+ const typed = typedFilm(v.path, v.words, ws.slice(v.path.length).map(at), c.defaults.map(at), c.time)
493
+ c.errors.push(...typed.errors)
494
+ const f: Extract<Film, { kind: 'cli' }> = { kind: 'cli', line, argv: typed.argv, output: v.film, typed: true, ...(typed.files.length ? { files: typed.files } : {}), ...marks }
495
+ for (const slot of typed.times) c.later(f, slot)
496
+ return f
497
+ }
498
+
499
+ /** a list of `min` to `max` different shot or footage names */
500
+ function cellList(what: string, w: Word | undefined, min: number, max: number): string[] {
501
+ const list = word(what, w, /^[a-z0-9][a-z0-9-]*(?:,[a-z0-9][a-z0-9-]*)*$/)
502
+ const cells = list.split(',')
503
+ if (cells.length < min || cells.length > max) bad(`${what}: ${min} to ${max} cells, got ${cells.length}`)
504
+ if (new Set(cells).size !== cells.length) bad(`${what}: a cell named twice in ${list}`)
505
+ return cells
506
+ }
507
+
508
+ function coreFilm(kind: string, ws: Word[], line: number): Film {
509
+ switch (kind) {
510
+ case 'clip': {
511
+ const path = pathWord('film clip', ws[1])
512
+ if (!/\.(mp4|webm|mov|mkv)$/i.test(path)) bad(`film clip wants a video file (.mp4, .webm, .mov, .mkv), got ${path}`)
513
+ pairs(`film clip ${path}`, ws.slice(2), {})
514
+ return { kind, line, path }
515
+ }
516
+ case 'image': {
517
+ const path = pathWord('film image', ws[1])
518
+ if (!/\.(png|jpe?g|webp)$/i.test(path)) bad(`film image wants an image file (.png, .jpg, .webp), got ${path}`)
519
+ pairs(`film image ${path}`, ws.slice(2), {})
520
+ return { kind, line, path }
521
+ }
522
+ case 'card':
523
+ pairs('film card', ws.slice(1), {})
524
+ return { kind, line }
525
+ case 'run': {
526
+ const cmd = quoted('film run', ws[1])
527
+ if (!cmd.includes('{out}')) bad('film run wants {out} in its command: the file the command writes the take to')
528
+ const p = pairs('film run', ws.slice(2), { reads: (w, k) => pathWord(k, w).split(',') }, ['still'])
529
+ return { kind, line, cmd, ...(p.still ? { still: true as const } : {}), ...(p.reads ? { reads: p.reads as string[] } : {}) }
530
+ }
531
+ case 'url': {
532
+ const route = pathWord('film url', ws[1])
533
+ if (!/^(\/|https?:\/\/)/.test(route)) bad(`film url wants a route of the dev url (/pricing) or an http url, got ${route}`)
534
+ const p = pairs(`film url ${route}`, ws.slice(2), { viewport: (w, k) => sizeWord(k, w, 16, 7680), wait: at }, ['still'])
535
+ return {
536
+ kind,
537
+ line,
538
+ route,
539
+ ...(p.viewport ? { viewport: p.viewport as [number, number] } : {}),
540
+ ...(p.wait !== undefined ? { wait: p.wait as number } : {}),
541
+ ...(p.still ? { still: true as const } : {}),
542
+ }
543
+ }
544
+ case 'grid': {
545
+ const cells = cellList('film grid', ws[1], 1, GRID_MAX)
546
+ const p = pairs(`film grid ${cells.join(',')}`, ws.slice(2), {
547
+ pop: at,
548
+ grow: (w, k) => {
549
+ const t = word(k, w, /^\d+(?:\.\d+)?s,\d+(?:\.\d+)?s$/)
550
+ const v = t.split(',').map((x) => Number(x.slice(0, -1)))
551
+ 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}`)
552
+ },
553
+ })
554
+ 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}`)
555
+ if (p.grow && p.pop) bad('film grid: grow or pop, not both')
556
+ return { kind, line, cells, pop: (p.pop as number) ?? 0, ...(p.grow ? { grow: p.grow as [number, number] } : {}) }
557
+ }
558
+ case 'split': {
559
+ const cells = cellList('film split', ws[1], 2, 3)
560
+ const p = pairs(`film split ${cells.join(',')}`, ws.slice(2), { angle: (w, k) => num(k, w, 0, 70), slide: at })
561
+ return { kind, line, cells, angle: (p.angle as number) ?? SPLIT_ANGLE[cells.length] ?? 0, slide: (p.slide as number) ?? SPLIT_SLIDE }
562
+ }
563
+ }
564
+ return bad(`film: unknown kind ${kind}`)
565
+ }
566
+
567
+ const volume = (w: Word | undefined, k: string) => num(k, w, 0, 4)
568
+ const at = (w: Word | undefined, k: string) => time(k, w)
569
+
570
+ /** a music cue that is silence */
571
+ export const NO_MUSIC = 'none'
572
+
573
+ /** the cut's opening cue fades in this long when it starts inside its song and names no fade-in */
574
+ export const OPENING_FADE_IN = 0.25
575
+
576
+ /** `music <file|id|none> [take k] [from t] [fade-in t] [fade-out t] [volume v]`; `opening`: the cut's own */
577
+ function musicLine(rest: Word[], n: number, opening = false): Music {
578
+ const song = pathWord('music', rest[0])
579
+ const p = pairs(`music ${song}`, rest.slice(1), {
580
+ take: (w, k) => word(k, w, /^[a-z0-9][a-z0-9-]*$/),
581
+ from: at,
582
+ 'fade-in': at,
583
+ 'fade-out': at,
584
+ volume,
585
+ })
586
+ return {
587
+ line: n,
588
+ song,
589
+ ...(p.take ? { take: p.take as string } : {}),
590
+ from: (p.from as number) ?? 0,
591
+ fadeIn: (p['fade-in'] as number | undefined) ?? (opening && ((p.from as number | undefined) ?? 0) > 0 ? OPENING_FADE_IN : 0),
592
+ fadeOut: (p['fade-out'] as number) ?? 0,
593
+ volume: (p.volume as number) ?? 1,
594
+ }
595
+ }
596
+
597
+ /**
598
+ * the lines the language reads as TypeScript, taken out before the words are read (their lines blank, so every line
599
+ * keeps its number): the `code` block (its indented lines under a margin `code` line) and each `play` line with the
600
+ * deeper indented lines that continue it. A line whose first mark is `#` is off in both.
601
+ */
602
+ export function scoreLines(text: string): { lines: string[]; code: Score['code']; plays: (Play & { section: number })[]; errors: TrailerError[] } {
603
+ const lines = text.split('\n')
604
+ const errors: TrailerError[] = []
605
+ const plays: (Play & { section: number })[] = []
606
+ let code: Score['code'] = null
607
+ let section = -1
608
+ const indent = (l: string): number => l.length - l.trimStart().length
609
+ const off = (l: string): boolean => l.trimStart().startsWith('#')
610
+ for (let i = 0; i < lines.length; i++) {
611
+ const l = lines[i]!
612
+ if (/^section\s/.test(l)) section++
613
+ if (/^code\s*(#.*)?$/.test(l)) {
614
+ const head = i + 1
615
+ if (code) errors.push({ line: head, message: `code is already written on line ${code.line}: one code block a file` })
616
+ const body: string[] = Array.from({ length: head }, () => '')
617
+ lines[i] = ''
618
+ for (let j = i + 1; j < lines.length; j++) {
619
+ const b = lines[j]!
620
+ if (b.trim() !== '' && !off(b) && indent(b) === 0) break
621
+ body.push(off(b) ? '' : b.replace(/^( {2}|\t)/, ''))
622
+ lines[j] = ''
623
+ i = j
624
+ }
625
+ code ??= { line: head, text: body.join('\n') }
626
+ continue
627
+ }
628
+ const m = /^(\s*)play(?:\s+(\S+))?(?:\s+(.*))?$/.exec(l)
629
+ if (!m || off(l)) continue
630
+ const at = i + 1
631
+ const parts = [m[3] ?? '']
632
+ lines[i] = ''
633
+ for (let j = i + 1; j < lines.length; j++) {
634
+ const b = lines[j]!
635
+ // deeper lines continue the expression; a closing bracket at the play's own indent ends it, and is its
636
+ const close = indent(b) === (m[1] ?? '').length && /^[)\]}]/.test(b.trimStart())
637
+ if (b.trim() !== '' && !off(b) && indent(b) <= (m[1] ?? '').length && !close) break
638
+ if (!off(b)) parts.push(b)
639
+ if (close) {
640
+ lines[j] = ''
641
+ i = j
642
+ break
643
+ }
644
+ lines[j] = ''
645
+ i = j
646
+ }
647
+ const track = m[2] ?? ''
648
+ const expr = parts.join('\n').trim()
649
+ 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}` : ''}` })
650
+ else if (expr === '') errors.push({ line: at, message: `play ${track}: nothing to play` })
651
+ else if (section < 0) errors.push({ line: at, message: `play ${track}: a play belongs to a section, write it under one` })
652
+ else plays.push({ line: at, track, expr, section })
653
+ }
654
+ return { lines, code, plays, errors }
655
+ }
656
+
657
+ export interface ParseOptions {
658
+ /** the project CLI's film rows; null when the repo has no project CLI */
659
+ verbs?: readonly FilmVerb[] | null
660
+ }
661
+
662
+ /** the file as a trailer, and every error with its line */
663
+ /** a film line's option lines: the lines under it indented deeper, each `key value`, taken out before the words are
664
+ * read (their lines blank, so every line keeps its number) and kept by the film line's number */
665
+ export function filmOptionLines(lines: string[]): Map<number, { line: number; text: string }[]> {
666
+ const out = new Map<number, { line: number; text: string }[]>()
667
+ const indent = (l: string): number => l.length - l.trimStart().length
668
+ for (let i = 0; i < lines.length; i++) {
669
+ const m = /^(\s*)film(\s|$)/.exec(lines[i]!)
670
+ if (!m) continue
671
+ const head = i + 1
672
+ const own: { line: number; text: string }[] = []
673
+ for (let j = i + 1; j < lines.length; j++) {
674
+ const l = lines[j]!
675
+ if (l.trim() === '' || l.trimStart().startsWith('#')) continue
676
+ if (indent(l) <= m[1]!.length) break
677
+ own.push({ line: j + 1, text: l })
678
+ lines[j] = ''
679
+ i = j
680
+ }
681
+ if (own.length) out.set(head, own)
682
+ }
683
+ return out
684
+ }
685
+
686
+ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Trailer; errors: TrailerError[] } {
687
+ const verbs = o.verbs ?? null
688
+ 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 }
689
+ const errors: TrailerError[] = []
690
+ const seen = new Set<string>()
691
+ let shot: Shot | null = null
692
+ let pending: SectionRef | null = null
693
+ let current: Section | null = null
694
+ /** the `at`s that name a mark, set once every shot has landed */
695
+ const refs: { line: number; shot: Shot; ref: MarkAt; set: (at: number) => void }[] = []
696
+ const landAt = (v: unknown, line: number, s: Shot, set: (at: number) => void): number => {
697
+ if (typeof v !== 'object' || v === null) return (v as number | undefined) ?? 0
698
+ refs.push({ line, shot: s, ref: v as MarkAt, set })
699
+ return 0
700
+ }
701
+ clock = null
702
+ const pre = scoreLines(text)
703
+ errors.push(...pre.errors)
704
+ const lines = pre.lines
705
+ const options = filmOptionLines(lines)
706
+ const presets = new Map<string, Word[]>()
707
+ /** the film slots that wait on a moment: resolved once the shots have landed, each plus its shot's `from` */
708
+ const slots: { shot: Shot; film: Extract<Film, { kind: 'cli' }>; slot: Typed['times'][number] }[] = []
709
+ /** a time as a declared word reads it, at the clock of its line; null when it is none */
710
+ const momentWord = (w: string): Moment | null => {
711
+ const m = new RegExp(`^(${MOMENT})(?:([+-])(\\d.*))?$`).exec(w)
712
+ // a bare number is seconds, as a cue of a take counts them
713
+ if (!m) return /^\d+(\.\d+)?$/.test(w) ? Number(w) : parseTime(w, clock)
714
+ const off = m[3] === undefined ? 0 : parseTime(m[3], clock)
715
+ return off === null ? null : { mark: m[1]!, offset: m[2] === '-' ? -off : off }
716
+ }
717
+ for (let n = 1; n <= lines.length; n++) {
718
+ const tk = tokens(lines[n - 1]!)
719
+ if ('error' in tk) {
720
+ errors.push({ line: n, message: tk.error })
721
+ // its own line failed: the shot is not also told it films nothing
722
+ if (shot && /^\s*film(\s|$)/.test(lines[n - 1]!)) shot.broken = true
723
+ continue
724
+ }
725
+ const ws = tk.words
726
+ if (ws.length === 0) continue
727
+ const head = ws[0]!
728
+ const rest = ws.slice(1)
729
+ const once = (key: string): void => {
730
+ if (seen.has(key)) bad(`${key} given twice`)
731
+ seen.add(key)
732
+ }
733
+ try {
734
+ if (head.quoted) bad(`a line starts with a word, got "${head.text}"`)
735
+ if (head.text === 'section') {
736
+ const name = word('section', rest[0], /^[a-z0-9][a-z0-9-]*$/)
737
+ const last = t.sections.at(-1)
738
+ if (t.sections.some((x) => x.name === name)) bad(`section ${name} is already a section of this file`)
739
+ const p = pairs(`section ${name}`, rest.slice(2), {
740
+ tempo: (w, k) => num(k, w, 20, 400),
741
+ meter: (w, k) => num(k, w, 1, 16),
742
+ grid: (w, k) => num(k, w, 1, 32),
743
+ })
744
+ const base = last?.clock ?? t.score?.clock ?? null
745
+ if (!base && !p.tempo && (p.meter || p.grid)) bad(`section ${name}: meter and grid count in a tempo, give it tempo <bpm>`)
746
+ 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
747
+ const seconds = +time(`section ${name}`, rest[1]).toFixed(6)
748
+ if (seconds <= 0) bad(`section ${name}: its length must be above 0`)
749
+ const at = last ? +(last.at + last.seconds).toFixed(6) : 0
750
+ current = { line: n, name, clock, at, seconds, marks: [], drums: {}, plays: [] }
751
+ t.sections.push(current)
752
+ pending = { line: n, name, at }
753
+ continue
754
+ }
755
+ if (head.text === 'drums') {
756
+ 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'))
757
+ if (rest.length === 0) bad('drums wants <letter>=<instrument> …')
758
+ for (const w of rest) {
759
+ const m = !w.quoted ? /^([A-Za-z])=([a-zA-Z][a-zA-Z0-9]*)$/.exec(w.text) : null
760
+ if (!m) bad(`drums wants <letter>=<instrument>, got ${w.text}`)
761
+ into[m![1]!] = m![2]!
762
+ }
763
+ continue
764
+ }
765
+ if (head.text === 'stop') {
766
+ const sec = current ?? bad('a stop is a moment of a section: write it under one')
767
+ const sc = t.score ?? bad('stop cuts the score dead: write score tempo <bpm> in the header')
768
+ if (sc.stop) bad(`the score already stops on line ${sc.stop.line}`)
769
+ if (rest[0]?.text !== 'at' || rest.length !== 2) bad('stop wants at <time>, from its section\'s start')
770
+ const at = time('stop', rest[1])
771
+ if (at > sec.seconds) bad(`stop at ${+at.toFixed(3)} s is past section ${sec.name}'s ${+sec.seconds.toFixed(3)} s`)
772
+ sc.stop = { line: n, at: +(sec.at + at).toFixed(6) }
773
+ continue
774
+ }
775
+ if (head.text === 'defaults') {
776
+ const sec = current ?? bad('defaults are the film words of a section: write it under one')
777
+ if (sec.defaults) bad(`section ${sec.name} already has its defaults`)
778
+ if (rest.length === 0) bad('defaults wants film words: defaults <word> <value> …')
779
+ sec.defaults = rest.map((w) => ({ ...w, line: n }))
780
+ continue
781
+ }
782
+ if (head.text === 'mark') {
783
+ const sec = current ?? bad('a mark is a moment of a section: write it under one')
784
+ const name = word('mark', rest[0], new RegExp(`^${MOMENT}$`))
785
+ if (rest[1]?.text !== 'at' || rest.length !== 3) bad(`mark ${name} wants at <time>, from its section's start`)
786
+ if (t.sections.some((x) => x.name === name || x.marks.some((m) => m.name === name))) bad(`mark ${name}: ${name} already names a section or a mark`)
787
+ const at = time(`mark ${name}`, rest[2])
788
+ if (at >= sec.seconds) bad(`mark ${name} at ${+at.toFixed(3)} s is past section ${sec.name}'s ${+sec.seconds.toFixed(3)} s`)
789
+ sec.marks.push({ line: n, name, at })
790
+ continue
791
+ }
792
+ if (head.text === 'footage') {
793
+ // a block filmed like a shot and never cut in: only a grid or a split reads it
794
+ 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 }
795
+ shot = open
796
+ t.footage.push(open)
797
+ const name = word('footage', rest[0], /^[a-z0-9][a-z0-9-]*$/)
798
+ 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`)
799
+ const length = time(`footage ${name}`, rest[1])
800
+ if (length <= 0) bad(`footage ${name}: its length must be above 0`)
801
+ if (rest.length > 2) bad(`footage ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a footage line is footage <name> <length>)`)
802
+ open.name = name
803
+ open.length = length
804
+ delete open.broken
805
+ continue
806
+ }
807
+ if (head.text === 'shot') {
808
+ // a bad shot line still opens a shot, so its own lines never read as the header's or the shot before's
809
+ 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 }
810
+ shot = open
811
+ t.shots.push(open)
812
+ const name = word('shot', rest[0], /^[a-z0-9][a-z0-9-]*$/)
813
+ if ([...t.shots, ...t.footage].some((s) => s !== open && s.name === name)) bad(`shot ${name} is already a shot or footage of this file`)
814
+ const length = time(`shot ${name}`, rest[1])
815
+ if (length <= 0) bad(`shot ${name}: its length must be above 0`)
816
+ if (rest.length > 2) bad(`shot ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a shot line is shot <name> <length>)`)
817
+ open.name = name
818
+ open.length = length
819
+ if (pending) open.section = pending
820
+ pending = null
821
+ delete open.broken
822
+ continue
823
+ }
824
+ if (shot === null) {
825
+ switch (head.text) {
826
+ case 'trailer':
827
+ once('trailer')
828
+ t.name = word('trailer', rest[0], /^[a-z0-9][a-z0-9-]*$/)
829
+ if (rest.length > 1) bad(`trailer ${t.name}: unknown word ${rest[1]?.text}`)
830
+ break
831
+ case 'size': {
832
+ once('size')
833
+ const m = rest[0] && !rest[0].quoted ? /^(\d+)x(\d+)$/.exec(rest[0].text) : null
834
+ const w = Number(m?.[1])
835
+ const h = Number(m?.[2])
836
+ if (!m || w < 160 || h < 90 || w > 3840 || h > 3840 || w % 2 || h % 2)
837
+ bad(`size wants WxH, even, from 160x90 to 3840x3840${rest[0] ? `, got ${rest[0].text}` : ''}`)
838
+ t.size = [w, h]
839
+ break
840
+ }
841
+ case 'fps':
842
+ once('fps')
843
+ t.fps = num('fps', rest[0], 1, FPS_MAX)
844
+ if (!Number.isInteger(t.fps)) bad(`fps wants a whole number, got ${rest[0]?.text}`)
845
+ break
846
+ case 'music':
847
+ once('music')
848
+ t.music = musicLine(rest, n, true)
849
+ if (t.music.song === NO_MUSIC) bad('music none is a shot\'s cue, the header names the song the cut opens on')
850
+ break
851
+ case 'duck': {
852
+ once('duck')
853
+ const drop = (w: Word | undefined, what: string): number => {
854
+ const m = w && !w.quoted ? /^(-?\d+(?:\.\d+)?)(?:dB)?$/.exec(w.text) : null
855
+ const db = Number(m?.[1])
856
+ if (!m || db > 0 || db < -40) bad(`${what} wants a drop in dB from -40 to 0 (-10dB)${w ? `, got ${w.text}` : ''}`)
857
+ return db
858
+ }
859
+ t.duck = drop(rest[0], 'duck')
860
+ if (rest.length > 1) {
861
+ if (rest[1]!.text !== 'sfx' || rest.length !== 3) bad(`duck takes its drop, then sfx and the drop under each sfx (duck -10dB sfx -5dB), got ${rest.slice(1).map((w) => w.text).join(' ')}`)
862
+ t.duckSfx = drop(rest[2], 'duck sfx')
863
+ }
864
+ break
865
+ }
866
+ case 'fade-in':
867
+ once('fade-in')
868
+ t.fadeIn = time('fade-in', rest[0])
869
+ break
870
+ case 'vertical': {
871
+ once('vertical')
872
+ const p = pairs('vertical', rest, {
873
+ mark: (w, k) => {
874
+ const c = word(k, w)
875
+ return c === 'left' || c === 'right' ? c : bad(`${k} wants left|right, got ${c}`)
876
+ },
877
+ outro: (w, k) => {
878
+ const o = pathWord(k, w)
879
+ if (o === 'card' || o === 'none') return o
880
+ if (!/\.(mp4|webm|mov|mkv)$/i.test(o)) bad(`${k} wants card, none or a video file, got ${o}`)
881
+ return { clip: o }
882
+ },
883
+ })
884
+ t.vertical = { line: n, outro: (p.outro as Vertical['outro'] | undefined) ?? 'card', ...(p.mark ? { mark: p.mark as 'left' | 'right' } : {}) }
885
+ break
886
+ }
887
+ case 'preset': {
888
+ const name = word('preset', rest[0], /^[a-z][a-z0-9-]*$/)
889
+ if (presets.has(name)) bad(`preset ${name} is already a preset of this file`)
890
+ const words = rest.slice(1)[0]?.text === '=' && !rest[1]!.quoted ? rest.slice(2) : rest.slice(1)
891
+ if (words.length === 0) bad(`preset ${name} wants film words: preset ${name} <word> <value> …`)
892
+ presets.set(name, words.map((w) => ({ ...w, line: n })))
893
+ break
894
+ }
895
+ case 'base':
896
+ once('base')
897
+ t.base = word('base', rest[0], /^[a-z0-9][a-z0-9-]*$/)
898
+ if (rest.length > 1) bad(`base ${t.base}: unknown word ${rest[1]?.text} (base <the id of a global process of this repo>)`)
899
+ break
900
+ case 'score': {
901
+ once('score')
902
+ if (t.sections.length > 0) bad('score opens the music the sections play: write it before the first section')
903
+ const sc: Score = { line: n, clock: { bpm: 120, meter: 4, grid: 4 }, fadeIn: 0, volume: 1, tracks: [], drums: {}, code: pre.code }
904
+ // reverb takes two values, so the words are read here rather than by pairs
905
+ const p: Record<string, number> = {}
906
+ for (let i = 0; i < rest.length; i++) {
907
+ const k = rest[i]!.text
908
+ if (rest[i]!.quoted || !['tempo', 'meter', 'grid', 'reverb', 'fade-in', 'volume'].includes(k))
909
+ bad(`score: unknown word ${k} (score takes tempo, meter, grid, reverb, fade-in, volume)`)
910
+ if (k in p) bad(`score: ${k} given twice`)
911
+ if (k === 'reverb') {
912
+ 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) }
913
+ p[k] = 1
914
+ i += 2
915
+ } else if (k === 'fade-in') p[k] = time('score fade-in', rest[++i])
916
+ 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)
917
+ }
918
+ if (p.tempo === undefined) bad('score wants its tempo: score tempo <bpm>')
919
+ sc.clock = { bpm: p.tempo!, meter: p.meter ?? 4, grid: p.grid ?? 4 }
920
+ sc.fadeIn = p['fade-in'] ?? 0
921
+ sc.volume = p.volume ?? 1
922
+ t.score = sc
923
+ clock = sc.clock
924
+ break
925
+ }
926
+ case 'track': {
927
+ const sc = t.score ?? bad('a track is a voice of the score: write score tempo <bpm> first')
928
+ const name = word('track', rest[0], /^[a-zA-Z][a-zA-Z0-9]*$/)
929
+ const instrument = word(`track ${name}`, rest[1], /^[a-zA-Z][a-zA-Z0-9]*$/)
930
+ if (sc.tracks.some((x) => x.name === name)) bad(`track ${name} is already a track of the score`)
931
+ if (sc.tracks.length >= TRACKS_MAX) bad(`track ${name}: the score holds ${TRACKS_MAX} tracks at most`)
932
+ const p = pairs(`track ${name}`, rest.slice(2), { volume })
933
+ sc.tracks.push({ line: n, name, instrument, volume: (p.volume as number) ?? 1 })
934
+ break
935
+ }
936
+ default:
937
+ 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)`)
938
+ }
939
+ continue
940
+ }
941
+ const s: Shot = shot
942
+ 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}`)
943
+ switch (head.text) {
944
+ case 'film':
945
+ if (s.film) bad(`shot ${s.name} already films on line ${s.film.line}: one film line a shot`)
946
+ {
947
+ // its option lines, each word keeping its own line
948
+ const more: Word[] = []
949
+ for (const o of options.get(n) ?? []) {
950
+ const tk = tokens(o.text)
951
+ if ('error' in tk) errors.push({ line: o.line, message: tk.error })
952
+ else more.push(...tk.words.map((w) => ({ ...w, line: o.line })))
953
+ }
954
+ s.film = filmLine([...rest.map((w) => ({ ...w, line: n })), ...more], n, verbs, {
955
+ defaults: s.offcut ? [] : (current?.defaults ?? []),
956
+ presets,
957
+ time: momentWord,
958
+ later: (film, slot) => slots.push({ shot: s, film, slot }),
959
+ errors,
960
+ })
961
+ }
962
+ break
963
+ case 'title': {
964
+ const text = quoted('title', rest[0])
965
+ const p = pairs('title', rest.slice(1), {
966
+ at: when,
967
+ for: at,
968
+ style: (w, k) => {
969
+ const st = word(k, w)
970
+ return (TITLE_STYLES as readonly string[]).includes(st) ? st : bad(`${k} wants ${TITLE_STYLES.join('|')}, got ${st}`)
971
+ },
972
+ place: (w, k) => {
973
+ const pl = word(k, w)
974
+ return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
975
+ },
976
+ knock: at,
977
+ })
978
+ const ti: Title = {
979
+ line: n,
980
+ text,
981
+ at: 0,
982
+ for: (p.for as number) ?? null,
983
+ style: (p.style as TitleStyle) ?? 'caption',
984
+ ...(p.place ? { place: p.place as 'top' | 'bottom' } : {}),
985
+ ...(p.knock !== undefined ? { knock: p.knock as number } : {}),
986
+ }
987
+ ti.at = landAt(p.at, n, s, (v) => (ti.at = v))
988
+ if (ti.knock !== undefined && (ti.knock <= ti.at || (ti.for !== null && ti.knock >= ti.at + ti.for)))
989
+ bad(`title knock ${ti.knock}s: inside the title's own span, after its at`)
990
+ s.titles.push(ti)
991
+ break
992
+ }
993
+ case 'progress': {
994
+ const names = quoted('progress', rest[0]).split('|').map((x) => x.trim())
995
+ if (names.length < 2 || names.some((x) => x === '')) bad('progress wants two or more step names, "<a>|<b>|…"')
996
+ const p = pairs('progress', rest.slice(1), {
997
+ at: when,
998
+ every: at,
999
+ place: (w, k) => {
1000
+ const pl = word(k, w)
1001
+ return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
1002
+ },
1003
+ })
1004
+ const every = (p.every as number | undefined) ?? bad('progress wants every <time>: how long each step stands')
1005
+ names.forEach((text, k) => {
1006
+ 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' } : {}) }
1007
+ ti.at = landAt(p.at, n, s, (v) => (ti.at = v + k * every)) + k * every
1008
+ s.titles.push(ti)
1009
+ })
1010
+ break
1011
+ }
1012
+ case 'sfx': {
1013
+ const id = pathWord('sfx', rest[0])
1014
+ const p = pairs(`sfx ${id}`, rest.slice(1), { at: when, volume })
1015
+ const x: Sfx = { line: n, id, at: 0, volume: (p.volume as number) ?? 1 }
1016
+ x.at = landAt(p.at, n, s, (v) => (x.at = v))
1017
+ s.sfx.push(x)
1018
+ break
1019
+ }
1020
+ case 'voice': {
1021
+ const fileForm = rest[0] && !rest[0].quoted && rest[0].text === 'file'
1022
+ const src = fileForm ? { file: pathWord('voice file', rest[1]) } : { text: quoted('voice', rest[0]) }
1023
+ const p = pairs('voice', rest.slice(fileForm ? 2 : 1), { at: when, volume })
1024
+ const v: Voice = { line: n, ...src, at: 0, volume: (p.volume as number) ?? 1 }
1025
+ v.at = landAt(p.at, n, s, (x) => (v.at = x))
1026
+ s.voices.push(v)
1027
+ break
1028
+ }
1029
+ case 'cut': {
1030
+ if (s.cut.line !== s.line) bad(`shot ${s.name} already cuts on line ${s.cut.line}`)
1031
+ const k = word('cut', rest[0])
1032
+ if (!(CUT_KINDS as readonly string[]).includes(k)) bad(`cut: unknown kind ${k} (${CUT_KINDS.join(', ')})`)
1033
+ const seconds = k === 'hard' ? 0 : rest[1] ? time(`cut ${k}`, rest[1]) : DEFAULT_CUT_S
1034
+ if (k === 'hard' && rest[1]) bad('cut hard takes no time')
1035
+ if (rest.length > 2) bad(`cut ${k}: unknown word ${rest[2]?.text}`)
1036
+ s.cut = { line: n, kind: k as CutKind, seconds }
1037
+ break
1038
+ }
1039
+ case 'music':
1040
+ if (s.music) bad(`shot ${s.name} already changes the music on line ${s.music.line}`)
1041
+ if (t.score && t.sections.length > 0) bad('music: the score plays from its first section on; a song plays only before it')
1042
+ s.music = musicLine(rest, n)
1043
+ break
1044
+ case 'starts': {
1045
+ if (s.lands) bad(`shot ${s.name} already lands on line ${s.lands.line}`)
1046
+ if (rest[0]?.text !== 'on' || !rest[1] || rest.length > 3) bad('starts wants on <section|mark> [+|-<time>]')
1047
+ const on = word('starts on', rest[1], /^[a-z0-9][a-z0-9-]*$/)
1048
+ const o2 = rest[2] && !rest[2].quoted ? /^([+-])(.+)$/.exec(rest[2].text) : null
1049
+ if (rest[2] && !o2) bad(`starts on ${on}: the time from it is signed, +0.5s or -1beat, got ${rest[2].text}`)
1050
+ const off = o2 ? time(`starts on ${on}`, { text: o2[2]!, quoted: false }) : 0
1051
+ s.lands = { line: n, on, offset: o2?.[1] === '-' ? -off : off }
1052
+ break
1053
+ }
1054
+ case 'from':
1055
+ s.from = time('from', rest[0])
1056
+ if (rest.length > 1) bad(`from: unknown word ${rest[1]?.text}`)
1057
+ break
1058
+ case 'push':
1059
+ s.push = num('push', rest[0], 1, 3)
1060
+ if (rest.length > 1) bad(`push: unknown word ${rest[1]?.text}`)
1061
+ break
1062
+ case 'lift':
1063
+ s.lift = num('lift', rest[0], 1, 2)
1064
+ if (rest.length > 1) bad(`lift: unknown word ${rest[1]?.text}`)
1065
+ break
1066
+ case 'frame':
1067
+ if (s.frame !== null) bad(`shot ${s.name} already frames its footage`)
1068
+ s.frame = num('frame', rest[0], 1, 3)
1069
+ if (rest[1]?.text === 'at' && !rest[1].quoted) {
1070
+ const m = /^(\d*\.?\d+),(\d*\.?\d+)$/.exec(rest[2] && !rest[2].quoted ? rest[2].text : '')
1071
+ if (!m || Number(m[1]) > 1 || Number(m[2]) > 1) bad(`frame at wants a point <x>,<y>, each 0 to 1 across and down the frame${rest[2] ? `, got ${rest[2].text}` : ''}`)
1072
+ else s.frameAt = [Number(m[1]), Number(m[2])]
1073
+ if (rest.length > 3) bad(`frame: unknown word ${rest[3]?.text}`)
1074
+ } else if (rest.length > 1) bad(`frame: unknown word ${rest[1]?.text}`)
1075
+ break
1076
+ case 'shake': {
1077
+ const p = pairs('shake', rest, { at: when, for: at, strength: (w, k) => num(k, w, 0.2, 3) })
1078
+ const x: Shake = { line: n, at: 0, for: (p.for as number) ?? 0.4, strength: (p.strength as number) ?? 1 }
1079
+ x.at = landAt(p.at, n, s, (v) => (x.at = v))
1080
+ s.shakes.push(x)
1081
+ break
1082
+ }
1083
+ case 'flash': {
1084
+ const p = pairs('flash', rest, { at: when, for: at })
1085
+ const x: Flash = { line: n, at: 0, for: (p.for as number) ?? 0.25 }
1086
+ x.at = landAt(p.at, n, s, (v) => (x.at = v))
1087
+ s.flashes.push(x)
1088
+ break
1089
+ }
1090
+ default:
1091
+ bad(`unknown word ${head.text} in shot ${s.name} (film, title, progress, sfx, voice, music, starts, cut, from, push, frame, lift, shake, flash, shot)`)
1092
+ }
1093
+ } catch (e) {
1094
+ if (!(e instanceof LineError)) throw e
1095
+ errors.push({ line: n, message: e.message })
1096
+ if (shot && head.text === 'film') shot.broken = true
1097
+ }
1098
+ }
1099
+ if (!seen.has('trailer')) errors.push({ line: 1, message: 'the file names its trailer first: trailer <name>' })
1100
+ if (t.shots.length === 0) errors.push({ line: lines.length, message: 'no shot: a trailer has at least one shot <name> <length>' })
1101
+ if (t.vertical && !seen.has('size')) t.size = [...VERTICAL_SIZE]
1102
+ 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')}` })
1103
+ clock = null
1104
+ for (const pl of pre.plays) {
1105
+ const sec = t.sections[pl.section]
1106
+ if (!sec) continue
1107
+ 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` })
1108
+ else if (pl.track !== '*' && !t.score.tracks.some((x) => x.name === pl.track))
1109
+ 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>'})` })
1110
+ 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` })
1111
+ else sec.plays.push({ line: pl.line, track: pl.track, expr: pl.expr })
1112
+ }
1113
+ if (pre.code && !t.score) errors.push({ line: pre.code.line, message: 'code is the score\'s: write score tempo <bpm> in the header' })
1114
+ 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' })
1115
+ errors.push(...landShots(t))
1116
+ if (t.score && t.sections.length > 0 && t.timelineAt === null && !t.shots.some((s) => s.broken))
1117
+ 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>' })
1118
+ for (const r of refs) {
1119
+ const i = t.shots.indexOf(r.shot)
1120
+ const m = momentOf(t, r.ref.mark)
1121
+ 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` })
1122
+ else r.set(+(t.timelineAt + m + r.ref.offset - (starts(t)[i] ?? 0)).toFixed(4))
1123
+ }
1124
+ for (const p of slots) {
1125
+ const i = t.shots.indexOf(p.shot)
1126
+ const parts = p.slot.parts.map((x) => {
1127
+ if (typeof x.at === 'number') return { name: x.name, seconds: x.at + p.shot.from }
1128
+ const m = momentOf(t, x.at.mark)
1129
+ 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` })
1130
+ else if (m === null || t.timelineAt === null) errors.push({ line: x.line, message: `film: no section or mark ${x.at.mark} in this file` })
1131
+ else return { name: x.name, seconds: +(t.timelineAt + m + x.at.offset - (starts(t)[i] ?? 0) + p.shot.from).toFixed(4) }
1132
+ return { name: x.name, seconds: 0 }
1133
+ })
1134
+ p.film.argv[p.slot.index] = slotText(parts)
1135
+ }
1136
+ errors.push(...structure(t), ...grids(t))
1137
+ return { trailer: t, errors: errors.sort((a, b) => a.line - b.line) }
1138
+ }
1139
+
1140
+ /** the least a fitted shot keeps past its cut out */
1141
+ const FIT_MIN_S = 0.3
1142
+
1143
+ /** a section's start, or a mark's moment, in the timeline's seconds; null when no section or mark has that name */
1144
+ function momentOf(t: Pick<Trailer, 'sections'>, name: string): number | null {
1145
+ for (const sec of t.sections) {
1146
+ if (sec.name === name) return sec.at
1147
+ const m = sec.marks.find((x) => x.name === name)
1148
+ if (m) return sec.at + m.at
1149
+ }
1150
+ return null
1151
+ }
1152
+
1153
+ /**
1154
+ * every shot that lands on the timeline starts at its moment: the first one sets where the timeline starts on the cut,
1155
+ * and the shot before each later one takes the length that lands it (fitted). An edit inside a section moves nothing
1156
+ * past the next landed shot
1157
+ */
1158
+ function landShots(t: Trailer): TrailerError[] {
1159
+ const out: TrailerError[] = []
1160
+ t.shots.forEach((s, i) => {
1161
+ if (s.broken) return
1162
+ const land = s.lands ?? (s.section ? { line: s.section.line, on: s.section.name, offset: 0 } : null)
1163
+ if (!land) return
1164
+ const moment = momentOf(t, land.on)
1165
+ if (moment === null) {
1166
+ out.push({ line: land.line, message: `starts on ${land.on}: no section or mark ${land.on} in this file` })
1167
+ return
1168
+ }
1169
+ const st = starts(t)
1170
+ if (t.timelineAt === null) {
1171
+ t.timelineAt = +((st[i] ?? 0) - moment - land.offset).toFixed(4)
1172
+ return
1173
+ }
1174
+ const prev = t.shots[i - 1]
1175
+ if (!prev || prev.broken) {
1176
+ out.push({ line: land.line, message: `shot ${s.name}: no shot before it to fit` })
1177
+ return
1178
+ }
1179
+ const length = prev.length + (t.timelineAt + moment + land.offset - (st[i] ?? 0))
1180
+ if (length - prev.cut.seconds < FIT_MIN_S) {
1181
+ out.push({ line: land.line, message: `shot ${s.name} lands on ${land.on}: shot ${prev.name} would last ${+length.toFixed(2)} s, too short; the shots before run ${+(prev.length - length).toFixed(2)} s long` })
1182
+ return
1183
+ }
1184
+ prev.written ??= prev.length
1185
+ prev.length = +length.toFixed(4)
1186
+ })
1187
+ return out
1188
+ }
1189
+
1190
+ /** a film that is one picture held for the shot */
1191
+ export const isStill = (f: Film | null): boolean =>
1192
+ f?.kind === 'card' || f?.kind === 'image' || ((f?.kind === 'run' || f?.kind === 'url') && f.still === true) || (f?.kind === 'cli' && f.output === 'still')
1193
+
1194
+ /** what the lines say together: a film per shot, titles inside their shot, cuts shorter than what they join */
1195
+ function structure(t: Trailer): TrailerError[] {
1196
+ const out: TrailerError[] = []
1197
+ t.shots.forEach((s, i) => {
1198
+ if (s.broken) return
1199
+ if (!s.film) out.push({ line: s.line, message: `shot ${s.name} films nothing: give it a film line` })
1200
+ 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)` })
1201
+ 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` })
1202
+ // a title may run on over the shots after its own (a caption over a montage), never past the cut's end
1203
+ const left = total(t) - (starts(t)[i] ?? 0)
1204
+ for (const ti of s.titles) {
1205
+ // a progress line's later steps run on over the next shots, as a `for` past its shot does
1206
+ 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` })
1207
+ 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` })
1208
+ }
1209
+ 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` })
1210
+ for (const x of [...s.shakes, ...s.flashes]) if (x.at >= s.length) out.push({ line: x.line, message: `${'strength' in x ? 'shake' : 'flash'} at ${x.at} s starts past shot ${s.name}'s ${s.length} s` })
1211
+ const next = t.shots[i + 1]
1212
+ if (s.cut.seconds > 0) {
1213
+ if (s.cut.seconds >= s.length) out.push({ line: s.cut.line, message: `cut ${s.cut.kind} ${s.cut.seconds} s is as long as shot ${s.name} or longer` })
1214
+ else if (next && !next.broken && s.cut.seconds >= next.length) out.push({ line: s.cut.line, message: `cut ${s.cut.kind} ${s.cut.seconds} s is as long as the next shot ${next.name} or longer` })
1215
+ if (!next && !['fade', 'dip-black', 'dip-white'].includes(s.cut.kind)) out.push({ line: s.cut.line, message: `the last shot ends on fade, dip-black or dip-white, never ${s.cut.kind}` })
1216
+ }
1217
+ const prev = t.shots[i - 1]
1218
+ if (prev && next && prev.cut.seconds < s.length && s.cut.seconds < s.length && prev.cut.seconds + s.cut.seconds >= s.length)
1219
+ out.push({ line: s.line, message: `shot ${s.name} is all cut: the cuts in and out (${prev.cut.seconds} s + ${s.cut.seconds} s) leave nothing of its ${s.length} s` })
1220
+ })
1221
+ const music = t.music
1222
+ if (music && music.fadeIn + music.fadeOut > total(t)) out.push({ line: music.line, message: `music fades for longer than the cut's ${total(t).toFixed(2)} s` })
1223
+ return out
1224
+ }
1225
+
1226
+ /** the shots and footage blocks a grid's or a split's cells name */
1227
+ export function gridCells(t: Pick<Trailer, 'shots' | 'footage'>, cells: readonly string[]): (Shot | undefined)[] {
1228
+ return cells.map((c) => [...t.footage, ...t.shots].find((s) => s.name === c))
1229
+ }
1230
+
1231
+ /** a cell plays filmed footage: a take that is a clip, never a grid, a split, a card, an image or a still */
1232
+ export const isCellFilm = (f: Film | null): boolean => (f?.kind === 'run' || f?.kind === 'url' || f?.kind === 'cli') && !isStill(f)
1233
+
1234
+ /** what a grid or a split names, and what a footage block films */
1235
+ function grids(t: Trailer): TrailerError[] {
1236
+ const out: TrailerError[] = []
1237
+ for (const s of [...t.shots, ...t.footage]) {
1238
+ if (s.broken || (s.film?.kind !== 'grid' && s.film?.kind !== 'split')) continue
1239
+ const f = s.film
1240
+ if (s.offcut) {
1241
+ out.push({ line: f.line, message: `footage ${s.name}: a ${f.kind} is laid by the stitch, never a footage block` })
1242
+ continue
1243
+ }
1244
+ gridCells(t, f.cells).forEach((c, i) => {
1245
+ const name = f.cells[i]
1246
+ if (!c) out.push({ line: f.line, message: `film ${f.kind}: no shot or footage ${name} in this file` })
1247
+ 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` })
1248
+ })
1249
+ }
1250
+ for (const s of t.footage) {
1251
+ if (s.broken) continue
1252
+ if (!s.film) out.push({ line: s.line, message: `footage ${s.name} films nothing: give it a film line` })
1253
+ 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` })
1254
+ }
1255
+ return out
1256
+ }
1257
+
1258
+ /** each shot's start on the cut: a cut overlaps the two shots it joins */
1259
+ export function starts(t: Pick<Trailer, 'shots'>): number[] {
1260
+ const out: number[] = []
1261
+ let at = 0
1262
+ for (const s of t.shots) {
1263
+ out.push(at)
1264
+ at += s.length - s.cut.seconds
1265
+ }
1266
+ return out
1267
+ }
1268
+
1269
+ /** the cut's length: the last shot's ending is inside its own length */
1270
+ export function total(t: Pick<Trailer, 'shots'>): number {
1271
+ const last = t.shots.at(-1)
1272
+ if (!last) return 0
1273
+ return (starts(t).at(-1) ?? 0) + last.length
1274
+ }
1275
+
1276
+ /** 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
1277
+ * fade-in; a `none` cue is silence and carries no song */
1278
+ export function musicCues(t: Pick<Trailer, 'music' | 'shots' | 'score' | 'timelineAt'>): { cue: Music; at: number; end: number }[] {
1279
+ const st = starts(t)
1280
+ const sc = t.score
1281
+ const raw: { cue: Music; at: number }[] = [
1282
+ ...(t.music ? [{ cue: t.music, at: 0 }] : []),
1283
+ ...t.shots.flatMap((s, i) => (s.music ? [{ cue: s.music, at: st[i] ?? 0 }] : [])),
1284
+ ...(sc && t.timelineAt !== null ? [{ cue: { line: sc.line, song: SCORE, from: SCORE_LEAD, fadeIn: sc.fadeIn, fadeOut: 0, volume: sc.volume }, at: t.timelineAt }] : []),
1285
+ ].sort((a, b) => a.at - b.at)
1286
+ const T = total(t)
1287
+ return raw
1288
+ .map((r, i) => ({ ...r, end: Math.min(T, (raw[i + 1]?.at ?? T) + (raw[i + 1]?.cue.fadeIn ?? 0)) }))
1289
+ .filter((r) => r.cue.song !== NO_MUSIC && r.end > r.at)
1290
+ }