rvlib-shipkit 2.2.0 → 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 (107) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -1,9 +1,12 @@
1
+ import { type LineWord, type Moment, slotText, type Typed, typedFilm, type Words } from './words.ts'
2
+
1
3
  // the trailer language, its core words: one text file → the shots, their films, titles, sounds and cuts. Pure: no
2
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
3
5
  // passes in. Every error carries its line, all at once, and a file with one error never builds
4
6
 
5
- export type TitleStyle = 'logo' | 'big' | 'caption' | 'corner' | 'line'
6
- export const TITLE_STYLES: readonly TitleStyle[] = ['logo', 'big', 'caption', 'corner', 'line']
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']
7
10
  export const CUT_KINDS = [
8
11
  'hard',
9
12
  'fade',
@@ -23,30 +26,71 @@ export const CUT_KINDS = [
23
26
  'pixel',
24
27
  ] as const
25
28
  export type CutKind = (typeof CUT_KINDS)[number]
26
- export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url'] as const
29
+ export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url', 'grid', 'split'] as const
27
30
 
28
31
  /** the most frames a second a cut runs at */
29
32
  export const FPS_MAX = 60
30
33
  const DEFAULT_CUT_S = 0.5
31
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
+
32
39
  /** a film row of the project CLI: a still writes one image at `--out`, a clip a video of `--seconds` */
33
40
  export interface FilmVerb {
34
41
  path: string[]
35
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'
36
51
  description?: string
37
52
  }
38
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
+
39
63
  export type Film =
40
64
  | { kind: 'clip'; line: number; path: string }
41
65
  | { kind: 'image'; line: number; path: string }
42
66
  | { 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' }
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
+ }
47
86
  /** a page of the repo's dev url (a route) or any http url, through the house shot service; `viewport` its css
48
87
  * size, `wait` before it films; `still`: a png, else a clip of the page's largest canvas */
49
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 }
50
94
 
51
95
  /** the vertical short: a lockup over every frame, a watermark in a bottom corner, then the outro and the end card,
52
96
  * each drawn from the brand's `trailer.vertical` data (vertical.ts) */
@@ -72,6 +116,8 @@ export interface Title {
72
116
  place?: 'top' | 'bottom'
73
117
  /** knocked off the frame from this second of its shot: it tips and flies out */
74
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 }
75
121
  }
76
122
 
77
123
  export interface Sfx {
@@ -159,6 +205,8 @@ export interface Shot {
159
205
  music: Music | null
160
206
  /** its own line failed: nothing else is checked against it */
161
207
  broken?: true
208
+ /** a `footage` block: filmed, never cut in */
209
+ offcut?: true
162
210
  }
163
211
 
164
212
  /** the tempo a line counts bars and beats in */
@@ -177,7 +225,8 @@ export interface Mark {
177
225
  at: number
178
226
  }
179
227
 
180
- /** a part of the timeline in bars: its shots land on its start, and the stitch caches it as one piece */
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 */
181
230
  export interface Section {
182
231
  line: number
183
232
  name: string
@@ -186,10 +235,48 @@ export interface Section {
186
235
  at: number
187
236
  seconds: number
188
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 }
189
267
  }
190
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
+
191
276
  export interface Trailer {
192
277
  name: string
278
+ /** `footage` blocks: filmed like shots, never cut in; a grid or a split reads them */
279
+ footage: Shot[]
193
280
  size: [number, number]
194
281
  fps: number
195
282
  music: Music | null
@@ -200,7 +287,9 @@ export interface Trailer {
200
287
  fadeIn: number
201
288
  shots: Shot[]
202
289
  sections: Section[]
203
- /** where the timeline of the sections starts on the cut: the first landed shot's start less its moment */
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 */
204
293
  timelineAt: number | null
205
294
  /** the cut is a vertical short (`vertical`), finished with the brand's lockup, watermark and end card */
206
295
  vertical: Vertical | null
@@ -216,9 +305,12 @@ export interface TrailerError {
216
305
  interface Word {
217
306
  text: string
218
307
  quoted: boolean
308
+ /** an option line's word keeps its own line */
309
+ line?: number
219
310
  }
220
311
 
221
- /** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line */
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 */
222
314
  export function tokens(line: string): { words: Word[] } | { error: string } {
223
315
  const words: Word[] = []
224
316
  let i = 0
@@ -229,6 +321,11 @@ export function tokens(line: string): { words: Word[] } | { error: string } {
229
321
  continue
230
322
  }
231
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
+ }
232
329
  if (c === '"') {
233
330
  const end = line.indexOf('"', i + 1)
234
331
  if (end < 0) return { error: 'a quote is not closed' }
@@ -356,7 +453,27 @@ const filmWords = (verbs: readonly FilmVerb[] | null): string =>
356
453
 
357
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
358
455
  * CLI (its `clip <scene>` row), else the core kind's error */
359
- function filmLine(ws: Word[], line: number, verbs: readonly FilmVerb[] | null): Film {
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
360
477
  const kind = ws[0] && !ws[0].quoted ? ws[0].text : ''
361
478
  const argv = ws.map((w) => w.text)
362
479
  const v = verbs && verbOf(argv, verbs)
@@ -369,7 +486,23 @@ function filmLine(ws: Word[], line: number, verbs: readonly FilmVerb[] | null):
369
486
  }
370
487
  if (!v) return bad(`film: unknown kind ${kind || '(none)'} (${filmWords(verbs)})`)
371
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`)
372
- return { kind: 'cli', line, argv, output: v.film }
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
373
506
  }
374
507
 
375
508
  function coreFilm(kind: string, ws: Word[], line: number): Film {
@@ -392,8 +525,8 @@ function coreFilm(kind: string, ws: Word[], line: number): Film {
392
525
  case 'run': {
393
526
  const cmd = quoted('film run', ws[1])
394
527
  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 } : {}) }
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[] } : {}) }
397
530
  }
398
531
  case 'url': {
399
532
  const route = pathWord('film url', ws[1])
@@ -408,6 +541,25 @@ function coreFilm(kind: string, ws: Word[], line: number): Film {
408
541
  ...(p.still ? { still: true as const } : {}),
409
542
  }
410
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
+ }
411
563
  }
412
564
  return bad(`film: unknown kind ${kind}`)
413
565
  }
@@ -442,45 +594,64 @@ function musicLine(rest: Word[], n: number, opening = false): Music {
442
594
  }
443
595
  }
444
596
 
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
597
  /**
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
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.
460
601
  */
461
- export function scoreBlocks(text: string): { lines: string[]; errors: TrailerError[] } {
602
+ export function scoreLines(text: string): { lines: string[]; code: Score['code']; plays: (Play & { section: number })[]; errors: TrailerError[] } {
462
603
  const lines = text.split('\n')
463
604
  const errors: TrailerError[] = []
605
+ const plays: (Play & { section: number })[] = []
606
+ let code: Score['code'] = null
607
+ let section = -1
464
608
  const indent = (l: string): number => l.length - l.trimStart().length
465
609
  const off = (l: string): boolean => l.trimStart().startsWith('#')
466
610
  for (let i = 0; i < lines.length; i++) {
467
611
  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') })
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] ?? '']
472
632
  lines[i] = ''
473
- const base = code ? 0 : play![1]!.length
474
633
  for (let j = i + 1; j < lines.length; j++) {
475
634
  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
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
+ }
478
644
  lines[j] = ''
479
645
  i = j
480
- if (close) break
481
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 })
482
653
  }
483
- return { lines, errors }
654
+ return { lines, code, plays, errors }
484
655
  }
485
656
 
486
657
  export interface ParseOptions {
@@ -489,16 +660,37 @@ export interface ParseOptions {
489
660
  }
490
661
 
491
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
+
492
686
  export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Trailer; errors: TrailerError[] } {
493
687
  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 }
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 }
495
689
  const errors: TrailerError[] = []
496
690
  const seen = new Set<string>()
497
691
  let shot: Shot | null = null
498
692
  let pending: SectionRef | null = null
499
693
  let current: Section | null = null
500
- let scoreClock: Clock | null = null
501
- let skipping = false
502
694
  /** the `at`s that name a mark, set once every shot has landed */
503
695
  const refs: { line: number; shot: Shot; ref: MarkAt; set: (at: number) => void }[] = []
504
696
  const landAt = (v: unknown, line: number, s: Shot, set: (at: number) => void): number => {
@@ -507,13 +699,27 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
507
699
  return 0
508
700
  }
509
701
  clock = null
510
- const pre = scoreBlocks(text)
702
+ const pre = scoreLines(text)
511
703
  errors.push(...pre.errors)
512
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
+ }
513
717
  for (let n = 1; n <= lines.length; n++) {
514
718
  const tk = tokens(lines[n - 1]!)
515
719
  if ('error' in tk) {
516
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
517
723
  continue
518
724
  }
519
725
  const ws = tk.words
@@ -524,25 +730,8 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
524
730
  if (seen.has(key)) bad(`${key} given twice`)
525
731
  seen.add(key)
526
732
  }
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
733
  try {
531
734
  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
735
  if (head.text === 'section') {
547
736
  const name = word('section', rest[0], /^[a-z0-9][a-z0-9-]*$/)
548
737
  const last = t.sections.at(-1)
@@ -552,17 +741,44 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
552
741
  meter: (w, k) => num(k, w, 1, 16),
553
742
  grid: (w, k) => num(k, w, 1, 32),
554
743
  })
555
- const base = last?.clock ?? scoreClock
744
+ const base = last?.clock ?? t.score?.clock ?? null
556
745
  if (!base && !p.tempo && (p.meter || p.grid)) bad(`section ${name}: meter and grid count in a tempo, give it tempo <bpm>`)
557
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
558
747
  const seconds = +time(`section ${name}`, rest[1]).toFixed(6)
559
748
  if (seconds <= 0) bad(`section ${name}: its length must be above 0`)
560
749
  const at = last ? +(last.at + last.seconds).toFixed(6) : 0
561
- current = { line: n, name, clock, at, seconds, marks: [] }
750
+ current = { line: n, name, clock, at, seconds, marks: [], drums: {}, plays: [] }
562
751
  t.sections.push(current)
563
752
  pending = { line: n, name, at }
564
753
  continue
565
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
+ }
566
782
  if (head.text === 'mark') {
567
783
  const sec = current ?? bad('a mark is a moment of a section: write it under one')
568
784
  const name = word('mark', rest[0], new RegExp(`^${MOMENT}$`))
@@ -573,13 +789,28 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
573
789
  sec.marks.push({ line: n, name, at })
574
790
  continue
575
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
+ }
576
807
  if (head.text === 'shot') {
577
808
  // a bad shot line still opens a shot, so its own lines never read as the header's or the shot before's
578
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 }
579
810
  shot = open
580
811
  t.shots.push(open)
581
812
  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`)
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`)
583
814
  const length = time(`shot ${name}`, rest[1])
584
815
  if (length <= 0) bad(`shot ${name}: its length must be above 0`)
585
816
  if (rest.length > 2) bad(`shot ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a shot line is shot <name> <length>)`)
@@ -653,21 +884,81 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
653
884
  t.vertical = { line: n, outro: (p.outro as Vertical['outro'] | undefined) ?? 'card', ...(p.mark ? { mark: p.mark as 'left' | 'right' } : {}) }
654
885
  break
655
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
+ }
656
895
  case 'base':
657
896
  once('base')
658
897
  t.base = word('base', rest[0], /^[a-z0-9][a-z0-9-]*$/)
659
898
  if (rest.length > 1) bad(`base ${t.base}: unknown word ${rest[1]?.text} (base <the id of a global process of this repo>)`)
660
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
+ }
661
936
  default:
662
- bad(`unknown word ${head.text} before the first shot (trailer, size, fps, music, duck, fade-in, vertical, base, section, shot)`)
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)`)
663
938
  }
664
939
  continue
665
940
  }
666
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}`)
667
943
  switch (head.text) {
668
944
  case 'film':
669
945
  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)
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
+ }
671
962
  break
672
963
  case 'title': {
673
964
  const text = quoted('title', rest[0])
@@ -699,6 +990,25 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
699
990
  s.titles.push(ti)
700
991
  break
701
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
+ }
702
1012
  case 'sfx': {
703
1013
  const id = pathWord('sfx', rest[0])
704
1014
  const p = pairs(`sfx ${id}`, rest.slice(1), { at: when, volume })
@@ -728,6 +1038,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
728
1038
  }
729
1039
  case 'music':
730
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')
731
1042
  s.music = musicLine(rest, n)
732
1043
  break
733
1044
  case 'starts': {
@@ -777,7 +1088,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
777
1088
  break
778
1089
  }
779
1090
  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)`)
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)`)
781
1092
  }
782
1093
  } catch (e) {
783
1094
  if (!(e instanceof LineError)) throw e
@@ -790,14 +1101,39 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
790
1101
  if (t.vertical && !seen.has('size')) t.size = [...VERTICAL_SIZE]
791
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')}` })
792
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' })
793
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>' })
794
1118
  for (const r of refs) {
795
1119
  const i = t.shots.indexOf(r.shot)
796
1120
  const m = momentOf(t, r.ref.mark)
797
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` })
798
1122
  else r.set(+(t.timelineAt + m + r.ref.offset - (starts(t)[i] ?? 0)).toFixed(4))
799
1123
  }
800
- errors.push(...structure(t))
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))
801
1137
  return { trailer: t, errors: errors.sort((a, b) => a.line - b.line) }
802
1138
  }
803
1139
 
@@ -862,10 +1198,12 @@ function structure(t: Trailer): TrailerError[] {
862
1198
  if (s.broken) return
863
1199
  if (!s.film) out.push({ line: s.line, message: `shot ${s.name} films nothing: give it a film line` })
864
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` })
865
1202
  // a title may run on over the shots after its own (a caption over a montage), never past the cut's end
866
1203
  const left = total(t) - (starts(t)[i] ?? 0)
867
1204
  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` })
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` })
869
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` })
870
1208
  }
871
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` })
@@ -885,6 +1223,38 @@ function structure(t: Trailer): TrailerError[] {
885
1223
  return out
886
1224
  }
887
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
+
888
1258
  /** each shot's start on the cut: a cut overlaps the two shots it joins */
889
1259
  export function starts(t: Pick<Trailer, 'shots'>): number[] {
890
1260
  const out: number[] = []
@@ -905,9 +1275,14 @@ export function total(t: Pick<Trailer, 'shots'>): number {
905
1275
 
906
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
907
1277
  * 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 }[] {
1278
+ export function musicCues(t: Pick<Trailer, 'music' | 'shots' | 'score' | 'timelineAt'>): { cue: Music; at: number; end: number }[] {
909
1279
  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)
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)
911
1286
  const T = total(t)
912
1287
  return raw
913
1288
  .map((r, i) => ({ ...r, end: Math.min(T, (raw[i + 1]?.at ?? T) + (raw[i + 1]?.cue.fadeIn ?? 0)) }))