rvlib-shipkit 2.1.0 → 2.2.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 (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +2 -1
  3. package/src/assets/kits/identity-kit.ts +7 -3
  4. package/src/assets/run-assets.ts +18 -4
  5. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  6. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  7. package/src/cli/cmd_CacheAudit.ts +3 -2
  8. package/src/cli/cmd_Calls.ts +53 -0
  9. package/src/cli/cmd_Ci.ts +4 -3
  10. package/src/cli/cmd_Commands.ts +158 -43
  11. package/src/cli/cmd_Fleet.ts +3 -1
  12. package/src/cli/cmd_GlobalProcesses.ts +7 -2
  13. package/src/cli/cmd_Shot.ts +63 -0
  14. package/src/cli/cmd_Start.ts +8 -4
  15. package/src/cli/cmd_Trailer.ts +108 -0
  16. package/src/cli/kit/builtins.ts +40 -0
  17. package/src/cli/kit/calls.ts +177 -0
  18. package/src/cli/kit/families.ts +67 -0
  19. package/src/cli/kit/index.ts +8 -0
  20. package/src/cli/kit/pages-check.ts +36 -0
  21. package/src/cli/kit/project-cli.ts +73 -0
  22. package/src/cli/kit/verb.ts +157 -0
  23. package/src/cli/list-flag.ts +3 -0
  24. package/src/cli.ts +13 -2
  25. package/src/core/layout.ts +10 -2
  26. package/src/core/migrate-layout.ts +31 -8
  27. package/src/core/one-way-in-policy.ts +12 -4
  28. package/src/mv/mv-md.ts +2 -1
  29. package/src/reflect/prompt-corpus.ts +5 -0
  30. package/src/scaffold/cli-skeleton.ts +149 -0
  31. package/src/scaffold/files/agent-index.md +2 -2
  32. package/src/scaffold/files/tsconfig.json +3 -0
  33. package/src/scaffold/files/verify-skill.md +1 -1
  34. package/src/scaffold/project-scaffold.ts +90 -12
  35. package/src/shipkit-files.ts +8 -4
  36. package/src/shot/client.ts +67 -0
  37. package/src/shot/clip-encoder.ts +146 -0
  38. package/src/shot/dom-clip.ts +124 -0
  39. package/src/shot/serve-shot.ts +54 -0
  40. package/src/shot/shooter.ts +343 -0
  41. package/src/shot/shot-ask.ts +189 -0
  42. package/src/shot/shot.shipkit.ts +14 -0
  43. package/src/smoke/run-smoke.ts +2 -2
  44. package/src/start-repo.ts +29 -14
  45. package/src/suite/judge-suites.ts +56 -10
  46. package/src/suite/select.ts +2 -5
  47. package/src/suite/suite-cli.ts +3 -2
  48. package/src/trailer/adapter.ts +23 -0
  49. package/src/trailer/build.ts +344 -0
  50. package/src/trailer/ffmpeg.ts +50 -0
  51. package/src/trailer/footage.ts +104 -0
  52. package/src/trailer/grammar.ts +915 -0
  53. package/src/trailer/graph.ts +323 -0
  54. package/src/trailer/load-unit.ts +14 -0
  55. package/src/trailer/look.ts +110 -0
  56. package/src/trailer/plan.ts +133 -0
  57. package/src/trailer/stitch.ts +150 -0
  58. package/src/trailer/titles.ts +274 -0
  59. package/src/trailer/url.ts +108 -0
  60. package/src/trailer/vertical-draw.ts +286 -0
  61. package/src/trailer/vertical.ts +148 -0
  62. package/src/trailer/vocab.ts +40 -0
  63. package/src/ts/rename.ts +9 -0
@@ -0,0 +1,915 @@
1
+ // the trailer language, its core words: one text file → the shots, their films, titles, sounds and cuts. Pure: no
2
+ // 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
+ // passes in. Every error carries its line, all at once, and a file with one error never builds
4
+
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
27
+
28
+ /** the most frames a second a cut runs at */
29
+ export const FPS_MAX = 60
30
+ const DEFAULT_CUT_S = 0.5
31
+
32
+ /** a film row of the project CLI: a still writes one image at `--out`, a clip a video of `--seconds` */
33
+ export interface FilmVerb {
34
+ path: string[]
35
+ film: 'still' | 'clip'
36
+ description?: string
37
+ }
38
+
39
+ export type Film =
40
+ | { kind: 'clip'; line: number; path: string }
41
+ | { kind: 'image'; line: number; path: string }
42
+ | { 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' }
47
+ /** a page of the repo's dev url (a route) or any http url, through the house shot service; `viewport` its css
48
+ * size, `wait` before it films; `still`: a png, else a clip of the page's largest canvas */
49
+ | { kind: 'url'; line: number; route: string; viewport?: [number, number]; wait?: number; still?: true }
50
+
51
+ /** the vertical short: a lockup over every frame, a watermark in a bottom corner, then the outro and the end card,
52
+ * each drawn from the brand's `trailer.vertical` data (vertical.ts) */
53
+ export interface Vertical {
54
+ line: number
55
+ /** the watermark's corner, over the brand's own */
56
+ mark?: 'left' | 'right'
57
+ /** what follows the short: the end card (the default), a clip then the end card, or nothing */
58
+ outro: 'card' | 'none' | { clip: string }
59
+ }
60
+
61
+ /** the size a vertical short films at when the file names none */
62
+ export const VERTICAL_SIZE: [number, number] = [1080, 1920]
63
+
64
+ export interface Title {
65
+ line: number
66
+ text: string
67
+ at: number
68
+ /** null: to the shot's end */
69
+ for: number | null
70
+ style: TitleStyle
71
+ /** where a caption sits: the bottom by default */
72
+ place?: 'top' | 'bottom'
73
+ /** knocked off the frame from this second of its shot: it tips and flies out */
74
+ knock?: number
75
+ }
76
+
77
+ export interface Sfx {
78
+ line: number
79
+ /** a file, or an id the repo's sound resolver knows */
80
+ id: string
81
+ at: number
82
+ volume: number
83
+ }
84
+
85
+ export interface Voice {
86
+ line: number
87
+ /** a line the repo's voice resolver finds by its text, or a recorded file */
88
+ text?: string
89
+ file?: string
90
+ at: number
91
+ volume: number
92
+ }
93
+
94
+ export interface Shake {
95
+ line: number
96
+ at: number
97
+ for: number
98
+ strength: number
99
+ }
100
+
101
+ export interface Flash {
102
+ line: number
103
+ at: number
104
+ for: number
105
+ }
106
+
107
+ export interface Cut {
108
+ line: number
109
+ kind: CutKind
110
+ seconds: number
111
+ }
112
+
113
+ /** a section as its first shot sees it: the shot starts when the timeline reaches `at` */
114
+ export interface SectionRef {
115
+ line: number
116
+ name: string
117
+ at: number
118
+ }
119
+
120
+ export interface Music {
121
+ line: number
122
+ /** a file, `none`, or an id the repo's sound resolver knows */
123
+ song: string
124
+ take?: string
125
+ from: number
126
+ fadeIn: number
127
+ fadeOut: number
128
+ volume: number
129
+ }
130
+
131
+ export interface Shot {
132
+ line: number
133
+ name: string
134
+ length: number
135
+ /** the shot opens this section: the shot before it is fitted so it lands on its start */
136
+ section?: SectionRef
137
+ /** `starts on`: the moment it lands on, a section or a mark, and a time from it */
138
+ lands?: { line: number; on: string; offset: number }
139
+ /** the length the file wrote, when a section fitted this shot to another */
140
+ written?: number
141
+ film: Film | null
142
+ titles: Title[]
143
+ sfx: Sfx[]
144
+ voices: Voice[]
145
+ cut: Cut
146
+ /** seconds of footage skipped at its start */
147
+ from: number
148
+ /** a slow push in to this zoom over the shot */
149
+ push: number | null
150
+ /** the footage cropped about its middle by this factor */
151
+ frame: number | null
152
+ /** the shot's dark tones lifted by this gamma, 1 to 2 */
153
+ lift?: number
154
+ /** the point the crop is taken about, 0 to 1 across and down the footage; absent: its middle */
155
+ frameAt?: [number, number]
156
+ shakes: Shake[]
157
+ flashes: Flash[]
158
+ /** the music changes as the shot starts: a crossfade over its fade-in, `none` for silence */
159
+ music: Music | null
160
+ /** its own line failed: nothing else is checked against it */
161
+ broken?: true
162
+ }
163
+
164
+ /** the tempo a line counts bars and beats in */
165
+ export interface Clock {
166
+ bpm: number
167
+ /** beats in a bar */
168
+ meter: number
169
+ /** rows a beat is written in */
170
+ grid: number
171
+ }
172
+
173
+ export interface Mark {
174
+ line: number
175
+ name: string
176
+ /** seconds from its section's start */
177
+ at: number
178
+ }
179
+
180
+ /** a part of the timeline in bars: its shots land on its start, and the stitch caches it as one piece */
181
+ export interface Section {
182
+ line: number
183
+ name: string
184
+ clock: Clock | null
185
+ /** where it starts on the timeline, seconds */
186
+ at: number
187
+ seconds: number
188
+ marks: Mark[]
189
+ }
190
+
191
+ export interface Trailer {
192
+ name: string
193
+ size: [number, number]
194
+ fps: number
195
+ music: Music | null
196
+ /** dB the music drops under a voice line, negative */
197
+ duck: number
198
+ /** dB the music drops under each sfx, negative; null: none */
199
+ duckSfx: number | null
200
+ fadeIn: number
201
+ shots: Shot[]
202
+ sections: Section[]
203
+ /** where the timeline of the sections starts on the cut: the first landed shot's start less its moment */
204
+ timelineAt: number | null
205
+ /** the cut is a vertical short (`vertical`), finished with the brand's lockup, watermark and end card */
206
+ vertical: Vertical | null
207
+ /** the global process whose url a `film url` route resolves against (`base <id>`); null: the repo's one */
208
+ base: string | null
209
+ }
210
+
211
+ export interface TrailerError {
212
+ line: number
213
+ message: string
214
+ }
215
+
216
+ interface Word {
217
+ text: string
218
+ quoted: boolean
219
+ }
220
+
221
+ /** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line */
222
+ export function tokens(line: string): { words: Word[] } | { error: string } {
223
+ const words: Word[] = []
224
+ let i = 0
225
+ while (i < line.length) {
226
+ const c = line[i]!
227
+ if (c === ' ' || c === '\t') {
228
+ i++
229
+ continue
230
+ }
231
+ if (c === '#') break
232
+ if (c === '"') {
233
+ const end = line.indexOf('"', i + 1)
234
+ if (end < 0) return { error: 'a quote is not closed' }
235
+ words.push({ text: line.slice(i + 1, end), quoted: true })
236
+ i = end + 1
237
+ continue
238
+ }
239
+ let j = i
240
+ while (j < line.length && line[j] !== ' ' && line[j] !== '\t' && line[j] !== '"' && line[j] !== '#') j++
241
+ words.push({ text: line.slice(i, j), quoted: false })
242
+ i = j
243
+ }
244
+ return { words }
245
+ }
246
+
247
+ class LineError extends Error {}
248
+ const bad = (message: string): never => {
249
+ throw new LineError(message)
250
+ }
251
+
252
+ /** the clock the line being read counts bars and beats in: each section's, from its line on */
253
+ let clock: Clock | null = null
254
+
255
+ /** `1.5s`, `400ms`, `m:ss(.f)`, and at a clock `2bars`, `1beat`, as seconds; terms add up with `+` (`1bar+0.5beat`) */
256
+ export function parseTime(s: string, c: Clock | null = null): number | null {
257
+ let total = 0
258
+ for (const term of s.split('+')) {
259
+ let m = /^(\d+(?:\.\d+)?)s$/.exec(term)
260
+ if (m) total += Number(m[1])
261
+ else if ((m = /^(\d+)ms$/.exec(term))) total += Number(m[1]) / 1000
262
+ else if ((m = /^(\d+):([0-5]\d(?:\.\d+)?)$/.exec(term))) total += Number(m[1]) * 60 + Number(m[2])
263
+ else if ((m = /^(\d+(?:\.\d+)?)(bars?|beats?)$/.exec(term)) && c) total += (Number(m[1]) * (m[2]!.startsWith('bar') ? c.meter : 1) * 60) / c.bpm
264
+ else return null
265
+ }
266
+ return total
267
+ }
268
+
269
+ const TIMES = '1.5s, 400ms, 0:04, 2bars, 1beat, 1bar+2beats'
270
+ const time = (what: string, w: Word | undefined): number => {
271
+ if (!w || w.quoted) return bad(`${what} wants a time (${TIMES})`)
272
+ const v = parseTime(w.text, clock)
273
+ if (v !== null) return v
274
+ 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>)`)
275
+ return bad(`${what} wants a time (${TIMES}), got ${w.text}`)
276
+ }
277
+
278
+ /** a section's or a mark's name as an `at` reads it: words of a letter and more, joined by `-` */
279
+ const MOMENT = '[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*'
280
+
281
+ /** a moment named on the timeline, and a time from it */
282
+ interface MarkAt {
283
+ mark: string
284
+ offset: number
285
+ }
286
+
287
+ /** an `at`: a time, or a section or mark with an offset (`drop`, `drop-1beat`, `title+0.5s`) */
288
+ const when = (w: Word | undefined, k: string): number | MarkAt => {
289
+ const m = w && !w.quoted ? new RegExp(`^(${MOMENT})(?:([+-])(\\d.*))?$`).exec(w.text) : null
290
+ if (!m) return time(k, w)
291
+ const off = m[3] === undefined ? 0 : (parseTime(m[3], clock) ?? bad(`${k}: ${m[3]} after ${m[1]} is no time (${TIMES})`))
292
+ return { mark: m[1]!, offset: m[2] === '-' ? -off : off }
293
+ }
294
+ const num = (what: string, w: Word | undefined, lo: number, hi: number): number => {
295
+ const n = w && !w.quoted ? Number(w.text) : Number.NaN
296
+ if (!Number.isFinite(n) || n < lo || n > hi) return bad(`${what} wants a number from ${lo} to ${hi}${w ? `, got ${w.text}` : ''}`)
297
+ return n
298
+ }
299
+ const word = (what: string, w: Word | undefined, re = /^[a-z0-9][a-z0-9:_.,=+-]*$/i): string => {
300
+ if (!w || w.quoted || !re.test(w.text)) return bad(`${what} wants a word${w ? `, got ${w.quoted ? `"${w.text}"` : w.text}` : ''}`)
301
+ return w.text
302
+ }
303
+ const quoted = (what: string, w: Word | undefined): string => {
304
+ if (!w || !w.quoted) return bad(`${what} wants a quoted text${w ? `, got ${w.text}` : ''}`)
305
+ if (w.text.trim() === '') return bad(`${what}: the text is empty`)
306
+ return w.text
307
+ }
308
+ /** a file path or an id, bare or quoted */
309
+ const pathWord = (what: string, w: Word | undefined): string => {
310
+ if (!w || w.text.trim() === '' || (!w.quoted && /\s/.test(w.text))) return bad(`${what} wants a file${w ? `, got ${w.text}` : ''}`)
311
+ return w.text
312
+ }
313
+
314
+ /** `WxH` in pixels, each side from `lo` to `hi` */
315
+ const sizeWord = (what: string, w: Word | undefined, lo: number, hi: number): [number, number] => {
316
+ const m = w && !w.quoted ? /^(\d+)x(\d+)$/.exec(w.text) : null
317
+ const [x, y] = [Number(m?.[1]), Number(m?.[2])]
318
+ 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}` : ''}`)
319
+ return [x, y]
320
+ }
321
+
322
+ /** the `key value` pairs after a line's fixed words; `flags` take no value */
323
+ function pairs(
324
+ what: string,
325
+ rest: Word[],
326
+ valued: Record<string, (w: Word | undefined, key: string) => unknown>,
327
+ flags: readonly string[] = [],
328
+ ): Record<string, unknown> {
329
+ const out: Record<string, unknown> = {}
330
+ for (let i = 0; i < rest.length; i++) {
331
+ const k = rest[i]!
332
+ if (k.quoted) bad(`${what}: a quoted text "${k.text}" where a word was expected`)
333
+ if (k.text in out) bad(`${what}: ${k.text} given twice`)
334
+ if (flags.includes(k.text)) {
335
+ out[k.text] = true
336
+ continue
337
+ }
338
+ const f = valued[k.text]
339
+ if (!f) bad(`${what}: unknown word ${k.text} (${what} takes ${[...Object.keys(valued), ...flags].join(', ') || 'nothing more'})`)
340
+ out[k.text] = f!(rest[++i], `${what} ${k.text}`)
341
+ }
342
+ return out
343
+ }
344
+
345
+ /** the film row a line's words start with: the longest row path at their head */
346
+ export function verbOf(words: readonly string[], verbs: readonly FilmVerb[]): FilmVerb | null {
347
+ let best: FilmVerb | null = null
348
+ for (const v of verbs)
349
+ if (v.path.length <= words.length && v.path.every((p, i) => words[i] === p) && v.path.length > (best?.path.length ?? 0)) best = v
350
+ return best
351
+ }
352
+
353
+ /** what a film line may start with in this repo, for its errors */
354
+ const filmWords = (verbs: readonly FilmVerb[] | null): string =>
355
+ `${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(', ')}`}`
356
+
357
+ /** 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
+ * CLI (its `clip <scene>` row), else the core kind's error */
359
+ function filmLine(ws: Word[], line: number, verbs: readonly FilmVerb[] | null): Film {
360
+ const kind = ws[0] && !ws[0].quoted ? ws[0].text : ''
361
+ const argv = ws.map((w) => w.text)
362
+ const v = verbs && verbOf(argv, verbs)
363
+ if ((CORE_FILMS as readonly string[]).includes(kind)) {
364
+ try {
365
+ return coreFilm(kind, ws, line)
366
+ } catch (e) {
367
+ if (!(e instanceof LineError) || !v) throw e
368
+ }
369
+ }
370
+ if (!v) return bad(`film: unknown kind ${kind || '(none)'} (${filmWords(verbs)})`)
371
+ 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 }
373
+ }
374
+
375
+ function coreFilm(kind: string, ws: Word[], line: number): Film {
376
+ switch (kind) {
377
+ case 'clip': {
378
+ const path = pathWord('film clip', ws[1])
379
+ if (!/\.(mp4|webm|mov|mkv)$/i.test(path)) bad(`film clip wants a video file (.mp4, .webm, .mov, .mkv), got ${path}`)
380
+ pairs(`film clip ${path}`, ws.slice(2), {})
381
+ return { kind, line, path }
382
+ }
383
+ case 'image': {
384
+ const path = pathWord('film image', ws[1])
385
+ if (!/\.(png|jpe?g|webp)$/i.test(path)) bad(`film image wants an image file (.png, .jpg, .webp), got ${path}`)
386
+ pairs(`film image ${path}`, ws.slice(2), {})
387
+ return { kind, line, path }
388
+ }
389
+ case 'card':
390
+ pairs('film card', ws.slice(1), {})
391
+ return { kind, line }
392
+ case 'run': {
393
+ const cmd = quoted('film run', ws[1])
394
+ 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 } : {}) }
397
+ }
398
+ case 'url': {
399
+ const route = pathWord('film url', ws[1])
400
+ if (!/^(\/|https?:\/\/)/.test(route)) bad(`film url wants a route of the dev url (/pricing) or an http url, got ${route}`)
401
+ const p = pairs(`film url ${route}`, ws.slice(2), { viewport: (w, k) => sizeWord(k, w, 16, 7680), wait: at }, ['still'])
402
+ return {
403
+ kind,
404
+ line,
405
+ route,
406
+ ...(p.viewport ? { viewport: p.viewport as [number, number] } : {}),
407
+ ...(p.wait !== undefined ? { wait: p.wait as number } : {}),
408
+ ...(p.still ? { still: true as const } : {}),
409
+ }
410
+ }
411
+ }
412
+ return bad(`film: unknown kind ${kind}`)
413
+ }
414
+
415
+ const volume = (w: Word | undefined, k: string) => num(k, w, 0, 4)
416
+ const at = (w: Word | undefined, k: string) => time(k, w)
417
+
418
+ /** a music cue that is silence */
419
+ export const NO_MUSIC = 'none'
420
+
421
+ /** the cut's opening cue fades in this long when it starts inside its song and names no fade-in */
422
+ export const OPENING_FADE_IN = 0.25
423
+
424
+ /** `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), {
428
+ take: (w, k) => word(k, w, /^[a-z0-9][a-z0-9-]*$/),
429
+ from: at,
430
+ 'fade-in': at,
431
+ 'fade-out': at,
432
+ volume,
433
+ })
434
+ return {
435
+ line: n,
436
+ song,
437
+ ...(p.take ? { take: p.take as string } : {}),
438
+ from: (p.from as number) ?? 0,
439
+ fadeIn: (p['fade-in'] as number | undefined) ?? (opening && ((p.from as number | undefined) ?? 0) > 0 ? OPENING_FADE_IN : 0),
440
+ fadeOut: (p['fade-out'] as number) ?? 0,
441
+ volume: (p.volume as number) ?? 1,
442
+ }
443
+ }
444
+
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
+ /**
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
460
+ */
461
+ export function scoreBlocks(text: string): { lines: string[]; errors: TrailerError[] } {
462
+ const lines = text.split('\n')
463
+ const errors: TrailerError[] = []
464
+ const indent = (l: string): number => l.length - l.trimStart().length
465
+ const off = (l: string): boolean => l.trimStart().startsWith('#')
466
+ for (let i = 0; i < lines.length; i++) {
467
+ 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') })
472
+ lines[i] = ''
473
+ const base = code ? 0 : play![1]!.length
474
+ for (let j = i + 1; j < lines.length; j++) {
475
+ 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
478
+ lines[j] = ''
479
+ i = j
480
+ if (close) break
481
+ }
482
+ }
483
+ return { lines, errors }
484
+ }
485
+
486
+ export interface ParseOptions {
487
+ /** the project CLI's film rows; null when the repo has no project CLI */
488
+ verbs?: readonly FilmVerb[] | null
489
+ }
490
+
491
+ /** the file as a trailer, and every error with its line */
492
+ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Trailer; errors: TrailerError[] } {
493
+ 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 }
495
+ const errors: TrailerError[] = []
496
+ const seen = new Set<string>()
497
+ let shot: Shot | null = null
498
+ let pending: SectionRef | null = null
499
+ let current: Section | null = null
500
+ let scoreClock: Clock | null = null
501
+ let skipping = false
502
+ /** the `at`s that name a mark, set once every shot has landed */
503
+ const refs: { line: number; shot: Shot; ref: MarkAt; set: (at: number) => void }[] = []
504
+ const landAt = (v: unknown, line: number, s: Shot, set: (at: number) => void): number => {
505
+ if (typeof v !== 'object' || v === null) return (v as number | undefined) ?? 0
506
+ refs.push({ line, shot: s, ref: v as MarkAt, set })
507
+ return 0
508
+ }
509
+ clock = null
510
+ const pre = scoreBlocks(text)
511
+ errors.push(...pre.errors)
512
+ const lines = pre.lines
513
+ for (let n = 1; n <= lines.length; n++) {
514
+ const tk = tokens(lines[n - 1]!)
515
+ if ('error' in tk) {
516
+ errors.push({ line: n, message: tk.error })
517
+ continue
518
+ }
519
+ const ws = tk.words
520
+ if (ws.length === 0) continue
521
+ const head = ws[0]!
522
+ const rest = ws.slice(1)
523
+ const once = (key: string): void => {
524
+ if (seen.has(key)) bad(`${key} given twice`)
525
+ seen.add(key)
526
+ }
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
+ try {
531
+ 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
+ if (head.text === 'section') {
547
+ const name = word('section', rest[0], /^[a-z0-9][a-z0-9-]*$/)
548
+ const last = t.sections.at(-1)
549
+ if (t.sections.some((x) => x.name === name)) bad(`section ${name} is already a section of this file`)
550
+ const p = pairs(`section ${name}`, rest.slice(2), {
551
+ tempo: (w, k) => num(k, w, 20, 400),
552
+ meter: (w, k) => num(k, w, 1, 16),
553
+ grid: (w, k) => num(k, w, 1, 32),
554
+ })
555
+ const base = last?.clock ?? scoreClock
556
+ if (!base && !p.tempo && (p.meter || p.grid)) bad(`section ${name}: meter and grid count in a tempo, give it tempo <bpm>`)
557
+ 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
+ const seconds = +time(`section ${name}`, rest[1]).toFixed(6)
559
+ if (seconds <= 0) bad(`section ${name}: its length must be above 0`)
560
+ const at = last ? +(last.at + last.seconds).toFixed(6) : 0
561
+ current = { line: n, name, clock, at, seconds, marks: [] }
562
+ t.sections.push(current)
563
+ pending = { line: n, name, at }
564
+ continue
565
+ }
566
+ if (head.text === 'mark') {
567
+ const sec = current ?? bad('a mark is a moment of a section: write it under one')
568
+ const name = word('mark', rest[0], new RegExp(`^${MOMENT}$`))
569
+ if (rest[1]?.text !== 'at' || rest.length !== 3) bad(`mark ${name} wants at <time>, from its section's start`)
570
+ 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`)
571
+ const at = time(`mark ${name}`, rest[2])
572
+ if (at >= sec.seconds) bad(`mark ${name} at ${+at.toFixed(3)} s is past section ${sec.name}'s ${+sec.seconds.toFixed(3)} s`)
573
+ sec.marks.push({ line: n, name, at })
574
+ continue
575
+ }
576
+ if (head.text === 'shot') {
577
+ // a bad shot line still opens a shot, so its own lines never read as the header's or the shot before's
578
+ 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
+ shot = open
580
+ t.shots.push(open)
581
+ 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`)
583
+ const length = time(`shot ${name}`, rest[1])
584
+ if (length <= 0) bad(`shot ${name}: its length must be above 0`)
585
+ if (rest.length > 2) bad(`shot ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a shot line is shot <name> <length>)`)
586
+ open.name = name
587
+ open.length = length
588
+ if (pending) open.section = pending
589
+ pending = null
590
+ delete open.broken
591
+ continue
592
+ }
593
+ if (shot === null) {
594
+ switch (head.text) {
595
+ case 'trailer':
596
+ once('trailer')
597
+ t.name = word('trailer', rest[0], /^[a-z0-9][a-z0-9-]*$/)
598
+ if (rest.length > 1) bad(`trailer ${t.name}: unknown word ${rest[1]?.text}`)
599
+ break
600
+ case 'size': {
601
+ once('size')
602
+ const m = rest[0] && !rest[0].quoted ? /^(\d+)x(\d+)$/.exec(rest[0].text) : null
603
+ const w = Number(m?.[1])
604
+ const h = Number(m?.[2])
605
+ if (!m || w < 160 || h < 90 || w > 3840 || h > 3840 || w % 2 || h % 2)
606
+ bad(`size wants WxH, even, from 160x90 to 3840x3840${rest[0] ? `, got ${rest[0].text}` : ''}`)
607
+ t.size = [w, h]
608
+ break
609
+ }
610
+ case 'fps':
611
+ once('fps')
612
+ t.fps = num('fps', rest[0], 1, FPS_MAX)
613
+ if (!Number.isInteger(t.fps)) bad(`fps wants a whole number, got ${rest[0]?.text}`)
614
+ 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')
619
+ break
620
+ case 'duck': {
621
+ once('duck')
622
+ const drop = (w: Word | undefined, what: string): number => {
623
+ const m = w && !w.quoted ? /^(-?\d+(?:\.\d+)?)(?:dB)?$/.exec(w.text) : null
624
+ const db = Number(m?.[1])
625
+ if (!m || db > 0 || db < -40) bad(`${what} wants a drop in dB from -40 to 0 (-10dB)${w ? `, got ${w.text}` : ''}`)
626
+ return db
627
+ }
628
+ t.duck = drop(rest[0], 'duck')
629
+ if (rest.length > 1) {
630
+ 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(' ')}`)
631
+ t.duckSfx = drop(rest[2], 'duck sfx')
632
+ }
633
+ break
634
+ }
635
+ case 'fade-in':
636
+ once('fade-in')
637
+ t.fadeIn = time('fade-in', rest[0])
638
+ break
639
+ case 'vertical': {
640
+ once('vertical')
641
+ const p = pairs('vertical', rest, {
642
+ mark: (w, k) => {
643
+ const c = word(k, w)
644
+ return c === 'left' || c === 'right' ? c : bad(`${k} wants left|right, got ${c}`)
645
+ },
646
+ outro: (w, k) => {
647
+ const o = pathWord(k, w)
648
+ if (o === 'card' || o === 'none') return o
649
+ if (!/\.(mp4|webm|mov|mkv)$/i.test(o)) bad(`${k} wants card, none or a video file, got ${o}`)
650
+ return { clip: o }
651
+ },
652
+ })
653
+ t.vertical = { line: n, outro: (p.outro as Vertical['outro'] | undefined) ?? 'card', ...(p.mark ? { mark: p.mark as 'left' | 'right' } : {}) }
654
+ break
655
+ }
656
+ case 'base':
657
+ once('base')
658
+ t.base = word('base', rest[0], /^[a-z0-9][a-z0-9-]*$/)
659
+ if (rest.length > 1) bad(`base ${t.base}: unknown word ${rest[1]?.text} (base <the id of a global process of this repo>)`)
660
+ break
661
+ default:
662
+ bad(`unknown word ${head.text} before the first shot (trailer, size, fps, music, duck, fade-in, vertical, base, section, shot)`)
663
+ }
664
+ continue
665
+ }
666
+ const s: Shot = shot
667
+ switch (head.text) {
668
+ case 'film':
669
+ 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)
671
+ break
672
+ case 'title': {
673
+ 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
+ })
687
+ const ti: Title = {
688
+ line: n,
689
+ text,
690
+ at: 0,
691
+ for: (p.for as number) ?? null,
692
+ style: (p.style as TitleStyle) ?? 'caption',
693
+ ...(p.place ? { place: p.place as 'top' | 'bottom' } : {}),
694
+ ...(p.knock !== undefined ? { knock: p.knock as number } : {}),
695
+ }
696
+ ti.at = landAt(p.at, n, s, (v) => (ti.at = v))
697
+ if (ti.knock !== undefined && (ti.knock <= ti.at || (ti.for !== null && ti.knock >= ti.at + ti.for)))
698
+ bad(`title knock ${ti.knock}s: inside the title's own span, after its at`)
699
+ s.titles.push(ti)
700
+ break
701
+ }
702
+ 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 }
706
+ x.at = landAt(p.at, n, s, (v) => (x.at = v))
707
+ s.sfx.push(x)
708
+ break
709
+ }
710
+ 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 }
715
+ v.at = landAt(p.at, n, s, (x) => (v.at = x))
716
+ s.voices.push(v)
717
+ break
718
+ }
719
+ case 'cut': {
720
+ if (s.cut.line !== s.line) bad(`shot ${s.name} already cuts on line ${s.cut.line}`)
721
+ const k = word('cut', rest[0])
722
+ 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
724
+ if (k === 'hard' && rest[1]) bad('cut hard takes no time')
725
+ if (rest.length > 2) bad(`cut ${k}: unknown word ${rest[2]?.text}`)
726
+ s.cut = { line: n, kind: k as CutKind, seconds }
727
+ break
728
+ }
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)
732
+ break
733
+ case 'starts': {
734
+ if (s.lands) bad(`shot ${s.name} already lands on line ${s.lands.line}`)
735
+ if (rest[0]?.text !== 'on' || !rest[1] || rest.length > 3) bad('starts wants on <section|mark> [+|-<time>]')
736
+ const on = word('starts on', rest[1], /^[a-z0-9][a-z0-9-]*$/)
737
+ const o2 = rest[2] && !rest[2].quoted ? /^([+-])(.+)$/.exec(rest[2].text) : null
738
+ if (rest[2] && !o2) bad(`starts on ${on}: the time from it is signed, +0.5s or -1beat, got ${rest[2].text}`)
739
+ const off = o2 ? time(`starts on ${on}`, { text: o2[2]!, quoted: false }) : 0
740
+ s.lands = { line: n, on, offset: o2?.[1] === '-' ? -off : off }
741
+ break
742
+ }
743
+ case 'from':
744
+ s.from = time('from', rest[0])
745
+ if (rest.length > 1) bad(`from: unknown word ${rest[1]?.text}`)
746
+ break
747
+ case 'push':
748
+ s.push = num('push', rest[0], 1, 3)
749
+ if (rest.length > 1) bad(`push: unknown word ${rest[1]?.text}`)
750
+ break
751
+ case 'lift':
752
+ s.lift = num('lift', rest[0], 1, 2)
753
+ if (rest.length > 1) bad(`lift: unknown word ${rest[1]?.text}`)
754
+ break
755
+ case 'frame':
756
+ if (s.frame !== null) bad(`shot ${s.name} already frames its footage`)
757
+ s.frame = num('frame', rest[0], 1, 3)
758
+ if (rest[1]?.text === 'at' && !rest[1].quoted) {
759
+ const m = /^(\d*\.?\d+),(\d*\.?\d+)$/.exec(rest[2] && !rest[2].quoted ? rest[2].text : '')
760
+ 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}` : ''}`)
761
+ else s.frameAt = [Number(m[1]), Number(m[2])]
762
+ if (rest.length > 3) bad(`frame: unknown word ${rest[3]?.text}`)
763
+ } else if (rest.length > 1) bad(`frame: unknown word ${rest[1]?.text}`)
764
+ break
765
+ case 'shake': {
766
+ const p = pairs('shake', rest, { at: when, for: at, strength: (w, k) => num(k, w, 0.2, 3) })
767
+ const x: Shake = { line: n, at: 0, for: (p.for as number) ?? 0.4, strength: (p.strength as number) ?? 1 }
768
+ x.at = landAt(p.at, n, s, (v) => (x.at = v))
769
+ s.shakes.push(x)
770
+ break
771
+ }
772
+ case 'flash': {
773
+ const p = pairs('flash', rest, { at: when, for: at })
774
+ const x: Flash = { line: n, at: 0, for: (p.for as number) ?? 0.25 }
775
+ x.at = landAt(p.at, n, s, (v) => (x.at = v))
776
+ s.flashes.push(x)
777
+ break
778
+ }
779
+ 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)`)
781
+ }
782
+ } catch (e) {
783
+ if (!(e instanceof LineError)) throw e
784
+ errors.push({ line: n, message: e.message })
785
+ if (shot && head.text === 'film') shot.broken = true
786
+ }
787
+ }
788
+ if (!seen.has('trailer')) errors.push({ line: 1, message: 'the file names its trailer first: trailer <name>' })
789
+ if (t.shots.length === 0) errors.push({ line: lines.length, message: 'no shot: a trailer has at least one shot <name> <length>' })
790
+ if (t.vertical && !seen.has('size')) t.size = [...VERTICAL_SIZE]
791
+ 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
+ clock = null
793
+ errors.push(...landShots(t))
794
+ for (const r of refs) {
795
+ const i = t.shots.indexOf(r.shot)
796
+ const m = momentOf(t, r.ref.mark)
797
+ 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
+ else r.set(+(t.timelineAt + m + r.ref.offset - (starts(t)[i] ?? 0)).toFixed(4))
799
+ }
800
+ errors.push(...structure(t))
801
+ return { trailer: t, errors: errors.sort((a, b) => a.line - b.line) }
802
+ }
803
+
804
+ /** the least a fitted shot keeps past its cut out */
805
+ const FIT_MIN_S = 0.3
806
+
807
+ /** a section's start, or a mark's moment, in the timeline's seconds; null when no section or mark has that name */
808
+ function momentOf(t: Pick<Trailer, 'sections'>, name: string): number | null {
809
+ for (const sec of t.sections) {
810
+ if (sec.name === name) return sec.at
811
+ const m = sec.marks.find((x) => x.name === name)
812
+ if (m) return sec.at + m.at
813
+ }
814
+ return null
815
+ }
816
+
817
+ /**
818
+ * every shot that lands on the timeline starts at its moment: the first one sets where the timeline starts on the cut,
819
+ * and the shot before each later one takes the length that lands it (fitted). An edit inside a section moves nothing
820
+ * past the next landed shot
821
+ */
822
+ function landShots(t: Trailer): TrailerError[] {
823
+ const out: TrailerError[] = []
824
+ t.shots.forEach((s, i) => {
825
+ if (s.broken) return
826
+ const land = s.lands ?? (s.section ? { line: s.section.line, on: s.section.name, offset: 0 } : null)
827
+ if (!land) return
828
+ const moment = momentOf(t, land.on)
829
+ if (moment === null) {
830
+ out.push({ line: land.line, message: `starts on ${land.on}: no section or mark ${land.on} in this file` })
831
+ return
832
+ }
833
+ const st = starts(t)
834
+ if (t.timelineAt === null) {
835
+ t.timelineAt = +((st[i] ?? 0) - moment - land.offset).toFixed(4)
836
+ return
837
+ }
838
+ const prev = t.shots[i - 1]
839
+ if (!prev || prev.broken) {
840
+ out.push({ line: land.line, message: `shot ${s.name}: no shot before it to fit` })
841
+ return
842
+ }
843
+ const length = prev.length + (t.timelineAt + moment + land.offset - (st[i] ?? 0))
844
+ if (length - prev.cut.seconds < FIT_MIN_S) {
845
+ 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` })
846
+ return
847
+ }
848
+ prev.written ??= prev.length
849
+ prev.length = +length.toFixed(4)
850
+ })
851
+ return out
852
+ }
853
+
854
+ /** a film that is one picture held for the shot */
855
+ 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')
857
+
858
+ /** what the lines say together: a film per shot, titles inside their shot, cuts shorter than what they join */
859
+ function structure(t: Trailer): TrailerError[] {
860
+ const out: TrailerError[] = []
861
+ t.shots.forEach((s, i) => {
862
+ if (s.broken) return
863
+ 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)` })
865
+ // a title may run on over the shots after its own (a caption over a montage), never past the cut's end
866
+ const left = total(t) - (starts(t)[i] ?? 0)
867
+ 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` })
869
+ 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
+ }
871
+ 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` })
872
+ 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` })
873
+ const next = t.shots[i + 1]
874
+ if (s.cut.seconds > 0) {
875
+ 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` })
876
+ 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` })
877
+ 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}` })
878
+ }
879
+ const prev = t.shots[i - 1]
880
+ if (prev && next && prev.cut.seconds < s.length && s.cut.seconds < s.length && prev.cut.seconds + s.cut.seconds >= s.length)
881
+ 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` })
882
+ })
883
+ const music = t.music
884
+ 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` })
885
+ return out
886
+ }
887
+
888
+ /** each shot's start on the cut: a cut overlaps the two shots it joins */
889
+ export function starts(t: Pick<Trailer, 'shots'>): number[] {
890
+ const out: number[] = []
891
+ let at = 0
892
+ for (const s of t.shots) {
893
+ out.push(at)
894
+ at += s.length - s.cut.seconds
895
+ }
896
+ return out
897
+ }
898
+
899
+ /** the cut's length: the last shot's ending is inside its own length */
900
+ export function total(t: Pick<Trailer, 'shots'>): number {
901
+ const last = t.shots.at(-1)
902
+ if (!last) return 0
903
+ return (starts(t).at(-1) ?? 0) + last.length
904
+ }
905
+
906
+ /** 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
+ * 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 }[] {
909
+ 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)
911
+ const T = total(t)
912
+ return raw
913
+ .map((r, i) => ({ ...r, end: Math.min(T, (raw[i + 1]?.at ?? T) + (raw[i + 1]?.cue.fadeIn ?? 0)) }))
914
+ .filter((r) => r.cue.song !== NO_MUSIC && r.end > r.at)
915
+ }