rvlib-shipkit 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. package/src/wiki/graph-pages.ts +128 -0
@@ -1,3 +1,4 @@
1
+ import { MOVES, type Move, type MoveKind } from './motion.ts'
1
2
  import { type LineWord, type Moment, slotText, type Typed, typedFilm, type Words } from './words.ts'
2
3
 
3
4
  // the trailer language, its core words: one text file → the shots, their films, titles, sounds and cuts. Pure: no
@@ -7,26 +8,57 @@ import { type LineWord, type Moment, slotText, type Typed, typedFilm, type Words
7
8
  export type TitleStyle = 'logo' | 'big' | 'caption' | 'corner' | 'line' | 'short' | 'progress'
8
9
  /** the styles a title line names; `progress` is its own line's */
9
10
  export const TITLE_STYLES: readonly TitleStyle[] = ['logo', 'big', 'caption', 'corner', 'line', 'short']
10
- export const CUT_KINDS = [
11
- 'hard',
12
- 'fade',
13
- 'dip-black',
14
- 'dip-white',
15
- 'slide-left',
16
- 'slide-right',
17
- 'slide-up',
18
- 'slide-down',
19
- 'whip-left',
20
- 'whip-right',
21
- 'wipe-left',
22
- 'wipe-right',
23
- 'wipe-up',
24
- 'wipe-down',
25
- 'circle',
26
- 'pixel',
27
- ] as const
28
- export type CutKind = (typeof CUT_KINDS)[number]
29
- export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url', 'grid', 'split'] as const
11
+ /** the ways a cut moves, as its words spell them: `slide-left` moves the picture left */
12
+ const DIRS = ['left', 'right', 'up', 'down'] as const
13
+ /** every cut, its word and what draws it: an ffmpeg xfade transition by name, or `drawn` by the core (graph.ts) */
14
+ export const CUTS = {
15
+ hard: null,
16
+ fade: 'fade',
17
+ 'fade-fast': 'fadefast',
18
+ 'fade-slow': 'fadeslow',
19
+ dissolve: 'dissolve',
20
+ grays: 'fadegrays',
21
+ blur: 'hblur',
22
+ 'dip-black': 'fadeblack',
23
+ 'dip-white': 'fadewhite',
24
+ ...Object.fromEntries(DIRS.flatMap((d) => [
25
+ [`slide-${d}`, `slide${d}`],
26
+ [`cover-${d}`, `cover${d}`],
27
+ [`reveal-${d}`, `reveal${d}`],
28
+ [`wipe-${d}`, `wipe${d}`],
29
+ [`smooth-${d}`, `smooth${d}`],
30
+ [`slice-${d}`, { left: 'hlslice', right: 'hrslice', up: 'vuslice', down: 'vdslice' }[d]],
31
+ [`wind-${d}`, { left: 'hlwind', right: 'hrwind', up: 'vuwind', down: 'vdwind' }[d]],
32
+ [`whip-${d}`, 'drawn'],
33
+ ])),
34
+ 'squeeze-h': 'squeezeh',
35
+ 'squeeze-v': 'squeezev',
36
+ zoom: 'zoomin',
37
+ ...Object.fromEntries(['tl', 'tr', 'bl', 'br'].flatMap((c) => [
38
+ [`wipe-${c}`, `wipe${c}`],
39
+ [`diag-${c}`, `diag${c}`],
40
+ ])),
41
+ radial: 'radial',
42
+ 'open-h': 'horzopen',
43
+ 'close-h': 'horzclose',
44
+ 'open-v': 'vertopen',
45
+ 'close-v': 'vertclose',
46
+ circle: 'circleopen',
47
+ 'circle-close': 'circleclose',
48
+ 'circle-crop': 'circlecrop',
49
+ 'rect-crop': 'rectcrop',
50
+ pixel: 'pixelize',
51
+ glitch: 'drawn',
52
+ leak: 'drawn',
53
+ } as Readonly<Record<CutKind, string | null>>
54
+ export const CUT_KINDS = Object.keys(CUTS) as readonly CutKind[]
55
+ export type CutKind = 'hard' | 'fade' | 'fade-fast' | 'fade-slow' | 'dissolve' | 'grays' | 'blur' | 'dip-black' | 'dip-white'
56
+ | `${'slide' | 'cover' | 'reveal' | 'wipe' | 'smooth' | 'slice' | 'wind' | 'whip'}-${(typeof DIRS)[number]}`
57
+ | 'squeeze-h' | 'squeeze-v' | 'zoom' | `${'wipe' | 'diag'}-${'tl' | 'tr' | 'bl' | 'br'}` | 'radial'
58
+ | 'open-h' | 'close-h' | 'open-v' | 'close-v' | 'circle' | 'circle-close' | 'circle-crop' | 'rect-crop' | 'pixel' | 'glitch' | 'leak'
59
+ /** a drawn cut's length when its line names none: a whip snaps, a leak burns slowly */
60
+ export const CUT_SECONDS: Partial<Record<CutKind, number>> = { 'whip-left': 0.35, 'whip-right': 0.35, 'whip-up': 0.35, 'whip-down': 0.35, glitch: 0.3, leak: 0.8 }
61
+ export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url', 'page', 'grid', 'split'] as const
30
62
 
31
63
  /** the most frames a second a cut runs at */
32
64
  export const FPS_MAX = 60
@@ -86,6 +118,9 @@ export type Film =
86
118
  /** a page of the repo's dev url (a route) or any http url, through the house shot service; `viewport` its css
87
119
  * size, `wait` before it films; `still`: a png, else a clip of the page's largest canvas */
88
120
  | { kind: 'url'; line: number; route: string; viewport?: [number, number]; wait?: number; still?: true }
121
+ /** a page filmed frame by frame through the house shot service: `window[step](t)` before each frame, then its ready
122
+ * mark; `query` its words (`key value`, cues made seconds of the take) as the url's query, in the order written */
123
+ | { kind: 'page'; line: number; route: string; query: string[]; flags?: string[]; step: string; viewport?: [number, number]; wait?: number; still?: true; at?: number }
89
124
  /** other shots' or footage blocks' takes tiled, each cell playing live; `pop`: cell i pops in at i times it; `grow`:
90
125
  * nine cells growing out of the first, settled on the 2 by 2 block then the whole at these seconds */
91
126
  | { kind: 'grid'; line: number; cells: string[]; pop: number; grow?: [number, number] }
@@ -118,12 +153,59 @@ export interface Title {
118
153
  knock?: number
119
154
  /** a `progress` line's step: the i-th of n notches filled under its name */
120
155
  step?: { i: number; n: number }
156
+ /** the brand style it wears (`trailer.styles`); its `style` is the built in one under it */
157
+ named?: string
158
+ /** its look over its style's own */
159
+ fx?: TitleFx
160
+ /** how it comes in and goes out, over its style's own */
161
+ in?: Move
162
+ out?: Move
163
+ }
164
+
165
+ /** where a title stands: as `place` says; a short's caption in the frame's middle, a progress bar at the top; else
166
+ * the bottom */
167
+ export type TitlePlace = 'top' | 'middle' | 'bottom'
168
+ export const placeOf = (ti: Pick<Title, 'place' | 'style'>): TitlePlace => ti.place ?? (ti.style === 'short' ? 'middle' : ti.style === 'progress' ? 'top' : 'bottom')
169
+
170
+ /** a colour as a line writes it: `#rgb`, `#rrggbb`, or one of the look's (titles.ts resolves it) */
171
+ export const LOOK_COLOURS = ['light', 'edge', 'accent'] as const
172
+
173
+ /** a title's look over its style's: its face (a font file or a Google Fonts family), weight, size in pixels of a frame
174
+ * 1080 tall, fill, and its outline, shadow and glow, each a size in those pixels and a colour */
175
+ export interface TitleFx {
176
+ font?: string
177
+ weight?: number
178
+ size?: number
179
+ color?: string
180
+ gradient?: string[]
181
+ outline?: { px: number; color?: string }
182
+ shadow?: { px: number; color?: string }
183
+ glow?: { px: number; color?: string }
184
+ }
185
+
186
+ /** how a made sound is made (design call 9): its seed, the voice or engine it is made `by`, its length (`for`), an sfx
187
+ * trimmed to its first `hit` */
188
+ export interface SoundGen {
189
+ seed?: number
190
+ by?: string
191
+ for?: number
192
+ hit?: true
193
+ }
194
+
195
+ /** a sound a command writes at `{out}` (marks `{out}`, `{seconds}`), and the files it reads, keyed by their content */
196
+ export interface SoundRun {
197
+ cmd: string
198
+ reads?: string[]
121
199
  }
122
200
 
123
201
  export interface Sfx {
124
202
  line: number
125
- /** a file, or an id the repo's sound resolver knows */
203
+ /** a file, an id the repo's sound resolver knows, or a prompt (`prompt`); `run` for a run's sound */
126
204
  id: string
205
+ /** the id is a quoted prompt the house makes from */
206
+ prompt?: true
207
+ gen?: SoundGen
208
+ run?: SoundRun
127
209
  at: number
128
210
  volume: number
129
211
  }
@@ -133,6 +215,8 @@ export interface Voice {
133
215
  /** a line the repo's voice resolver finds by its text, or a recorded file */
134
216
  text?: string
135
217
  file?: string
218
+ gen?: SoundGen
219
+ run?: SoundRun
136
220
  at: number
137
221
  volume: number
138
222
  }
@@ -165,8 +249,13 @@ export interface SectionRef {
165
249
 
166
250
  export interface Music {
167
251
  line: number
168
- /** a file, `none`, or an id the repo's sound resolver knows */
252
+ /** a file, `none`, an id the repo's sound resolver knows, or a prompt (`prompt`); `run` for a run's music */
169
253
  song: string
254
+ prompt?: true
255
+ gen?: SoundGen
256
+ run?: SoundRun
257
+ /** it plays only in this music variant (`sk trailer --variant <name>`) */
258
+ variant?: string
170
259
  take?: string
171
260
  from: number
172
261
  fadeIn: number
@@ -295,6 +384,9 @@ export interface Trailer {
295
384
  vertical: Vertical | null
296
385
  /** the global process whose url a `film url` route resolves against (`base <id>`); null: the repo's one */
297
386
  base: string | null
387
+ /** the music variants the file names, and the one it was read as (null: it names none) */
388
+ variants: string[]
389
+ variant: string | null
298
390
  }
299
391
 
300
392
  export interface TrailerError {
@@ -311,6 +403,9 @@ interface Word {
311
403
 
312
404
  /** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line. Only double quotes quote, as in
313
405
  * the full language, so a word opening on a single quote is refused: it would reach a command with the quotes inside */
406
+ /** a hex colour at `k` (`#rgb`, `#rrggbb`, then the word's end or a comma): a word, never a comment */
407
+ const colourAt = (line: string, k: number): boolean => /^#([0-9a-f]{3}|[0-9a-f]{6})(?=[\s,"]|$)/i.test(line.slice(k))
408
+
314
409
  export function tokens(line: string): { words: Word[] } | { error: string } {
315
410
  const words: Word[] = []
316
411
  let i = 0
@@ -320,7 +415,7 @@ export function tokens(line: string): { words: Word[] } | { error: string } {
320
415
  i++
321
416
  continue
322
417
  }
323
- if (c === '#') break
418
+ if (c === '#' && !colourAt(line, i)) break
324
419
  if (c === "'") {
325
420
  const end = line.indexOf("'", i + 1)
326
421
  const word = end < 0 ? line.slice(i).split(/[\s#]/)[0]! : line.slice(i, end + 1)
@@ -334,7 +429,7 @@ export function tokens(line: string): { words: Word[] } | { error: string } {
334
429
  continue
335
430
  }
336
431
  let j = i
337
- while (j < line.length && line[j] !== ' ' && line[j] !== '\t' && line[j] !== '"' && line[j] !== '#') j++
432
+ while (j < line.length && line[j] !== ' ' && line[j] !== '\t' && line[j] !== '"' && (line[j] !== '#' || (j === i || line[j - 1] === ',') && colourAt(line, j))) j++
338
433
  words.push({ text: line.slice(i, j), quoted: false })
339
434
  i = j
340
435
  }
@@ -416,6 +511,131 @@ const sizeWord = (what: string, w: Word | undefined, lo: number, hi: number): [n
416
511
  return [x, y]
417
512
  }
418
513
 
514
+ /** a colour word: `#rgb`, `#rrggbb`, or the look's light, edge or accent */
515
+ const colour = (k: string, text: string | undefined): string => {
516
+ if (!text || !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|light|edge|accent)$/i.test(text)) return bad(`${k} wants a colour: #rgb, #rrggbb, ${LOOK_COLOURS.join(', ')}${text ? `, got ${text}` : ''}`)
517
+ return text
518
+ }
519
+ /** `<px>[,<colour>]`: an outline, a shadow or a glow, its size in pixels of a frame 1080 tall */
520
+ const sized = (w: Word | undefined, k: string): { px: number; color?: string } => {
521
+ if (!w || w.quoted) return bad(`${k} wants <px>[,<colour>] (10, or 10,#2a1020)`)
522
+ const [a, c, more] = w.text.split(',')
523
+ const px = Number(a)
524
+ if (!Number.isFinite(px) || px < 0 || px > 300 || more !== undefined) return bad(`${k} wants <px>[,<colour>], its size 0 to 300, got ${w.text}`)
525
+ return { px, ...(c !== undefined ? { color: colour(k, c) } : {}) }
526
+ }
527
+ /** a title's look words, in TitleFx */
528
+ const FX_WORDS = ['font', 'weight', 'size', 'color', 'gradient', 'outline', 'shadow', 'glow'] as const
529
+ const TITLE_WORDS: Record<string, (w: Word | undefined, k: string) => unknown> = {
530
+ style: (w, k) => word(k, w, /^[a-z][a-z0-9-]*$/),
531
+ at: when,
532
+ for: (w, k) => time(k, w),
533
+ place: (w, k) => {
534
+ const pl = word(k, w)
535
+ return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
536
+ },
537
+ knock: (w, k) => time(k, w),
538
+ font: (w, k) => pathWord(k, w),
539
+ weight: (w, k) => {
540
+ const v = num(k, w, 100, 1000)
541
+ return Number.isInteger(v) ? v : bad(`${k} wants a whole number from 100 to 1000, got ${w?.text}`)
542
+ },
543
+ size: (w, k) => num(k, w, 6, 600),
544
+ color: (w, k) => colour(k, w && !w.quoted ? w.text : undefined),
545
+ gradient: (w, k) => {
546
+ const cs = w && !w.quoted ? w.text.split(',') : []
547
+ if (cs.length < 2 || cs.length > 4) bad(`${k} wants two to four colours, top to bottom: light,accent`)
548
+ return cs.map((c) => colour(k, c))
549
+ },
550
+ outline: sized,
551
+ shadow: sized,
552
+ glow: sized,
553
+ }
554
+
555
+ /** a title's words, in order, each `+<preset>` in place; `in` and `out` take a move and, when the next word is a
556
+ * time, its length. A word the line itself writes twice is an error; a preset's word gives way to a later one */
557
+ function readTitleWords(raw: Word[], presets: ReadonlyMap<string, Word[]>, what = 'title'): Record<string, unknown> {
558
+ type Own = Word & { own?: true }
559
+ const words: Own[] = raw.flatMap((w): Own[] => {
560
+ if (w.quoted || !/^\+[a-z][a-z0-9-]*$/.test(w.text)) return [{ ...w, own: true }]
561
+ return presets.get(w.text.slice(1)) ?? bad(`${what}: no preset ${w.text.slice(1)} (preset <name> <words> in the header${presets.size ? `: ${[...presets.keys()].join(', ')}` : ''})`)
562
+ })
563
+ const values: Record<string, unknown> = {}
564
+ const written = new Set<string>()
565
+ for (let i = 0; i < words.length; i++) {
566
+ const w = words[i]!
567
+ if (w.quoted) bad(`${what}: a quoted text "${w.text}" where a word was expected`)
568
+ if (w.own) {
569
+ if (written.has(w.text)) bad(`${what}: ${w.text} given twice`)
570
+ written.add(w.text)
571
+ }
572
+ if (w.text === 'in' || w.text === 'out') {
573
+ const kind = word(`${what} ${w.text}`, words[i + 1])
574
+ if (!(MOVES as readonly string[]).includes(kind)) bad(`${what} ${w.text} wants ${MOVES.join('|')}, got ${kind}`)
575
+ i++
576
+ const next = words[i + 1]
577
+ const seconds = next && !next.quoted && /^\d/.test(next.text) ? time(`${what} ${w.text} ${kind}`, next) : undefined
578
+ if (seconds !== undefined) i++
579
+ values[w.text] = { kind: kind as MoveKind, ...(seconds !== undefined ? { seconds } : {}) }
580
+ continue
581
+ }
582
+ const f = TITLE_WORDS[w.text]
583
+ if (!f) bad(`${what}: unknown word ${w.text} (title takes ${[...Object.keys(TITLE_WORDS), 'in', 'out', '+<preset>'].join(', ')})`)
584
+ values[w.text] = f!(words[++i], `${what} ${w.text}`)
585
+ }
586
+ return values
587
+ }
588
+
589
+ /** a title's look words out of read values */
590
+ const fxOf = (v: Record<string, unknown>): TitleFx => Object.fromEntries(FX_WORDS.filter((k) => v[k] !== undefined).map((k) => [k, v[k]])) as TitleFx
591
+ const builtInStyle = (st: string): st is TitleStyle => (TITLE_STYLES as readonly string[]).includes(st)
592
+
593
+ /** the brand's text styles (`trailer.styles`, each a title's words by name) laid under each title: the defaults of
594
+ * its built in style, then the brand style it names, then its own words, a later one winning. A brand style names
595
+ * the built in style under it (default caption) and holds look words, `place`, `in` and `out` only. Run again, it
596
+ * gives the same titles. Every error at the title's line */
597
+ export function resolveStyles(t: Pick<Trailer, 'shots'>, styles: Readonly<Record<string, string>>): TrailerError[] {
598
+ const errors: TrailerError[] = []
599
+ const read = (name: string): Record<string, unknown> => {
600
+ const tk = tokens(styles[name]!)
601
+ if ('error' in tk) return bad(`the brand's style ${name}: ${tk.error}`)
602
+ const v = readTitleWords(tk.words, new Map(), `the brand's style ${name}`)
603
+ const odd = Object.keys(v).filter((k) => !['style', 'place', 'in', 'out', ...FX_WORDS].includes(k))
604
+ if (odd.length > 0) bad(`the brand's style ${name} holds look words, place, in and out, never ${odd.join(', ')}`)
605
+ return v
606
+ }
607
+ for (const s of t.shots)
608
+ for (const ti of s.titles) {
609
+ if (ti.style === 'progress') continue
610
+ try {
611
+ let base: string = ti.style
612
+ let named: Record<string, unknown> = {}
613
+ if (ti.named) {
614
+ if (styles[ti.named] === undefined) bad(`title style wants ${[...TITLE_STYLES, ...Object.keys(styles).filter((k) => !builtInStyle(k))].join('|')}, got ${ti.named}`)
615
+ named = read(ti.named)
616
+ base = (named.style as string | undefined) ?? 'caption'
617
+ if (!builtInStyle(base)) bad(`the brand's style ${ti.named} is over ${base}, which is no built in style (${TITLE_STYLES.join('|')})`)
618
+ }
619
+ const defaults = styles[base] !== undefined ? read(base) : {}
620
+ if (defaults.style !== undefined) bad(`the brand's style ${base} gives the defaults of the built in ${base}: it names no style of its own`)
621
+ const under = { ...defaults, ...named }
622
+ ti.style = base as TitleStyle
623
+ const fx = { ...fxOf(under), ...ti.fx }
624
+ if (Object.keys(fx).length > 0) ti.fx = fx
625
+ if (under.place && !ti.place) ti.place = under.place as 'top' | 'bottom'
626
+ if (under.in && !ti.in) ti.in = under.in as Move
627
+ if (under.out && !ti.out && ti.knock === undefined) ti.out = under.out as Move
628
+ } catch (e) {
629
+ if (!(e instanceof LineError)) throw e
630
+ errors.push({ line: ti.line, message: e.message })
631
+ }
632
+ }
633
+ return errors
634
+ }
635
+
636
+ /** the brand styles a cut names: a title's style word that is no built in style */
637
+ export const namedStyles = (t: Pick<Trailer, 'shots'>): string[] => [...new Set(t.shots.flatMap((s) => s.titles.flatMap((ti) => (ti.named ? [ti.named] : []))))]
638
+
419
639
  /** the `key value` pairs after a line's fixed words; `flags` take no value */
420
640
  function pairs(
421
641
  what: string,
@@ -459,7 +679,9 @@ interface FilmContext {
459
679
  presets: ReadonlyMap<string, Word[]>
460
680
  time: (text: string) => Moment | null
461
681
  /** 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
682
+ later: (f: { argv: string[] }, slot: Typed['times'][number]) => void
683
+ /** the words a `film page` takes, when the repo declares them (its trailer unit's `page`) */
684
+ pageWords?: Words
463
685
  errors: TrailerError[]
464
686
  }
465
687
 
@@ -472,9 +694,58 @@ function expandPresets(ws: Word[], line: number, presets: ReadonlyMap<string, Wo
472
694
  })
473
695
  }
474
696
 
697
+ /** `film page <route> [viewport WxH] [wait t] [step <hook>] [still] <words>`: the words a query, typed when the repo
698
+ * declares them, else `key value` (a value of cues, `name@<time>` words, made seconds of the take) */
699
+ function pageFilm(ws: Word[], line: number, c: FilmContext): Extract<Film, { kind: 'page' }> {
700
+ const route = pathWord('film page', ws[1])
701
+ if (!/^(\/|https?:\/\/)/.test(route)) bad(`film page wants a route of the dev url (/look) or an http url, got ${route}`)
702
+ const own: Record<string, unknown> = {}
703
+ const rest: Word[] = []
704
+ const RESERVED: Record<string, (w: Word | undefined, k: string) => unknown> = { viewport: (w, k) => sizeWord(k, w, 16, 7680), wait: at, step: (w, k) => word(k, w, /^[A-Za-z_$][A-Za-z0-9_$]*$/) }
705
+ for (let i = 2; i < ws.length; i++) {
706
+ const w = ws[i]!
707
+ if (!w.quoted && w.text === 'still') own.still = true
708
+ else if (!w.quoted && RESERVED[w.text]) {
709
+ if (w.text in own) bad(`film page: ${w.text} given twice`)
710
+ own[w.text] = RESERVED[w.text]!(ws[++i], `film page ${w.text}`)
711
+ } else rest.push(w)
712
+ }
713
+ // a page's words are its query, key=value: a positional has no key to write
714
+ for (const [k, w] of Object.entries(c.pageWords ?? {})) if (w.arg !== undefined) bad(`film page: ${k} declares arg ${w.arg}, and a page word has a key (it is the url's query): drop its arg`)
715
+ // undeclared words: a key, then one value, or cue words while they come
716
+ const words: Words = c.pageWords ?? {}
717
+ if (!c.pageWords)
718
+ for (let i = 0; i < rest.length; i++) {
719
+ const k = rest[i]!
720
+ if (k.quoted || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(k.text)) bad(`film page: ${k.quoted ? `"${k.text}"` : k.text} is no word of the query: write key value`)
721
+ const cue = (w: Word | undefined): boolean => !!w && !w.quoted && /^[^@\s]+@\S+$/.test(w.text)
722
+ words[k.text] = { type: cue(rest[i + 1]) ? 'cues' : 'text' }
723
+ if (cue(rest[i + 1])) while (cue(rest[i + 1])) i++
724
+ else if (!rest[++i]) bad(`film page ${k.text} wants a value`)
725
+ }
726
+ const at2 = (w: Word): LineWord => ({ text: w.text, quoted: w.quoted, line: w.line ?? line })
727
+ const typed = typedFilm(['page'], words, rest.map(at2), c.pageWords ? c.defaults.map(at2) : [], c.time)
728
+ c.errors.push(...typed.errors)
729
+ const f: Extract<Film, { kind: 'page' }> = {
730
+ kind: 'page',
731
+ line,
732
+ route,
733
+ query: typed.argv.slice(1),
734
+ ...(Object.entries(words).some(([, s]) => s.type === 'flag') ? { flags: Object.entries(words).filter(([, s]) => s.type === 'flag').map(([k]) => k) } : {}),
735
+ step: (own.step as string | undefined) ?? '__trailerStep',
736
+ ...(own.viewport ? { viewport: own.viewport as [number, number] } : {}),
737
+ ...(own.wait !== undefined ? { wait: own.wait as number } : {}),
738
+ ...(own.still ? { still: true as const } : {}),
739
+ }
740
+ // the query's slots sit one word to the left of the typed argv's (its `page` path word dropped)
741
+ for (const slot of typed.times) c.later({ argv: f.query }, { ...slot, index: slot.index - 1 })
742
+ return f
743
+ }
744
+
475
745
  function filmLine(raw: Word[], line: number, verbs: readonly FilmVerb[] | null, c?: FilmContext): Film {
476
746
  const ws = c ? expandPresets(raw, line, c.presets) : raw
477
747
  const kind = ws[0] && !ws[0].quoted ? ws[0].text : ''
748
+ if (kind === 'page' && c) return pageFilm(ws, line, c)
478
749
  const argv = ws.map((w) => w.text)
479
750
  const v = verbs && verbOf(argv, verbs)
480
751
  if ((CORE_FILMS as readonly string[]).includes(kind)) {
@@ -574,18 +845,55 @@ export const NO_MUSIC = 'none'
574
845
  export const OPENING_FADE_IN = 0.25
575
846
 
576
847
  /** `music <file|id|none> [take k] [from t] [fade-in t] [fade-out t] [volume v]`; `opening`: the cut's own */
577
- function musicLine(rest: Word[], n: number, opening = false): Music {
578
- const song = pathWord('music', rest[0])
579
- const p = pairs(`music ${song}`, rest.slice(1), {
848
+ /** `<kind> run "<cmd {out}>" [reads <file>,…]`: the run form of a sound line, and the words after it; null when the
849
+ * line is no run */
850
+ function soundRun(kind: string, rest: Word[]): { run: SoundRun; rest: Word[] } | null {
851
+ if (!(rest[0] && !rest[0].quoted && rest[0].text === 'run' && rest[1]?.quoted)) return null
852
+ const cmd = quoted(`${kind} run`, rest[1])
853
+ if (!cmd.includes('{out}')) bad(`${kind} run wants {out} in its command: the file the command writes the sound to`)
854
+ let more = rest.slice(2)
855
+ let reads: string[] | undefined
856
+ if (more[0]?.text === 'reads' && !more[0].quoted) {
857
+ reads = pathWord(`${kind} run reads`, more[1]).split(',')
858
+ more = more.slice(2)
859
+ }
860
+ return { run: { cmd, ...(reads ? { reads } : {}) }, rest: more }
861
+ }
862
+
863
+ /** the words a made sound takes, read off a line's pairs */
864
+ const genOf = (p: Record<string, unknown>): { gen?: SoundGen } => {
865
+ const g: SoundGen = { ...(p.seed !== undefined ? { seed: p.seed as number } : {}), ...(p.by ? { by: p.by as string } : {}), ...(p.for !== undefined ? { for: p.for as number } : {}), ...(p.hit ? { hit: true as const } : {}) }
866
+ return Object.keys(g).length ? { gen: g } : {}
867
+ }
868
+ const seedWord = (w: Word | undefined, k: string): number => {
869
+ const v = num(k, w, 0, 2 ** 31 - 1)
870
+ return Number.isInteger(v) ? v : bad(`${k} wants a whole number, got ${w?.text}`)
871
+ }
872
+ const byWord = (w: Word | undefined, k: string): string => word(k, w, /^[a-zA-Z0-9][a-zA-Z0-9:_./-]*$/)
873
+
874
+ function musicLine(raw: Word[], n: number, opening = false): Music {
875
+ const r = soundRun('music', raw)
876
+ const rest = r ? [{ text: 'run', quoted: false }, ...r.rest] : raw
877
+ const prompt = !r && rest[0]?.quoted === true
878
+ const song = r ? 'run' : prompt ? quoted('music', rest[0]) : pathWord('music', rest[0])
879
+ const p = pairs(`music ${r ? 'run' : song}`, rest.slice(1), {
580
880
  take: (w, k) => word(k, w, /^[a-z0-9][a-z0-9-]*$/),
581
881
  from: at,
582
882
  'fade-in': at,
583
883
  'fade-out': at,
584
884
  volume,
885
+ by: byWord,
886
+ for: at,
887
+ seed: seedWord,
888
+ variant: (w, k) => word(k, w, /^[a-z0-9][a-z0-9-]*$/),
585
889
  })
586
890
  return {
587
891
  line: n,
588
892
  song,
893
+ ...(prompt ? { prompt: true as const } : {}),
894
+ ...(r ? { run: r.run } : {}),
895
+ ...genOf(p),
896
+ ...(p.variant ? { variant: p.variant as string } : {}),
589
897
  ...(p.take ? { take: p.take as string } : {}),
590
898
  from: (p.from as number) ?? 0,
591
899
  fadeIn: (p['fade-in'] as number | undefined) ?? (opening && ((p.from as number | undefined) ?? 0) > 0 ? OPENING_FADE_IN : 0),
@@ -657,6 +965,20 @@ export function scoreLines(text: string): { lines: string[]; code: Score['code']
657
965
  export interface ParseOptions {
658
966
  /** the project CLI's film rows; null when the repo has no project CLI */
659
967
  verbs?: readonly FilmVerb[] | null
968
+ /** the words a `film page` takes, when the repo's trailer unit declares them */
969
+ pageWords?: Words
970
+ /** the music variant the cut is read as; default the first the file names */
971
+ variant?: string
972
+ }
973
+
974
+ /** the music variants a file names, in order: its music lines' `variant <name>` words */
975
+ export function variantsOf(text: string): string[] {
976
+ const out: string[] = []
977
+ for (const l of text.split('\n')) {
978
+ const m = /^\s*music\s.*\svariant\s+([a-z0-9][a-z0-9-]*)\b/.exec(l.replace(/"[^"]*"/g, '""'))
979
+ if (m && !out.includes(m[1]!)) out.push(m[1]!)
980
+ }
981
+ return out
660
982
  }
661
983
 
662
984
  /** the file as a trailer, and every error with its line */
@@ -685,8 +1007,20 @@ export function filmOptionLines(lines: string[]): Map<number, { line: number; te
685
1007
 
686
1008
  export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Trailer; errors: TrailerError[] } {
687
1009
  const verbs = o.verbs ?? null
688
- const t: Trailer = { name: '', footage: [], size: [1920, 1080], fps: 30, music: null, duck: -10, duckSfx: null, fadeIn: 0, shots: [], sections: [], score: null, timelineAt: null, vertical: null, base: null }
1010
+ const variants = variantsOf(text)
1011
+ const variant = o.variant ?? variants[0] ?? null
1012
+ const t: Trailer = { name: '', footage: [], size: [1920, 1080], fps: 30, music: null, duck: -10, duckSfx: null, fadeIn: 0, shots: [], sections: [], score: null, timelineAt: null, vertical: null, base: null, variants, variant }
1013
+ /** a music line of another variant plays elsewhere; one of this variant takes the place of the line with none */
1014
+ const mine = (m: Music, had: Music | null, what: string): boolean => {
1015
+ if (m.variant !== undefined && m.variant !== variant) return false
1016
+ // the variant's line came first: the plain one under it stands aside, whatever their order
1017
+ if (had && had.variant !== undefined && m.variant === undefined) return false
1018
+ if (had && (had.variant !== undefined || m.variant === undefined)) bad(`${what} already ${had.variant ? `plays variant ${had.variant}` : 'plays its music'} on line ${had.line}`)
1019
+ return true
1020
+ }
689
1021
  const errors: TrailerError[] = []
1022
+ /** a section's defaults that some film of it cannot read, by the defaults line */
1023
+ const unread: { line: number; section: string; films: string[] }[] = []
690
1024
  const seen = new Set<string>()
691
1025
  let shot: Shot | null = null
692
1026
  let pending: SectionRef | null = null
@@ -705,7 +1039,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
705
1039
  const options = filmOptionLines(lines)
706
1040
  const presets = new Map<string, Word[]>()
707
1041
  /** 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] }[] = []
1042
+ const slots: { shot: Shot; film: { argv: string[] }; slot: Typed['times'][number] }[] = []
709
1043
  /** a time as a declared word reads it, at the clock of its line; null when it is none */
710
1044
  const momentWord = (w: string): Moment | null => {
711
1045
  const m = new RegExp(`^(${MOMENT})(?:([+-])(\\d.*))?$`).exec(w)
@@ -756,7 +1090,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
756
1090
  const into = current?.drums ?? (shot === null && t.score ? t.score.drums : bad('drums names the score\'s drum letters: write it after the score line, or under a section'))
757
1091
  if (rest.length === 0) bad('drums wants <letter>=<instrument> …')
758
1092
  for (const w of rest) {
759
- const m = !w.quoted ? /^([A-Za-z])=([a-zA-Z][a-zA-Z0-9]*)$/.exec(w.text) : null
1093
+ const m = !w.quoted ? /^([A-Za-z])=([a-zA-Z][a-zA-Z0-9]*(?::[a-z0-9-]+)?)$/.exec(w.text) : null
760
1094
  if (!m) bad(`drums wants <letter>=<instrument>, got ${w.text}`)
761
1095
  into[m![1]!] = m![2]!
762
1096
  }
@@ -843,11 +1177,12 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
843
1177
  t.fps = num('fps', rest[0], 1, FPS_MAX)
844
1178
  if (!Number.isInteger(t.fps)) bad(`fps wants a whole number, got ${rest[0]?.text}`)
845
1179
  break
846
- case 'music':
847
- once('music')
848
- t.music = musicLine(rest, n, true)
849
- if (t.music.song === NO_MUSIC) bad('music none is a shot\'s cue, the header names the song the cut opens on')
1180
+ case 'music': {
1181
+ const m = musicLine(rest, n, true)
1182
+ if (m.song === NO_MUSIC) bad('music none is a shot\'s cue, the header names the song the cut opens on')
1183
+ if (mine(m, t.music, 'the header')) t.music = m
850
1184
  break
1185
+ }
851
1186
  case 'duck': {
852
1187
  once('duck')
853
1188
  const drop = (w: Word | undefined, what: string): number => {
@@ -926,7 +1261,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
926
1261
  case 'track': {
927
1262
  const sc = t.score ?? bad('a track is a voice of the score: write score tempo <bpm> first')
928
1263
  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]*$/)
1264
+ const instrument = word(`track ${name}`, rest[1], /^[a-zA-Z][a-zA-Z0-9]*(:[a-z0-9-]+)?$/)
930
1265
  if (sc.tracks.some((x) => x.name === name)) bad(`track ${name} is already a track of the score`)
931
1266
  if (sc.tracks.length >= TRACKS_MAX) bad(`track ${name}: the score holds ${TRACKS_MAX} tracks at most`)
932
1267
  const p = pairs(`track ${name}`, rest.slice(2), { volume })
@@ -951,39 +1286,45 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
951
1286
  if ('error' in tk) errors.push({ line: o.line, message: tk.error })
952
1287
  else more.push(...tk.words.map((w) => ({ ...w, line: o.line })))
953
1288
  }
954
- s.film = filmLine([...rest.map((w) => ({ ...w, line: n })), ...more], n, verbs, {
1289
+ const f = (s.film = filmLine([...rest.map((w) => ({ ...w, line: n })), ...more], n, verbs, {
955
1290
  defaults: s.offcut ? [] : (current?.defaults ?? []),
956
1291
  presets,
957
1292
  time: momentWord,
958
1293
  later: (film, slot) => slots.push({ shot: s, film, slot }),
1294
+ ...(o.pageWords ? { pageWords: o.pageWords } : {}),
959
1295
  errors,
960
- })
1296
+ }))
1297
+ // a film that reads no declared words drops the section's defaults: said once, at the defaults line
1298
+ const takes = f.kind === 'cli' ? f.typed === true : f.kind === 'page' && o.pageWords !== undefined
1299
+ if (!s.offcut && current?.defaults && !takes) {
1300
+ const at = current.defaults[0]!.line ?? n
1301
+ const what = f.kind === 'cli' ? `film ${f.argv[0]} declares no words` : f.kind === 'page' ? 'film page declares no words (the trailer unit\'s page)' : `film ${f.kind} is a core film`
1302
+ if (!unread.some((u) => u.line === at)) unread.push({ line: at, section: current.name, films: [] })
1303
+ const u = unread.find((x) => x.line === at)!
1304
+ if (!u.films.includes(what)) u.films.push(what)
1305
+ }
961
1306
  }
962
1307
  break
963
1308
  case 'title': {
964
1309
  const text = quoted('title', rest[0])
965
- const p = pairs('title', rest.slice(1), {
966
- at: when,
967
- for: at,
968
- style: (w, k) => {
969
- const st = word(k, w)
970
- return (TITLE_STYLES as readonly string[]).includes(st) ? st : bad(`${k} wants ${TITLE_STYLES.join('|')}, got ${st}`)
971
- },
972
- place: (w, k) => {
973
- const pl = word(k, w)
974
- return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
975
- },
976
- knock: at,
977
- })
1310
+ const p = readTitleWords(rest.slice(1), presets)
1311
+ const st = p.style === undefined ? 'caption' : word('title style', { text: p.style as string, quoted: false }, /^[a-z][a-z0-9-]*$/)
978
1312
  const ti: Title = {
979
1313
  line: n,
980
1314
  text,
981
1315
  at: 0,
982
1316
  for: (p.for as number) ?? null,
983
- style: (p.style as TitleStyle) ?? 'caption',
1317
+ // a brand style is laid in by resolveStyles, once the brand is read
1318
+ style: builtInStyle(st) ? st : 'caption',
1319
+ ...(builtInStyle(st) ? {} : { named: st }),
984
1320
  ...(p.place ? { place: p.place as 'top' | 'bottom' } : {}),
985
1321
  ...(p.knock !== undefined ? { knock: p.knock as number } : {}),
1322
+ ...(p.in ? { in: p.in as Move } : {}),
1323
+ ...(p.out ? { out: p.out as Move } : {}),
986
1324
  }
1325
+ const fx = fxOf(p)
1326
+ if (Object.keys(fx).length > 0) ti.fx = fx
1327
+ if (ti.knock !== undefined && ti.out) bad('title: knock is its exit, give out or knock, not both')
987
1328
  ti.at = landAt(p.at, n, s, (v) => (ti.at = v))
988
1329
  if (ti.knock !== undefined && (ti.knock <= ti.at || (ti.for !== null && ti.knock >= ti.at + ti.for)))
989
1330
  bad(`title knock ${ti.knock}s: inside the title's own span, after its at`)
@@ -1010,18 +1351,22 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
1010
1351
  break
1011
1352
  }
1012
1353
  case 'sfx': {
1013
- const id = pathWord('sfx', rest[0])
1014
- const p = pairs(`sfx ${id}`, rest.slice(1), { at: when, volume })
1015
- const x: Sfx = { line: n, id, at: 0, volume: (p.volume as number) ?? 1 }
1354
+ const r = soundRun('sfx', rest)
1355
+ const words = r ? r.rest : rest.slice(1)
1356
+ const prompt = !r && rest[0]?.quoted === true
1357
+ const id = r ? 'run' : prompt ? quoted('sfx', rest[0]) : pathWord('sfx', rest[0])
1358
+ const p = pairs(`sfx ${r ? 'run' : id}`, words, { at: when, volume, for: at, seed: seedWord }, ['hit'])
1359
+ const x: Sfx = { line: n, id, ...(prompt ? { prompt: true as const } : {}), ...(r ? { run: r.run } : {}), ...genOf(p), at: 0, volume: (p.volume as number) ?? 1 }
1016
1360
  x.at = landAt(p.at, n, s, (v) => (x.at = v))
1017
1361
  s.sfx.push(x)
1018
1362
  break
1019
1363
  }
1020
1364
  case 'voice': {
1021
- const fileForm = rest[0] && !rest[0].quoted && rest[0].text === 'file'
1022
- const src = fileForm ? { file: pathWord('voice file', rest[1]) } : { text: quoted('voice', rest[0]) }
1023
- const p = pairs('voice', rest.slice(fileForm ? 2 : 1), { at: when, volume })
1024
- const v: Voice = { line: n, ...src, at: 0, volume: (p.volume as number) ?? 1 }
1365
+ const r = soundRun('voice', rest)
1366
+ const fileForm = !r && rest[0] && !rest[0].quoted && rest[0].text === 'file'
1367
+ const src = r ? { run: r.run } : fileForm ? { file: pathWord('voice file', rest[1]) } : { text: quoted('voice', rest[0]) }
1368
+ const p = pairs('voice', r ? r.rest : rest.slice(fileForm ? 2 : 1), { at: when, volume, ...(fileForm || r ? {} : { by: byWord, seed: seedWord }) })
1369
+ const v: Voice = { line: n, ...src, ...genOf(p), at: 0, volume: (p.volume as number) ?? 1 }
1025
1370
  v.at = landAt(p.at, n, s, (x) => (v.at = x))
1026
1371
  s.voices.push(v)
1027
1372
  break
@@ -1030,17 +1375,18 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
1030
1375
  if (s.cut.line !== s.line) bad(`shot ${s.name} already cuts on line ${s.cut.line}`)
1031
1376
  const k = word('cut', rest[0])
1032
1377
  if (!(CUT_KINDS as readonly string[]).includes(k)) bad(`cut: unknown kind ${k} (${CUT_KINDS.join(', ')})`)
1033
- const seconds = k === 'hard' ? 0 : rest[1] ? time(`cut ${k}`, rest[1]) : DEFAULT_CUT_S
1378
+ const seconds = k === 'hard' ? 0 : rest[1] ? time(`cut ${k}`, rest[1]) : (CUT_SECONDS[k as CutKind] ?? DEFAULT_CUT_S)
1034
1379
  if (k === 'hard' && rest[1]) bad('cut hard takes no time')
1035
1380
  if (rest.length > 2) bad(`cut ${k}: unknown word ${rest[2]?.text}`)
1036
1381
  s.cut = { line: n, kind: k as CutKind, seconds }
1037
1382
  break
1038
1383
  }
1039
- case 'music':
1040
- if (s.music) bad(`shot ${s.name} already changes the music on line ${s.music.line}`)
1384
+ case 'music': {
1041
1385
  if (t.score && t.sections.length > 0) bad('music: the score plays from its first section on; a song plays only before it')
1042
- s.music = musicLine(rest, n)
1386
+ const m = musicLine(rest, n)
1387
+ if (mine(m, s.music, `shot ${s.name}`)) s.music = m
1043
1388
  break
1389
+ }
1044
1390
  case 'starts': {
1045
1391
  if (s.lands) bad(`shot ${s.name} already lands on line ${s.lands.line}`)
1046
1392
  if (rest[0]?.text !== 'on' || !rest[1] || rest.length > 3) bad('starts wants on <section|mark> [+|-<time>]')
@@ -1097,6 +1443,9 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
1097
1443
  }
1098
1444
  }
1099
1445
  if (!seen.has('trailer')) errors.push({ line: 1, message: 'the file names its trailer first: trailer <name>' })
1446
+ if (o.variant !== undefined && !variants.includes(o.variant)) errors.push({ line: 1, message: `--variant ${o.variant}: this cut names ${variants.length ? `the variants ${variants.join(', ')}` : 'no music variant (music … variant <name>)'}` })
1447
+ // a variant other than the first renders beside it: out/<name>-<variant>.mp4
1448
+ if (variant !== null && variant !== variants[0]) t.name = `${t.name}-${variant}`
1100
1449
  if (t.shots.length === 0) errors.push({ line: lines.length, message: 'no shot: a trailer has at least one shot <name> <length>' })
1101
1450
  if (t.vertical && !seen.has('size')) t.size = [...VERTICAL_SIZE]
1102
1451
  else if (t.vertical && t.size[0] >= t.size[1]) errors.push({ line: t.vertical.line, message: `vertical: a short is filmed upright, its size taller than wide (1080x1920), not ${t.size.join('x')}` })
@@ -1121,6 +1470,8 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
1121
1470
  if (m === null || t.timelineAt === null) errors.push({ line: r.line, message: `at ${r.ref.mark}: no section or mark ${r.ref.mark} in this file` })
1122
1471
  else r.set(+(t.timelineAt + m + r.ref.offset - (starts(t)[i] ?? 0)).toFixed(4))
1123
1472
  }
1473
+ // a page still is the page stepped to its shot's from: the moment is part of its take
1474
+ for (const s of t.shots) if (s.film?.kind === 'page' && s.film.still) s.film.at = s.from
1124
1475
  for (const p of slots) {
1125
1476
  const i = t.shots.indexOf(p.shot)
1126
1477
  const parts = p.slot.parts.map((x) => {
@@ -1131,10 +1482,21 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
1131
1482
  else return { name: x.name, seconds: +(t.timelineAt + m + x.at.offset - (starts(t)[i] ?? 0) + p.shot.from).toFixed(4) }
1132
1483
  return { name: x.name, seconds: 0 }
1133
1484
  })
1485
+ // a moment of the take lies in its footage: from 0 to where the shot stops showing it
1486
+ const end = p.shot.from + p.shot.length
1487
+ p.slot.parts.forEach((x, k) => {
1488
+ const sec = parts[k]!.seconds
1489
+ if (sec < -1e-4 || sec > end + 1e-4)
1490
+ errors.push({ line: x.line, message: `film: ${x.name ? `${x.name}@` : ''}${+sec.toFixed(4)}s of the footage is outside the take (0 to ${+end.toFixed(4)}s, its from plus its length)` })
1491
+ })
1134
1492
  p.film.argv[p.slot.index] = slotText(parts)
1135
1493
  }
1494
+ for (const u of unread) errors.push({ line: u.line, message: `defaults of section ${u.section}: ${u.films.join(', ')}, so it drops them; give its words on its film line` })
1136
1495
  errors.push(...structure(t), ...grids(t))
1137
- return { trailer: t, errors: errors.sort((a, b) => a.line - b.line) }
1496
+ // a section's defaults read by each of its film lines say a wrong word once, at its line
1497
+ const said = new Set<string>()
1498
+ const once = errors.filter((e) => !said.has(`${e.line} ${e.message}`) && said.add(`${e.line} ${e.message}`))
1499
+ return { trailer: t, errors: once.sort((a, b) => a.line - b.line) }
1138
1500
  }
1139
1501
 
1140
1502
  /** the least a fitted shot keeps past its cut out */
@@ -1189,7 +1551,7 @@ function landShots(t: Trailer): TrailerError[] {
1189
1551
 
1190
1552
  /** a film that is one picture held for the shot */
1191
1553
  export const isStill = (f: Film | null): boolean =>
1192
- f?.kind === 'card' || f?.kind === 'image' || ((f?.kind === 'run' || f?.kind === 'url') && f.still === true) || (f?.kind === 'cli' && f.output === 'still')
1554
+ f?.kind === 'card' || f?.kind === 'image' || ((f?.kind === 'run' || f?.kind === 'url' || f?.kind === 'page') && f.still === true) || (f?.kind === 'cli' && f.output === 'still')
1193
1555
 
1194
1556
  /** what the lines say together: a film per shot, titles inside their shot, cuts shorter than what they join */
1195
1557
  function structure(t: Trailer): TrailerError[] {
@@ -1197,7 +1559,7 @@ function structure(t: Trailer): TrailerError[] {
1197
1559
  t.shots.forEach((s, i) => {
1198
1560
  if (s.broken) return
1199
1561
  if (!s.film) out.push({ line: s.line, message: `shot ${s.name} films nothing: give it a film line` })
1200
- if (isStill(s.film) && s.from > 0) out.push({ line: s.line, message: `shot ${s.name}: its film is a still image, no footage to skip (from)` })
1562
+ if (isStill(s.film) && s.film?.kind !== 'page' && s.from > 0) out.push({ line: s.line, message: `shot ${s.name}: its film is a still image, no footage to skip (from)` })
1201
1563
  if ((s.film?.kind === 'grid' || s.film?.kind === 'split') && s.from > 0) out.push({ line: s.line, message: `shot ${s.name}: a ${s.film.kind} has no footage of its own to skip; each cell's from skips its own` })
1202
1564
  // a title may run on over the shots after its own (a caption over a montage), never past the cut's end
1203
1565
  const left = total(t) - (starts(t)[i] ?? 0)
@@ -1229,7 +1591,7 @@ export function gridCells(t: Pick<Trailer, 'shots' | 'footage'>, cells: readonly
1229
1591
  }
1230
1592
 
1231
1593
  /** 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)
1594
+ export const isCellFilm = (f: Film | null): boolean => (f?.kind === 'run' || f?.kind === 'url' || f?.kind === 'page' || f?.kind === 'cli') && !isStill(f)
1233
1595
 
1234
1596
  /** what a grid or a split names, and what a footage block films */
1235
1597
  function grids(t: Trailer): TrailerError[] {