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.
- package/CHANGELOG.md +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/serve-hub.ts +7 -0
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +170 -29
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
package/src/trailer/grammar.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
|
|
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,
|
|
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`,
|
|
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:
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
if (t.music
|
|
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 =
|
|
966
|
-
|
|
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
|
-
|
|
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
|
|
1014
|
-
const
|
|
1015
|
-
const
|
|
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
|
|
1022
|
-
const
|
|
1023
|
-
const
|
|
1024
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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[] {
|