rvlib-shipkit 2.2.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 +47 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +183 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- 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/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +83 -14
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +389 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- 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 +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- 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 +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- 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/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- 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/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- 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,52 +1,131 @@
|
|
|
1
|
+
import { MOVES, type Move, type MoveKind } from './motion.ts'
|
|
2
|
+
import { type LineWord, type Moment, slotText, type Typed, typedFilm, type Words } from './words.ts'
|
|
3
|
+
|
|
1
4
|
// the trailer language, its core words: one text file → the shots, their films, titles, sounds and cuts. Pure: no
|
|
2
5
|
// file, no process; a film word that is not a core kind is looked up in the project CLI's film rows the caller
|
|
3
6
|
// passes in. Every error carries its line, all at once, and a file with one error never builds
|
|
4
7
|
|
|
5
|
-
export type TitleStyle = 'logo' | 'big' | 'caption' | 'corner' | 'line'
|
|
6
|
-
|
|
7
|
-
export const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
]
|
|
25
|
-
|
|
26
|
-
|
|
8
|
+
export type TitleStyle = 'logo' | 'big' | 'caption' | 'corner' | 'line' | 'short' | 'progress'
|
|
9
|
+
/** the styles a title line names; `progress` is its own line's */
|
|
10
|
+
export const TITLE_STYLES: readonly TitleStyle[] = ['logo', 'big', 'caption', 'corner', 'line', 'short']
|
|
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
|
|
27
62
|
|
|
28
63
|
/** the most frames a second a cut runs at */
|
|
29
64
|
export const FPS_MAX = 60
|
|
30
65
|
const DEFAULT_CUT_S = 0.5
|
|
31
66
|
|
|
67
|
+
/** how long a take films: what the shot shows rounded up to 2 s (`steps`, the default), exactly that (`exact`), or as
|
|
68
|
+
* long as it plays (`own`: no --seconds, the key holds none) */
|
|
69
|
+
export type TakeLength = 'steps' | 'exact' | 'own'
|
|
70
|
+
|
|
32
71
|
/** a film row of the project CLI: a still writes one image at `--out`, a clip a video of `--seconds` */
|
|
33
72
|
export interface FilmVerb {
|
|
34
73
|
path: string[]
|
|
35
74
|
film: 'still' | 'clip'
|
|
75
|
+
/** the words the row takes and their types (words.ts): every word checked at its line, values handed plain */
|
|
76
|
+
words?: Words
|
|
77
|
+
/** the paths the row's takes film: their world, over the unit's `stamp` */
|
|
78
|
+
stamp?: string[]
|
|
79
|
+
/** the row's take version: a bump films every take of the row again */
|
|
80
|
+
version?: number | string
|
|
81
|
+
/** `own`: the row films as long as it plays */
|
|
82
|
+
length?: 'own'
|
|
36
83
|
description?: string
|
|
37
84
|
}
|
|
38
85
|
|
|
86
|
+
/** a grid holds at most this many cells, 3 by 3 */
|
|
87
|
+
export const GRID_MAX = 9
|
|
88
|
+
/** a growing grid eases out over this long before each settle */
|
|
89
|
+
export const GROW_S = 1
|
|
90
|
+
/** a split's seams lean this far from upright by default: two panes top left and bottom right, three slanted columns */
|
|
91
|
+
export const SPLIT_ANGLE: Readonly<Record<number, number>> = { 2: 56, 3: 14 }
|
|
92
|
+
/** a split's panes slide in this long by default */
|
|
93
|
+
export const SPLIT_SLIDE = 0.3
|
|
94
|
+
|
|
39
95
|
export type Film =
|
|
40
96
|
| { kind: 'clip'; line: number; path: string }
|
|
41
97
|
| { kind: 'image'; line: number; path: string }
|
|
42
98
|
| { kind: 'card'; line: number }
|
|
43
|
-
/** any command that writes the take at `{out}`; `still`: it writes an image, held for the shot
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
99
|
+
/** any command that writes the take at `{out}`; `still`: it writes an image, held for the shot; `reads`: the files
|
|
100
|
+
* the command reads, their content part of the take's key */
|
|
101
|
+
| { kind: 'run'; line: number; cmd: string; still?: true; reads?: string[] }
|
|
102
|
+
/** a film row of the project CLI, its words as written: `<cli> <argv> --out <file> [--seconds <n>]`; its row's take
|
|
103
|
+
* version and length when the row has them */
|
|
104
|
+
| {
|
|
105
|
+
kind: 'cli'
|
|
106
|
+
line: number
|
|
107
|
+
argv: string[]
|
|
108
|
+
output: 'still' | 'clip'
|
|
109
|
+
version?: number | string
|
|
110
|
+
length?: 'own'
|
|
111
|
+
/** the row declares its words: argv holds them plain */
|
|
112
|
+
typed?: true
|
|
113
|
+
/** argv slots that are files as written: the build finds them and keys their content */
|
|
114
|
+
files?: number[]
|
|
115
|
+
/** the paths the row's takes film */
|
|
116
|
+
stamp?: string[]
|
|
117
|
+
}
|
|
47
118
|
/** a page of the repo's dev url (a route) or any http url, through the house shot service; `viewport` its css
|
|
48
119
|
* size, `wait` before it films; `still`: a png, else a clip of the page's largest canvas */
|
|
49
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 }
|
|
124
|
+
/** other shots' or footage blocks' takes tiled, each cell playing live; `pop`: cell i pops in at i times it; `grow`:
|
|
125
|
+
* nine cells growing out of the first, settled on the 2 by 2 block then the whole at these seconds */
|
|
126
|
+
| { kind: 'grid'; line: number; cells: string[]; pop: number; grow?: [number, number] }
|
|
127
|
+
/** two or three takes side by side, cut by slanted seams `angle` degrees from upright, each pane sliding in */
|
|
128
|
+
| { kind: 'split'; line: number; cells: string[]; angle: number; slide: number }
|
|
50
129
|
|
|
51
130
|
/** the vertical short: a lockup over every frame, a watermark in a bottom corner, then the outro and the end card,
|
|
52
131
|
* each drawn from the brand's `trailer.vertical` data (vertical.ts) */
|
|
@@ -72,12 +151,61 @@ export interface Title {
|
|
|
72
151
|
place?: 'top' | 'bottom'
|
|
73
152
|
/** knocked off the frame from this second of its shot: it tips and flies out */
|
|
74
153
|
knock?: number
|
|
154
|
+
/** a `progress` line's step: the i-th of n notches filled under its name */
|
|
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[]
|
|
75
199
|
}
|
|
76
200
|
|
|
77
201
|
export interface Sfx {
|
|
78
202
|
line: number
|
|
79
|
-
/** a file,
|
|
203
|
+
/** a file, an id the repo's sound resolver knows, or a prompt (`prompt`); `run` for a run's sound */
|
|
80
204
|
id: string
|
|
205
|
+
/** the id is a quoted prompt the house makes from */
|
|
206
|
+
prompt?: true
|
|
207
|
+
gen?: SoundGen
|
|
208
|
+
run?: SoundRun
|
|
81
209
|
at: number
|
|
82
210
|
volume: number
|
|
83
211
|
}
|
|
@@ -87,6 +215,8 @@ export interface Voice {
|
|
|
87
215
|
/** a line the repo's voice resolver finds by its text, or a recorded file */
|
|
88
216
|
text?: string
|
|
89
217
|
file?: string
|
|
218
|
+
gen?: SoundGen
|
|
219
|
+
run?: SoundRun
|
|
90
220
|
at: number
|
|
91
221
|
volume: number
|
|
92
222
|
}
|
|
@@ -119,8 +249,13 @@ export interface SectionRef {
|
|
|
119
249
|
|
|
120
250
|
export interface Music {
|
|
121
251
|
line: number
|
|
122
|
-
/** 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 */
|
|
123
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
|
|
124
259
|
take?: string
|
|
125
260
|
from: number
|
|
126
261
|
fadeIn: number
|
|
@@ -159,6 +294,8 @@ export interface Shot {
|
|
|
159
294
|
music: Music | null
|
|
160
295
|
/** its own line failed: nothing else is checked against it */
|
|
161
296
|
broken?: true
|
|
297
|
+
/** a `footage` block: filmed, never cut in */
|
|
298
|
+
offcut?: true
|
|
162
299
|
}
|
|
163
300
|
|
|
164
301
|
/** the tempo a line counts bars and beats in */
|
|
@@ -177,7 +314,8 @@ export interface Mark {
|
|
|
177
314
|
at: number
|
|
178
315
|
}
|
|
179
316
|
|
|
180
|
-
/** a part of the timeline in bars: its shots land on its start, and the stitch caches it as one piece
|
|
317
|
+
/** a part of the timeline in bars: its shots land on its start, and the stitch caches it as one piece. Under a
|
|
318
|
+
* score, it is also a part of the music: what each track plays over it */
|
|
181
319
|
export interface Section {
|
|
182
320
|
line: number
|
|
183
321
|
name: string
|
|
@@ -186,10 +324,48 @@ export interface Section {
|
|
|
186
324
|
at: number
|
|
187
325
|
seconds: number
|
|
188
326
|
marks: Mark[]
|
|
327
|
+
/** `defaults <words>`: what every declared film line of the section starts from */
|
|
328
|
+
defaults?: Word[]
|
|
329
|
+
/** its drum letters over the score's */
|
|
330
|
+
drums: Record<string, string>
|
|
331
|
+
plays: Play[]
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** `play <track> <expression>`: what a track plays over its section, TypeScript; `*` a record of tracks */
|
|
335
|
+
export interface Play {
|
|
336
|
+
line: number
|
|
337
|
+
track: string
|
|
338
|
+
expr: string
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** the cut's own music, written in the file (score/score.ts compiles it, score/synth.ts renders it) */
|
|
342
|
+
export interface Score {
|
|
343
|
+
line: number
|
|
344
|
+
/** the first section's clock: the score line's own */
|
|
345
|
+
clock: Clock
|
|
346
|
+
reverb?: { seconds: number; mix: number }
|
|
347
|
+
fadeIn: number
|
|
348
|
+
volume: number
|
|
349
|
+
tracks: { line: number; name: string; instrument: string; volume: number }[]
|
|
350
|
+
/** drum letter → instrument, every section */
|
|
351
|
+
drums: Record<string, string>
|
|
352
|
+
/** the `code` block, its lines at their own line numbers (the others blank) */
|
|
353
|
+
code: { line: number; text: string } | null
|
|
354
|
+
/** `stop at <time>`: the score and its room cut dead there, seconds from the first section's start */
|
|
355
|
+
stop?: { line: number; at: number }
|
|
189
356
|
}
|
|
190
357
|
|
|
358
|
+
/** the voices a score holds at most */
|
|
359
|
+
export const TRACKS_MAX = 16
|
|
360
|
+
/** the music a score starts with: the render's lead, so its first note is never cut */
|
|
361
|
+
export const SCORE_LEAD = 0.05
|
|
362
|
+
/** the score in the cut's music cues */
|
|
363
|
+
export const SCORE = '@score'
|
|
364
|
+
|
|
191
365
|
export interface Trailer {
|
|
192
366
|
name: string
|
|
367
|
+
/** `footage` blocks: filmed like shots, never cut in; a grid or a split reads them */
|
|
368
|
+
footage: Shot[]
|
|
193
369
|
size: [number, number]
|
|
194
370
|
fps: number
|
|
195
371
|
music: Music | null
|
|
@@ -200,12 +376,17 @@ export interface Trailer {
|
|
|
200
376
|
fadeIn: number
|
|
201
377
|
shots: Shot[]
|
|
202
378
|
sections: Section[]
|
|
203
|
-
/**
|
|
379
|
+
/** the cut's own music; null: none */
|
|
380
|
+
score: Score | null
|
|
381
|
+
/** where the timeline of the sections (and the score) starts on the cut: the first landed shot's start less its moment */
|
|
204
382
|
timelineAt: number | null
|
|
205
383
|
/** the cut is a vertical short (`vertical`), finished with the brand's lockup, watermark and end card */
|
|
206
384
|
vertical: Vertical | null
|
|
207
385
|
/** the global process whose url a `film url` route resolves against (`base <id>`); null: the repo's one */
|
|
208
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
|
|
209
390
|
}
|
|
210
391
|
|
|
211
392
|
export interface TrailerError {
|
|
@@ -216,9 +397,15 @@ export interface TrailerError {
|
|
|
216
397
|
interface Word {
|
|
217
398
|
text: string
|
|
218
399
|
quoted: boolean
|
|
400
|
+
/** an option line's word keeps its own line */
|
|
401
|
+
line?: number
|
|
219
402
|
}
|
|
220
403
|
|
|
221
|
-
/** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line
|
|
404
|
+
/** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line. Only double quotes quote, as in
|
|
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
|
+
|
|
222
409
|
export function tokens(line: string): { words: Word[] } | { error: string } {
|
|
223
410
|
const words: Word[] = []
|
|
224
411
|
let i = 0
|
|
@@ -228,7 +415,12 @@ export function tokens(line: string): { words: Word[] } | { error: string } {
|
|
|
228
415
|
i++
|
|
229
416
|
continue
|
|
230
417
|
}
|
|
231
|
-
if (c === '#') break
|
|
418
|
+
if (c === '#' && !colourAt(line, i)) break
|
|
419
|
+
if (c === "'") {
|
|
420
|
+
const end = line.indexOf("'", i + 1)
|
|
421
|
+
const word = end < 0 ? line.slice(i).split(/[\s#]/)[0]! : line.slice(i, end + 1)
|
|
422
|
+
return { error: `${word} opens on a single quote: the language quotes with double quotes only, write "${word.slice(1, end < 0 ? undefined : -1)}"` }
|
|
423
|
+
}
|
|
232
424
|
if (c === '"') {
|
|
233
425
|
const end = line.indexOf('"', i + 1)
|
|
234
426
|
if (end < 0) return { error: 'a quote is not closed' }
|
|
@@ -237,7 +429,7 @@ export function tokens(line: string): { words: Word[] } | { error: string } {
|
|
|
237
429
|
continue
|
|
238
430
|
}
|
|
239
431
|
let j = i
|
|
240
|
-
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++
|
|
241
433
|
words.push({ text: line.slice(i, j), quoted: false })
|
|
242
434
|
i = j
|
|
243
435
|
}
|
|
@@ -319,6 +511,131 @@ const sizeWord = (what: string, w: Word | undefined, lo: number, hi: number): [n
|
|
|
319
511
|
return [x, y]
|
|
320
512
|
}
|
|
321
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
|
+
|
|
322
639
|
/** the `key value` pairs after a line's fixed words; `flags` take no value */
|
|
323
640
|
function pairs(
|
|
324
641
|
what: string,
|
|
@@ -356,8 +673,79 @@ const filmWords = (verbs: readonly FilmVerb[] | null): string =>
|
|
|
356
673
|
|
|
357
674
|
/** a film line: a core kind when its words fit that kind's form (`clip <video file>`), else a film row of the project
|
|
358
675
|
* CLI (its `clip <scene>` row), else the core kind's error */
|
|
359
|
-
|
|
676
|
+
/** what a film line of a declared row reads past its words: its section's defaults, the header's presets, a time */
|
|
677
|
+
interface FilmContext {
|
|
678
|
+
defaults: Word[]
|
|
679
|
+
presets: ReadonlyMap<string, Word[]>
|
|
680
|
+
time: (text: string) => Moment | null
|
|
681
|
+
/** a slot that waits on a moment, resolved once the shots have landed */
|
|
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
|
|
685
|
+
errors: TrailerError[]
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** a film line's words with every `+<preset>` put in place */
|
|
689
|
+
function expandPresets(ws: Word[], line: number, presets: ReadonlyMap<string, Word[]>): Word[] {
|
|
690
|
+
return ws.flatMap((w) => {
|
|
691
|
+
if (w.quoted || !/^\+[a-z][a-z0-9-]*$/.test(w.text)) return [w]
|
|
692
|
+
const p = presets.get(w.text.slice(1)) ?? bad(`film: no preset ${w.text.slice(1)} (preset <name> <words> in the header${presets.size ? `: ${[...presets.keys()].join(', ')}` : ''})`)
|
|
693
|
+
return p.map((x) => ({ ...x, line: w.line ?? line }))
|
|
694
|
+
})
|
|
695
|
+
}
|
|
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
|
+
|
|
745
|
+
function filmLine(raw: Word[], line: number, verbs: readonly FilmVerb[] | null, c?: FilmContext): Film {
|
|
746
|
+
const ws = c ? expandPresets(raw, line, c.presets) : raw
|
|
360
747
|
const kind = ws[0] && !ws[0].quoted ? ws[0].text : ''
|
|
748
|
+
if (kind === 'page' && c) return pageFilm(ws, line, c)
|
|
361
749
|
const argv = ws.map((w) => w.text)
|
|
362
750
|
const v = verbs && verbOf(argv, verbs)
|
|
363
751
|
if ((CORE_FILMS as readonly string[]).includes(kind)) {
|
|
@@ -369,7 +757,23 @@ function filmLine(ws: Word[], line: number, verbs: readonly FilmVerb[] | null):
|
|
|
369
757
|
}
|
|
370
758
|
if (!v) return bad(`film: unknown kind ${kind || '(none)'} (${filmWords(verbs)})`)
|
|
371
759
|
if (argv.some((a) => a === '--out' || a.startsWith('--out=') || a === '--seconds' || a.startsWith('--seconds='))) bad(`film ${v.path.join(' ')}: the trailer gives --out and --seconds itself`)
|
|
372
|
-
|
|
760
|
+
const marks = { ...(v.version !== undefined ? { version: v.version } : {}), ...(v.length ? { length: v.length } : {}), ...(v.stamp ? { stamp: v.stamp } : {}) }
|
|
761
|
+
if (!v.words || !c) return { kind: 'cli', line, argv, output: v.film, ...marks }
|
|
762
|
+
const at = (w: Word): LineWord => ({ text: w.text, quoted: w.quoted, line: w.line ?? line })
|
|
763
|
+
const typed = typedFilm(v.path, v.words, ws.slice(v.path.length).map(at), c.defaults.map(at), c.time)
|
|
764
|
+
c.errors.push(...typed.errors)
|
|
765
|
+
const f: Extract<Film, { kind: 'cli' }> = { kind: 'cli', line, argv: typed.argv, output: v.film, typed: true, ...(typed.files.length ? { files: typed.files } : {}), ...marks }
|
|
766
|
+
for (const slot of typed.times) c.later(f, slot)
|
|
767
|
+
return f
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** a list of `min` to `max` different shot or footage names */
|
|
771
|
+
function cellList(what: string, w: Word | undefined, min: number, max: number): string[] {
|
|
772
|
+
const list = word(what, w, /^[a-z0-9][a-z0-9-]*(?:,[a-z0-9][a-z0-9-]*)*$/)
|
|
773
|
+
const cells = list.split(',')
|
|
774
|
+
if (cells.length < min || cells.length > max) bad(`${what}: ${min} to ${max} cells, got ${cells.length}`)
|
|
775
|
+
if (new Set(cells).size !== cells.length) bad(`${what}: a cell named twice in ${list}`)
|
|
776
|
+
return cells
|
|
373
777
|
}
|
|
374
778
|
|
|
375
779
|
function coreFilm(kind: string, ws: Word[], line: number): Film {
|
|
@@ -392,8 +796,8 @@ function coreFilm(kind: string, ws: Word[], line: number): Film {
|
|
|
392
796
|
case 'run': {
|
|
393
797
|
const cmd = quoted('film run', ws[1])
|
|
394
798
|
if (!cmd.includes('{out}')) bad('film run wants {out} in its command: the file the command writes the take to')
|
|
395
|
-
const p = pairs('film run', ws.slice(2), {}, ['still'])
|
|
396
|
-
return { kind, line, cmd, ...(p.still ? { still: true as const } : {}) }
|
|
799
|
+
const p = pairs('film run', ws.slice(2), { reads: (w, k) => pathWord(k, w).split(',') }, ['still'])
|
|
800
|
+
return { kind, line, cmd, ...(p.still ? { still: true as const } : {}), ...(p.reads ? { reads: p.reads as string[] } : {}) }
|
|
397
801
|
}
|
|
398
802
|
case 'url': {
|
|
399
803
|
const route = pathWord('film url', ws[1])
|
|
@@ -408,6 +812,25 @@ function coreFilm(kind: string, ws: Word[], line: number): Film {
|
|
|
408
812
|
...(p.still ? { still: true as const } : {}),
|
|
409
813
|
}
|
|
410
814
|
}
|
|
815
|
+
case 'grid': {
|
|
816
|
+
const cells = cellList('film grid', ws[1], 1, GRID_MAX)
|
|
817
|
+
const p = pairs(`film grid ${cells.join(',')}`, ws.slice(2), {
|
|
818
|
+
pop: at,
|
|
819
|
+
grow: (w, k) => {
|
|
820
|
+
const t = word(k, w, /^\d+(?:\.\d+)?s,\d+(?:\.\d+)?s$/)
|
|
821
|
+
const v = t.split(',').map((x) => Number(x.slice(0, -1)))
|
|
822
|
+
return v[0]! >= GROW_S && v[1]! >= v[0]! + GROW_S ? v : bad(`${k} wants two settles at least ${GROW_S}s apart, the first at ${GROW_S}s or later, got ${t}`)
|
|
823
|
+
},
|
|
824
|
+
})
|
|
825
|
+
if (p.grow && cells.length !== 9) bad(`film grid … grow: it grows out of one cell into four, then nine: it wants nine cells, got ${cells.length}`)
|
|
826
|
+
if (p.grow && p.pop) bad('film grid: grow or pop, not both')
|
|
827
|
+
return { kind, line, cells, pop: (p.pop as number) ?? 0, ...(p.grow ? { grow: p.grow as [number, number] } : {}) }
|
|
828
|
+
}
|
|
829
|
+
case 'split': {
|
|
830
|
+
const cells = cellList('film split', ws[1], 2, 3)
|
|
831
|
+
const p = pairs(`film split ${cells.join(',')}`, ws.slice(2), { angle: (w, k) => num(k, w, 0, 70), slide: at })
|
|
832
|
+
return { kind, line, cells, angle: (p.angle as number) ?? SPLIT_ANGLE[cells.length] ?? 0, slide: (p.slide as number) ?? SPLIT_SLIDE }
|
|
833
|
+
}
|
|
411
834
|
}
|
|
412
835
|
return bad(`film: unknown kind ${kind}`)
|
|
413
836
|
}
|
|
@@ -422,18 +845,55 @@ export const NO_MUSIC = 'none'
|
|
|
422
845
|
export const OPENING_FADE_IN = 0.25
|
|
423
846
|
|
|
424
847
|
/** `music <file|id|none> [take k] [from t] [fade-in t] [fade-out t] [volume v]`; `opening`: the cut's own */
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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), {
|
|
428
880
|
take: (w, k) => word(k, w, /^[a-z0-9][a-z0-9-]*$/),
|
|
429
881
|
from: at,
|
|
430
882
|
'fade-in': at,
|
|
431
883
|
'fade-out': at,
|
|
432
884
|
volume,
|
|
885
|
+
by: byWord,
|
|
886
|
+
for: at,
|
|
887
|
+
seed: seedWord,
|
|
888
|
+
variant: (w, k) => word(k, w, /^[a-z0-9][a-z0-9-]*$/),
|
|
433
889
|
})
|
|
434
890
|
return {
|
|
435
891
|
line: n,
|
|
436
892
|
song,
|
|
893
|
+
...(prompt ? { prompt: true as const } : {}),
|
|
894
|
+
...(r ? { run: r.run } : {}),
|
|
895
|
+
...genOf(p),
|
|
896
|
+
...(p.variant ? { variant: p.variant as string } : {}),
|
|
437
897
|
...(p.take ? { take: p.take as string } : {}),
|
|
438
898
|
from: (p.from as number) ?? 0,
|
|
439
899
|
fadeIn: (p['fade-in'] as number | undefined) ?? (opening && ((p.from as number | undefined) ?? 0) > 0 ? OPENING_FADE_IN : 0),
|
|
@@ -442,63 +902,129 @@ function musicLine(rest: Word[], n: number, opening = false): Music {
|
|
|
442
902
|
}
|
|
443
903
|
}
|
|
444
904
|
|
|
445
|
-
/** words of the full trailer language a repo adds through its own words, not the core's */
|
|
446
|
-
const NOT_CORE: Record<string, string> = {
|
|
447
|
-
score: 'a score the repo synthesizes',
|
|
448
|
-
track: 'a voice of a score',
|
|
449
|
-
drums: 'a score\'s drum letters',
|
|
450
|
-
code: 'a score\'s code block',
|
|
451
|
-
play: 'what a score track plays',
|
|
452
|
-
progress: 'a progress bar title',
|
|
453
|
-
footage: 'a footage block a grid reads',
|
|
454
|
-
}
|
|
455
|
-
const notCore = (w: string): string => `${w} is ${NOT_CORE[w]}: a word of the full trailer language the core does not read yet`
|
|
456
|
-
|
|
457
905
|
/**
|
|
458
|
-
* the
|
|
459
|
-
*
|
|
906
|
+
* the lines the language reads as TypeScript, taken out before the words are read (their lines blank, so every line
|
|
907
|
+
* keeps its number): the `code` block (its indented lines under a margin `code` line) and each `play` line with the
|
|
908
|
+
* deeper indented lines that continue it. A line whose first mark is `#` is off in both.
|
|
460
909
|
*/
|
|
461
|
-
export function
|
|
910
|
+
export function scoreLines(text: string): { lines: string[]; code: Score['code']; plays: (Play & { section: number })[]; errors: TrailerError[] } {
|
|
462
911
|
const lines = text.split('\n')
|
|
463
912
|
const errors: TrailerError[] = []
|
|
913
|
+
const plays: (Play & { section: number })[] = []
|
|
914
|
+
let code: Score['code'] = null
|
|
915
|
+
let section = -1
|
|
464
916
|
const indent = (l: string): number => l.length - l.trimStart().length
|
|
465
917
|
const off = (l: string): boolean => l.trimStart().startsWith('#')
|
|
466
918
|
for (let i = 0; i < lines.length; i++) {
|
|
467
919
|
const l = lines[i]!
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
920
|
+
if (/^section\s/.test(l)) section++
|
|
921
|
+
if (/^code\s*(#.*)?$/.test(l)) {
|
|
922
|
+
const head = i + 1
|
|
923
|
+
if (code) errors.push({ line: head, message: `code is already written on line ${code.line}: one code block a file` })
|
|
924
|
+
const body: string[] = Array.from({ length: head }, () => '')
|
|
925
|
+
lines[i] = ''
|
|
926
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
927
|
+
const b = lines[j]!
|
|
928
|
+
if (b.trim() !== '' && !off(b) && indent(b) === 0) break
|
|
929
|
+
body.push(off(b) ? '' : b.replace(/^( {2}|\t)/, ''))
|
|
930
|
+
lines[j] = ''
|
|
931
|
+
i = j
|
|
932
|
+
}
|
|
933
|
+
code ??= { line: head, text: body.join('\n') }
|
|
934
|
+
continue
|
|
935
|
+
}
|
|
936
|
+
const m = /^(\s*)play(?:\s+(\S+))?(?:\s+(.*))?$/.exec(l)
|
|
937
|
+
if (!m || off(l)) continue
|
|
938
|
+
const at = i + 1
|
|
939
|
+
const parts = [m[3] ?? '']
|
|
472
940
|
lines[i] = ''
|
|
473
|
-
const base = code ? 0 : play![1]!.length
|
|
474
941
|
for (let j = i + 1; j < lines.length; j++) {
|
|
475
942
|
const b = lines[j]!
|
|
476
|
-
|
|
477
|
-
|
|
943
|
+
// deeper lines continue the expression; a closing bracket at the play's own indent ends it, and is its
|
|
944
|
+
const close = indent(b) === (m[1] ?? '').length && /^[)\]}]/.test(b.trimStart())
|
|
945
|
+
if (b.trim() !== '' && !off(b) && indent(b) <= (m[1] ?? '').length && !close) break
|
|
946
|
+
if (!off(b)) parts.push(b)
|
|
947
|
+
if (close) {
|
|
948
|
+
lines[j] = ''
|
|
949
|
+
i = j
|
|
950
|
+
break
|
|
951
|
+
}
|
|
478
952
|
lines[j] = ''
|
|
479
953
|
i = j
|
|
480
|
-
if (close) break
|
|
481
954
|
}
|
|
955
|
+
const track = m[2] ?? ''
|
|
956
|
+
const expr = parts.join('\n').trim()
|
|
957
|
+
if (!/^(\*|[a-zA-Z][a-zA-Z0-9]*)$/.test(track)) errors.push({ line: at, message: `play wants a track and what it plays: play <track> <expression>${track ? `, got ${track}` : ''}` })
|
|
958
|
+
else if (expr === '') errors.push({ line: at, message: `play ${track}: nothing to play` })
|
|
959
|
+
else if (section < 0) errors.push({ line: at, message: `play ${track}: a play belongs to a section, write it under one` })
|
|
960
|
+
else plays.push({ line: at, track, expr, section })
|
|
482
961
|
}
|
|
483
|
-
return { lines, errors }
|
|
962
|
+
return { lines, code, plays, errors }
|
|
484
963
|
}
|
|
485
964
|
|
|
486
965
|
export interface ParseOptions {
|
|
487
966
|
/** the project CLI's film rows; null when the repo has no project CLI */
|
|
488
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
|
|
489
982
|
}
|
|
490
983
|
|
|
491
984
|
/** the file as a trailer, and every error with its line */
|
|
985
|
+
/** a film line's option lines: the lines under it indented deeper, each `key value`, taken out before the words are
|
|
986
|
+
* read (their lines blank, so every line keeps its number) and kept by the film line's number */
|
|
987
|
+
export function filmOptionLines(lines: string[]): Map<number, { line: number; text: string }[]> {
|
|
988
|
+
const out = new Map<number, { line: number; text: string }[]>()
|
|
989
|
+
const indent = (l: string): number => l.length - l.trimStart().length
|
|
990
|
+
for (let i = 0; i < lines.length; i++) {
|
|
991
|
+
const m = /^(\s*)film(\s|$)/.exec(lines[i]!)
|
|
992
|
+
if (!m) continue
|
|
993
|
+
const head = i + 1
|
|
994
|
+
const own: { line: number; text: string }[] = []
|
|
995
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
996
|
+
const l = lines[j]!
|
|
997
|
+
if (l.trim() === '' || l.trimStart().startsWith('#')) continue
|
|
998
|
+
if (indent(l) <= m[1]!.length) break
|
|
999
|
+
own.push({ line: j + 1, text: l })
|
|
1000
|
+
lines[j] = ''
|
|
1001
|
+
i = j
|
|
1002
|
+
}
|
|
1003
|
+
if (own.length) out.set(head, own)
|
|
1004
|
+
}
|
|
1005
|
+
return out
|
|
1006
|
+
}
|
|
1007
|
+
|
|
492
1008
|
export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Trailer; errors: TrailerError[] } {
|
|
493
1009
|
const verbs = o.verbs ?? null
|
|
494
|
-
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
|
+
}
|
|
495
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[] }[] = []
|
|
496
1024
|
const seen = new Set<string>()
|
|
497
1025
|
let shot: Shot | null = null
|
|
498
1026
|
let pending: SectionRef | null = null
|
|
499
1027
|
let current: Section | null = null
|
|
500
|
-
let scoreClock: Clock | null = null
|
|
501
|
-
let skipping = false
|
|
502
1028
|
/** the `at`s that name a mark, set once every shot has landed */
|
|
503
1029
|
const refs: { line: number; shot: Shot; ref: MarkAt; set: (at: number) => void }[] = []
|
|
504
1030
|
const landAt = (v: unknown, line: number, s: Shot, set: (at: number) => void): number => {
|
|
@@ -507,13 +1033,27 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
507
1033
|
return 0
|
|
508
1034
|
}
|
|
509
1035
|
clock = null
|
|
510
|
-
const pre =
|
|
1036
|
+
const pre = scoreLines(text)
|
|
511
1037
|
errors.push(...pre.errors)
|
|
512
1038
|
const lines = pre.lines
|
|
1039
|
+
const options = filmOptionLines(lines)
|
|
1040
|
+
const presets = new Map<string, Word[]>()
|
|
1041
|
+
/** the film slots that wait on a moment: resolved once the shots have landed, each plus its shot's `from` */
|
|
1042
|
+
const slots: { shot: Shot; film: { argv: string[] }; slot: Typed['times'][number] }[] = []
|
|
1043
|
+
/** a time as a declared word reads it, at the clock of its line; null when it is none */
|
|
1044
|
+
const momentWord = (w: string): Moment | null => {
|
|
1045
|
+
const m = new RegExp(`^(${MOMENT})(?:([+-])(\\d.*))?$`).exec(w)
|
|
1046
|
+
// a bare number is seconds, as a cue of a take counts them
|
|
1047
|
+
if (!m) return /^\d+(\.\d+)?$/.test(w) ? Number(w) : parseTime(w, clock)
|
|
1048
|
+
const off = m[3] === undefined ? 0 : parseTime(m[3], clock)
|
|
1049
|
+
return off === null ? null : { mark: m[1]!, offset: m[2] === '-' ? -off : off }
|
|
1050
|
+
}
|
|
513
1051
|
for (let n = 1; n <= lines.length; n++) {
|
|
514
1052
|
const tk = tokens(lines[n - 1]!)
|
|
515
1053
|
if ('error' in tk) {
|
|
516
1054
|
errors.push({ line: n, message: tk.error })
|
|
1055
|
+
// its own line failed: the shot is not also told it films nothing
|
|
1056
|
+
if (shot && /^\s*film(\s|$)/.test(lines[n - 1]!)) shot.broken = true
|
|
517
1057
|
continue
|
|
518
1058
|
}
|
|
519
1059
|
const ws = tk.words
|
|
@@ -524,25 +1064,8 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
524
1064
|
if (seen.has(key)) bad(`${key} given twice`)
|
|
525
1065
|
seen.add(key)
|
|
526
1066
|
}
|
|
527
|
-
// a footage block's own lines belong to it: skipped with it, its one error at its line
|
|
528
|
-
if (skipping && !['shot', 'footage', 'section'].includes(head.text)) continue
|
|
529
|
-
skipping = false
|
|
530
1067
|
try {
|
|
531
1068
|
if (head.quoted) bad(`a line starts with a word, got "${head.text}"`)
|
|
532
|
-
if (head.text === 'footage') skipping = true
|
|
533
|
-
if (head.text in NOT_CORE) {
|
|
534
|
-
// a score's clock still counts the sections' bars, so one error stands for the score, not one per section
|
|
535
|
-
if (head.text === 'score') {
|
|
536
|
-
const v = (k: string): number | undefined => {
|
|
537
|
-
const i = rest.findIndex((w) => w.text === k)
|
|
538
|
-
const x = i >= 0 ? Number(rest[i + 1]?.text) : Number.NaN
|
|
539
|
-
return Number.isFinite(x) ? x : undefined
|
|
540
|
-
}
|
|
541
|
-
const bpm = v('tempo')
|
|
542
|
-
if (bpm) scoreClock = { bpm, meter: v('meter') ?? 4, grid: v('grid') ?? 4 }
|
|
543
|
-
}
|
|
544
|
-
bad(notCore(head.text))
|
|
545
|
-
}
|
|
546
1069
|
if (head.text === 'section') {
|
|
547
1070
|
const name = word('section', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
548
1071
|
const last = t.sections.at(-1)
|
|
@@ -552,17 +1075,44 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
552
1075
|
meter: (w, k) => num(k, w, 1, 16),
|
|
553
1076
|
grid: (w, k) => num(k, w, 1, 32),
|
|
554
1077
|
})
|
|
555
|
-
const base = last?.clock ??
|
|
1078
|
+
const base = last?.clock ?? t.score?.clock ?? null
|
|
556
1079
|
if (!base && !p.tempo && (p.meter || p.grid)) bad(`section ${name}: meter and grid count in a tempo, give it tempo <bpm>`)
|
|
557
1080
|
clock = p.tempo || base ? { ...(base ?? { bpm: 120, meter: 4, grid: 4 }), ...(p.tempo ? { bpm: p.tempo as number } : {}), ...(p.meter ? { meter: p.meter as number } : {}), ...(p.grid ? { grid: p.grid as number } : {}) } : null
|
|
558
1081
|
const seconds = +time(`section ${name}`, rest[1]).toFixed(6)
|
|
559
1082
|
if (seconds <= 0) bad(`section ${name}: its length must be above 0`)
|
|
560
1083
|
const at = last ? +(last.at + last.seconds).toFixed(6) : 0
|
|
561
|
-
current = { line: n, name, clock, at, seconds, marks: [] }
|
|
1084
|
+
current = { line: n, name, clock, at, seconds, marks: [], drums: {}, plays: [] }
|
|
562
1085
|
t.sections.push(current)
|
|
563
1086
|
pending = { line: n, name, at }
|
|
564
1087
|
continue
|
|
565
1088
|
}
|
|
1089
|
+
if (head.text === 'drums') {
|
|
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'))
|
|
1091
|
+
if (rest.length === 0) bad('drums wants <letter>=<instrument> …')
|
|
1092
|
+
for (const w of rest) {
|
|
1093
|
+
const m = !w.quoted ? /^([A-Za-z])=([a-zA-Z][a-zA-Z0-9]*(?::[a-z0-9-]+)?)$/.exec(w.text) : null
|
|
1094
|
+
if (!m) bad(`drums wants <letter>=<instrument>, got ${w.text}`)
|
|
1095
|
+
into[m![1]!] = m![2]!
|
|
1096
|
+
}
|
|
1097
|
+
continue
|
|
1098
|
+
}
|
|
1099
|
+
if (head.text === 'stop') {
|
|
1100
|
+
const sec = current ?? bad('a stop is a moment of a section: write it under one')
|
|
1101
|
+
const sc = t.score ?? bad('stop cuts the score dead: write score tempo <bpm> in the header')
|
|
1102
|
+
if (sc.stop) bad(`the score already stops on line ${sc.stop.line}`)
|
|
1103
|
+
if (rest[0]?.text !== 'at' || rest.length !== 2) bad('stop wants at <time>, from its section\'s start')
|
|
1104
|
+
const at = time('stop', rest[1])
|
|
1105
|
+
if (at > sec.seconds) bad(`stop at ${+at.toFixed(3)} s is past section ${sec.name}'s ${+sec.seconds.toFixed(3)} s`)
|
|
1106
|
+
sc.stop = { line: n, at: +(sec.at + at).toFixed(6) }
|
|
1107
|
+
continue
|
|
1108
|
+
}
|
|
1109
|
+
if (head.text === 'defaults') {
|
|
1110
|
+
const sec = current ?? bad('defaults are the film words of a section: write it under one')
|
|
1111
|
+
if (sec.defaults) bad(`section ${sec.name} already has its defaults`)
|
|
1112
|
+
if (rest.length === 0) bad('defaults wants film words: defaults <word> <value> …')
|
|
1113
|
+
sec.defaults = rest.map((w) => ({ ...w, line: n }))
|
|
1114
|
+
continue
|
|
1115
|
+
}
|
|
566
1116
|
if (head.text === 'mark') {
|
|
567
1117
|
const sec = current ?? bad('a mark is a moment of a section: write it under one')
|
|
568
1118
|
const name = word('mark', rest[0], new RegExp(`^${MOMENT}$`))
|
|
@@ -573,13 +1123,28 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
573
1123
|
sec.marks.push({ line: n, name, at })
|
|
574
1124
|
continue
|
|
575
1125
|
}
|
|
1126
|
+
if (head.text === 'footage') {
|
|
1127
|
+
// a block filmed like a shot and never cut in: only a grid or a split reads it
|
|
1128
|
+
const open: Shot = { line: n, name: rest[0]?.text ?? '', length: 0, film: null, titles: [], sfx: [], voices: [], cut: { line: n, kind: 'hard', seconds: 0 }, from: 0, push: null, frame: null, shakes: [], flashes: [], music: null, broken: true, offcut: true }
|
|
1129
|
+
shot = open
|
|
1130
|
+
t.footage.push(open)
|
|
1131
|
+
const name = word('footage', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
1132
|
+
if ([...t.shots, ...t.footage].some((s) => s !== open && s.name === name)) bad(`footage ${name}: ${name} is already a shot or footage of this file`)
|
|
1133
|
+
const length = time(`footage ${name}`, rest[1])
|
|
1134
|
+
if (length <= 0) bad(`footage ${name}: its length must be above 0`)
|
|
1135
|
+
if (rest.length > 2) bad(`footage ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a footage line is footage <name> <length>)`)
|
|
1136
|
+
open.name = name
|
|
1137
|
+
open.length = length
|
|
1138
|
+
delete open.broken
|
|
1139
|
+
continue
|
|
1140
|
+
}
|
|
576
1141
|
if (head.text === 'shot') {
|
|
577
1142
|
// a bad shot line still opens a shot, so its own lines never read as the header's or the shot before's
|
|
578
1143
|
const open: Shot = { line: n, name: rest[0]?.text ?? '', length: 0, film: null, titles: [], sfx: [], voices: [], cut: { line: n, kind: 'hard', seconds: 0 }, from: 0, push: null, frame: null, shakes: [], flashes: [], music: null, broken: true }
|
|
579
1144
|
shot = open
|
|
580
1145
|
t.shots.push(open)
|
|
581
1146
|
const name = word('shot', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
582
|
-
if (t.shots.some((s) => s !== open && s.name === name)) bad(`shot ${name} is already a shot of this file`)
|
|
1147
|
+
if ([...t.shots, ...t.footage].some((s) => s !== open && s.name === name)) bad(`shot ${name} is already a shot or footage of this file`)
|
|
583
1148
|
const length = time(`shot ${name}`, rest[1])
|
|
584
1149
|
if (length <= 0) bad(`shot ${name}: its length must be above 0`)
|
|
585
1150
|
if (rest.length > 2) bad(`shot ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a shot line is shot <name> <length>)`)
|
|
@@ -612,11 +1177,12 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
612
1177
|
t.fps = num('fps', rest[0], 1, FPS_MAX)
|
|
613
1178
|
if (!Number.isInteger(t.fps)) bad(`fps wants a whole number, got ${rest[0]?.text}`)
|
|
614
1179
|
break
|
|
615
|
-
case 'music':
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
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
|
|
619
1184
|
break
|
|
1185
|
+
}
|
|
620
1186
|
case 'duck': {
|
|
621
1187
|
once('duck')
|
|
622
1188
|
const drop = (w: Word | undefined, what: string): number => {
|
|
@@ -653,65 +1219,154 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
653
1219
|
t.vertical = { line: n, outro: (p.outro as Vertical['outro'] | undefined) ?? 'card', ...(p.mark ? { mark: p.mark as 'left' | 'right' } : {}) }
|
|
654
1220
|
break
|
|
655
1221
|
}
|
|
1222
|
+
case 'preset': {
|
|
1223
|
+
const name = word('preset', rest[0], /^[a-z][a-z0-9-]*$/)
|
|
1224
|
+
if (presets.has(name)) bad(`preset ${name} is already a preset of this file`)
|
|
1225
|
+
const words = rest.slice(1)[0]?.text === '=' && !rest[1]!.quoted ? rest.slice(2) : rest.slice(1)
|
|
1226
|
+
if (words.length === 0) bad(`preset ${name} wants film words: preset ${name} <word> <value> …`)
|
|
1227
|
+
presets.set(name, words.map((w) => ({ ...w, line: n })))
|
|
1228
|
+
break
|
|
1229
|
+
}
|
|
656
1230
|
case 'base':
|
|
657
1231
|
once('base')
|
|
658
1232
|
t.base = word('base', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
659
1233
|
if (rest.length > 1) bad(`base ${t.base}: unknown word ${rest[1]?.text} (base <the id of a global process of this repo>)`)
|
|
660
1234
|
break
|
|
1235
|
+
case 'score': {
|
|
1236
|
+
once('score')
|
|
1237
|
+
if (t.sections.length > 0) bad('score opens the music the sections play: write it before the first section')
|
|
1238
|
+
const sc: Score = { line: n, clock: { bpm: 120, meter: 4, grid: 4 }, fadeIn: 0, volume: 1, tracks: [], drums: {}, code: pre.code }
|
|
1239
|
+
// reverb takes two values, so the words are read here rather than by pairs
|
|
1240
|
+
const p: Record<string, number> = {}
|
|
1241
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1242
|
+
const k = rest[i]!.text
|
|
1243
|
+
if (rest[i]!.quoted || !['tempo', 'meter', 'grid', 'reverb', 'fade-in', 'volume'].includes(k))
|
|
1244
|
+
bad(`score: unknown word ${k} (score takes tempo, meter, grid, reverb, fade-in, volume)`)
|
|
1245
|
+
if (k in p) bad(`score: ${k} given twice`)
|
|
1246
|
+
if (k === 'reverb') {
|
|
1247
|
+
sc.reverb = { seconds: num('score reverb <seconds> <mix>', rest[i + 1], 0.1, 10), mix: num('score reverb <seconds> <mix>', rest[i + 2], 0, 1) }
|
|
1248
|
+
p[k] = 1
|
|
1249
|
+
i += 2
|
|
1250
|
+
} else if (k === 'fade-in') p[k] = time('score fade-in', rest[++i])
|
|
1251
|
+
else p[k] = num(`score ${k}`, rest[++i], k === 'tempo' ? 20 : k === 'volume' ? 0 : 1, k === 'tempo' ? 400 : k === 'grid' ? 32 : k === 'volume' ? 4 : 16)
|
|
1252
|
+
}
|
|
1253
|
+
if (p.tempo === undefined) bad('score wants its tempo: score tempo <bpm>')
|
|
1254
|
+
sc.clock = { bpm: p.tempo!, meter: p.meter ?? 4, grid: p.grid ?? 4 }
|
|
1255
|
+
sc.fadeIn = p['fade-in'] ?? 0
|
|
1256
|
+
sc.volume = p.volume ?? 1
|
|
1257
|
+
t.score = sc
|
|
1258
|
+
clock = sc.clock
|
|
1259
|
+
break
|
|
1260
|
+
}
|
|
1261
|
+
case 'track': {
|
|
1262
|
+
const sc = t.score ?? bad('a track is a voice of the score: write score tempo <bpm> first')
|
|
1263
|
+
const name = word('track', rest[0], /^[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-]+)?$/)
|
|
1265
|
+
if (sc.tracks.some((x) => x.name === name)) bad(`track ${name} is already a track of the score`)
|
|
1266
|
+
if (sc.tracks.length >= TRACKS_MAX) bad(`track ${name}: the score holds ${TRACKS_MAX} tracks at most`)
|
|
1267
|
+
const p = pairs(`track ${name}`, rest.slice(2), { volume })
|
|
1268
|
+
sc.tracks.push({ line: n, name, instrument, volume: (p.volume as number) ?? 1 })
|
|
1269
|
+
break
|
|
1270
|
+
}
|
|
661
1271
|
default:
|
|
662
|
-
bad(`unknown word ${head.text} before the first shot (trailer, size, fps, music, duck, fade-in, vertical, base, section, shot)`)
|
|
1272
|
+
bad(`unknown word ${head.text} before the first shot (trailer, size, fps, music, duck, fade-in, vertical, base, preset, score, track, drums, section, defaults, shot)`)
|
|
663
1273
|
}
|
|
664
1274
|
continue
|
|
665
1275
|
}
|
|
666
1276
|
const s: Shot = shot
|
|
1277
|
+
if (s.offcut && !['film', 'from', 'frame'].includes(head.text)) bad(`footage ${s.name} is never cut in: it takes film, from and frame, never ${head.text}`)
|
|
667
1278
|
switch (head.text) {
|
|
668
1279
|
case 'film':
|
|
669
1280
|
if (s.film) bad(`shot ${s.name} already films on line ${s.film.line}: one film line a shot`)
|
|
670
|
-
|
|
1281
|
+
{
|
|
1282
|
+
// its option lines, each word keeping its own line
|
|
1283
|
+
const more: Word[] = []
|
|
1284
|
+
for (const o of options.get(n) ?? []) {
|
|
1285
|
+
const tk = tokens(o.text)
|
|
1286
|
+
if ('error' in tk) errors.push({ line: o.line, message: tk.error })
|
|
1287
|
+
else more.push(...tk.words.map((w) => ({ ...w, line: o.line })))
|
|
1288
|
+
}
|
|
1289
|
+
const f = (s.film = filmLine([...rest.map((w) => ({ ...w, line: n })), ...more], n, verbs, {
|
|
1290
|
+
defaults: s.offcut ? [] : (current?.defaults ?? []),
|
|
1291
|
+
presets,
|
|
1292
|
+
time: momentWord,
|
|
1293
|
+
later: (film, slot) => slots.push({ shot: s, film, slot }),
|
|
1294
|
+
...(o.pageWords ? { pageWords: o.pageWords } : {}),
|
|
1295
|
+
errors,
|
|
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
|
+
}
|
|
1306
|
+
}
|
|
671
1307
|
break
|
|
672
1308
|
case 'title': {
|
|
673
1309
|
const text = quoted('title', rest[0])
|
|
674
|
-
const p =
|
|
675
|
-
|
|
676
|
-
for: at,
|
|
677
|
-
style: (w, k) => {
|
|
678
|
-
const st = word(k, w)
|
|
679
|
-
return (TITLE_STYLES as readonly string[]).includes(st) ? st : bad(`${k} wants ${TITLE_STYLES.join('|')}, got ${st}`)
|
|
680
|
-
},
|
|
681
|
-
place: (w, k) => {
|
|
682
|
-
const pl = word(k, w)
|
|
683
|
-
return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
|
|
684
|
-
},
|
|
685
|
-
knock: at,
|
|
686
|
-
})
|
|
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-]*$/)
|
|
687
1312
|
const ti: Title = {
|
|
688
1313
|
line: n,
|
|
689
1314
|
text,
|
|
690
1315
|
at: 0,
|
|
691
1316
|
for: (p.for as number) ?? null,
|
|
692
|
-
|
|
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 }),
|
|
693
1320
|
...(p.place ? { place: p.place as 'top' | 'bottom' } : {}),
|
|
694
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 } : {}),
|
|
695
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')
|
|
696
1328
|
ti.at = landAt(p.at, n, s, (v) => (ti.at = v))
|
|
697
1329
|
if (ti.knock !== undefined && (ti.knock <= ti.at || (ti.for !== null && ti.knock >= ti.at + ti.for)))
|
|
698
1330
|
bad(`title knock ${ti.knock}s: inside the title's own span, after its at`)
|
|
699
1331
|
s.titles.push(ti)
|
|
700
1332
|
break
|
|
701
1333
|
}
|
|
1334
|
+
case 'progress': {
|
|
1335
|
+
const names = quoted('progress', rest[0]).split('|').map((x) => x.trim())
|
|
1336
|
+
if (names.length < 2 || names.some((x) => x === '')) bad('progress wants two or more step names, "<a>|<b>|…"')
|
|
1337
|
+
const p = pairs('progress', rest.slice(1), {
|
|
1338
|
+
at: when,
|
|
1339
|
+
every: at,
|
|
1340
|
+
place: (w, k) => {
|
|
1341
|
+
const pl = word(k, w)
|
|
1342
|
+
return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
|
|
1343
|
+
},
|
|
1344
|
+
})
|
|
1345
|
+
const every = (p.every as number | undefined) ?? bad('progress wants every <time>: how long each step stands')
|
|
1346
|
+
names.forEach((text, k) => {
|
|
1347
|
+
const ti: Title = { line: n, text, at: 0, for: every, style: 'progress', step: { i: k + 1, n: names.length }, ...(p.place ? { place: p.place as 'top' | 'bottom' } : {}) }
|
|
1348
|
+
ti.at = landAt(p.at, n, s, (v) => (ti.at = v + k * every)) + k * every
|
|
1349
|
+
s.titles.push(ti)
|
|
1350
|
+
})
|
|
1351
|
+
break
|
|
1352
|
+
}
|
|
702
1353
|
case 'sfx': {
|
|
703
|
-
const
|
|
704
|
-
const
|
|
705
|
-
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 }
|
|
706
1360
|
x.at = landAt(p.at, n, s, (v) => (x.at = v))
|
|
707
1361
|
s.sfx.push(x)
|
|
708
1362
|
break
|
|
709
1363
|
}
|
|
710
1364
|
case 'voice': {
|
|
711
|
-
const
|
|
712
|
-
const
|
|
713
|
-
const
|
|
714
|
-
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 }
|
|
715
1370
|
v.at = landAt(p.at, n, s, (x) => (v.at = x))
|
|
716
1371
|
s.voices.push(v)
|
|
717
1372
|
break
|
|
@@ -720,16 +1375,18 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
720
1375
|
if (s.cut.line !== s.line) bad(`shot ${s.name} already cuts on line ${s.cut.line}`)
|
|
721
1376
|
const k = word('cut', rest[0])
|
|
722
1377
|
if (!(CUT_KINDS as readonly string[]).includes(k)) bad(`cut: unknown kind ${k} (${CUT_KINDS.join(', ')})`)
|
|
723
|
-
const seconds = k === 'hard' ? 0 : rest[1] ? time(`cut ${k}`, rest[1]) : DEFAULT_CUT_S
|
|
1378
|
+
const seconds = k === 'hard' ? 0 : rest[1] ? time(`cut ${k}`, rest[1]) : (CUT_SECONDS[k as CutKind] ?? DEFAULT_CUT_S)
|
|
724
1379
|
if (k === 'hard' && rest[1]) bad('cut hard takes no time')
|
|
725
1380
|
if (rest.length > 2) bad(`cut ${k}: unknown word ${rest[2]?.text}`)
|
|
726
1381
|
s.cut = { line: n, kind: k as CutKind, seconds }
|
|
727
1382
|
break
|
|
728
1383
|
}
|
|
729
|
-
case 'music':
|
|
730
|
-
if (
|
|
731
|
-
|
|
1384
|
+
case 'music': {
|
|
1385
|
+
if (t.score && t.sections.length > 0) bad('music: the score plays from its first section on; a song plays only before it')
|
|
1386
|
+
const m = musicLine(rest, n)
|
|
1387
|
+
if (mine(m, s.music, `shot ${s.name}`)) s.music = m
|
|
732
1388
|
break
|
|
1389
|
+
}
|
|
733
1390
|
case 'starts': {
|
|
734
1391
|
if (s.lands) bad(`shot ${s.name} already lands on line ${s.lands.line}`)
|
|
735
1392
|
if (rest[0]?.text !== 'on' || !rest[1] || rest.length > 3) bad('starts wants on <section|mark> [+|-<time>]')
|
|
@@ -777,7 +1434,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
777
1434
|
break
|
|
778
1435
|
}
|
|
779
1436
|
default:
|
|
780
|
-
bad(`unknown word ${head.text} in shot ${s.name} (film, title, sfx, voice, music, starts, cut, from, push, frame, lift, shake, flash, shot)`)
|
|
1437
|
+
bad(`unknown word ${head.text} in shot ${s.name} (film, title, progress, sfx, voice, music, starts, cut, from, push, frame, lift, shake, flash, shot)`)
|
|
781
1438
|
}
|
|
782
1439
|
} catch (e) {
|
|
783
1440
|
if (!(e instanceof LineError)) throw e
|
|
@@ -786,19 +1443,60 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
786
1443
|
}
|
|
787
1444
|
}
|
|
788
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}`
|
|
789
1449
|
if (t.shots.length === 0) errors.push({ line: lines.length, message: 'no shot: a trailer has at least one shot <name> <length>' })
|
|
790
1450
|
if (t.vertical && !seen.has('size')) t.size = [...VERTICAL_SIZE]
|
|
791
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')}` })
|
|
792
1452
|
clock = null
|
|
1453
|
+
for (const pl of pre.plays) {
|
|
1454
|
+
const sec = t.sections[pl.section]
|
|
1455
|
+
if (!sec) continue
|
|
1456
|
+
if (!t.score) errors.push({ line: pl.line, message: `play ${pl.track}: a play is the score's: write score tempo <bpm> in the header` })
|
|
1457
|
+
else if (pl.track !== '*' && !t.score.tracks.some((x) => x.name === pl.track))
|
|
1458
|
+
errors.push({ line: pl.line, message: `play ${pl.track}: no track ${pl.track} (${t.score.tracks.map((x) => x.name).join(', ') || 'the score has no track: track <name> <instrument>'})` })
|
|
1459
|
+
else if (sec.plays.some((x) => x.track === pl.track)) errors.push({ line: pl.line, message: `play ${pl.track}: section ${sec.name} already plays it` })
|
|
1460
|
+
else sec.plays.push({ line: pl.line, track: pl.track, expr: pl.expr })
|
|
1461
|
+
}
|
|
1462
|
+
if (pre.code && !t.score) errors.push({ line: pre.code.line, message: 'code is the score\'s: write score tempo <bpm> in the header' })
|
|
1463
|
+
if (t.score && t.sections.length === 0) errors.push({ line: t.score.line, message: 'the score has no section: section <name> <length> under the header, its plays under it' })
|
|
793
1464
|
errors.push(...landShots(t))
|
|
1465
|
+
if (t.score && t.sections.length > 0 && t.timelineAt === null && !t.shots.some((s) => s.broken))
|
|
1466
|
+
errors.push({ line: t.score.line, message: 'no shot lands on a section, so the score never plays: put a shot under a section, or starts on <section>' })
|
|
794
1467
|
for (const r of refs) {
|
|
795
1468
|
const i = t.shots.indexOf(r.shot)
|
|
796
1469
|
const m = momentOf(t, r.ref.mark)
|
|
797
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` })
|
|
798
1471
|
else r.set(+(t.timelineAt + m + r.ref.offset - (starts(t)[i] ?? 0)).toFixed(4))
|
|
799
1472
|
}
|
|
800
|
-
|
|
801
|
-
|
|
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
|
|
1475
|
+
for (const p of slots) {
|
|
1476
|
+
const i = t.shots.indexOf(p.shot)
|
|
1477
|
+
const parts = p.slot.parts.map((x) => {
|
|
1478
|
+
if (typeof x.at === 'number') return { name: x.name, seconds: x.at + p.shot.from }
|
|
1479
|
+
const m = momentOf(t, x.at.mark)
|
|
1480
|
+
if (i < 0) errors.push({ line: x.line, message: `film: ${x.at.mark} is a moment of the cut, and footage ${p.shot.name} is never cut in: write seconds` })
|
|
1481
|
+
else if (m === null || t.timelineAt === null) errors.push({ line: x.line, message: `film: no section or mark ${x.at.mark} in this file` })
|
|
1482
|
+
else return { name: x.name, seconds: +(t.timelineAt + m + x.at.offset - (starts(t)[i] ?? 0) + p.shot.from).toFixed(4) }
|
|
1483
|
+
return { name: x.name, seconds: 0 }
|
|
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
|
+
})
|
|
1492
|
+
p.film.argv[p.slot.index] = slotText(parts)
|
|
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` })
|
|
1495
|
+
errors.push(...structure(t), ...grids(t))
|
|
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) }
|
|
802
1500
|
}
|
|
803
1501
|
|
|
804
1502
|
/** the least a fitted shot keeps past its cut out */
|
|
@@ -853,7 +1551,7 @@ function landShots(t: Trailer): TrailerError[] {
|
|
|
853
1551
|
|
|
854
1552
|
/** a film that is one picture held for the shot */
|
|
855
1553
|
export const isStill = (f: Film | null): boolean =>
|
|
856
|
-
f?.kind === 'card' || f?.kind === 'image' || ((f?.kind === 'run' || f?.kind === 'url') && f.still === true) || (f?.kind === 'cli' && f.output === 'still')
|
|
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')
|
|
857
1555
|
|
|
858
1556
|
/** what the lines say together: a film per shot, titles inside their shot, cuts shorter than what they join */
|
|
859
1557
|
function structure(t: Trailer): TrailerError[] {
|
|
@@ -861,11 +1559,13 @@ function structure(t: Trailer): TrailerError[] {
|
|
|
861
1559
|
t.shots.forEach((s, i) => {
|
|
862
1560
|
if (s.broken) return
|
|
863
1561
|
if (!s.film) out.push({ line: s.line, message: `shot ${s.name} films nothing: give it a film line` })
|
|
864
|
-
if (isStill(s.film) && s.from > 0) out.push({ line: s.line, message: `shot ${s.name}: its film is a still image, no footage to skip (from)` })
|
|
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)` })
|
|
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` })
|
|
865
1564
|
// a title may run on over the shots after its own (a caption over a montage), never past the cut's end
|
|
866
1565
|
const left = total(t) - (starts(t)[i] ?? 0)
|
|
867
1566
|
for (const ti of s.titles) {
|
|
868
|
-
|
|
1567
|
+
// a progress line's later steps run on over the next shots, as a `for` past its shot does
|
|
1568
|
+
if (ti.at >= s.length && !(ti.step && ti.step.i > 1)) out.push({ line: ti.line, message: `title at ${ti.at} s starts past shot ${s.name}'s ${s.length} s` })
|
|
869
1569
|
else if (ti.for !== null && ti.at + ti.for > left + 1e-9) out.push({ line: ti.line, message: `title ends ${+(ti.at + ti.for - left).toFixed(2)} s past the cut's end` })
|
|
870
1570
|
}
|
|
871
1571
|
for (const x of [...s.sfx, ...s.voices]) if (x.at >= s.length) out.push({ line: x.line, message: `${'id' in x ? 'sfx' : 'voice'} at ${x.at} s starts past shot ${s.name}'s ${s.length} s` })
|
|
@@ -885,6 +1585,38 @@ function structure(t: Trailer): TrailerError[] {
|
|
|
885
1585
|
return out
|
|
886
1586
|
}
|
|
887
1587
|
|
|
1588
|
+
/** the shots and footage blocks a grid's or a split's cells name */
|
|
1589
|
+
export function gridCells(t: Pick<Trailer, 'shots' | 'footage'>, cells: readonly string[]): (Shot | undefined)[] {
|
|
1590
|
+
return cells.map((c) => [...t.footage, ...t.shots].find((s) => s.name === c))
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/** a cell plays filmed footage: a take that is a clip, never a grid, a split, a card, an image or a still */
|
|
1594
|
+
export const isCellFilm = (f: Film | null): boolean => (f?.kind === 'run' || f?.kind === 'url' || f?.kind === 'page' || f?.kind === 'cli') && !isStill(f)
|
|
1595
|
+
|
|
1596
|
+
/** what a grid or a split names, and what a footage block films */
|
|
1597
|
+
function grids(t: Trailer): TrailerError[] {
|
|
1598
|
+
const out: TrailerError[] = []
|
|
1599
|
+
for (const s of [...t.shots, ...t.footage]) {
|
|
1600
|
+
if (s.broken || (s.film?.kind !== 'grid' && s.film?.kind !== 'split')) continue
|
|
1601
|
+
const f = s.film
|
|
1602
|
+
if (s.offcut) {
|
|
1603
|
+
out.push({ line: f.line, message: `footage ${s.name}: a ${f.kind} is laid by the stitch, never a footage block` })
|
|
1604
|
+
continue
|
|
1605
|
+
}
|
|
1606
|
+
gridCells(t, f.cells).forEach((c, i) => {
|
|
1607
|
+
const name = f.cells[i]
|
|
1608
|
+
if (!c) out.push({ line: f.line, message: `film ${f.kind}: no shot or footage ${name} in this file` })
|
|
1609
|
+
else if (c.film && !isCellFilm(c.film)) out.push({ line: f.line, message: `film ${f.kind}: ${name} is a ${c.film.kind === 'cli' || c.film.kind === 'run' || c.film.kind === 'url' ? 'still' : c.film.kind}, a cell plays filmed footage` })
|
|
1610
|
+
})
|
|
1611
|
+
}
|
|
1612
|
+
for (const s of t.footage) {
|
|
1613
|
+
if (s.broken) continue
|
|
1614
|
+
if (!s.film) out.push({ line: s.line, message: `footage ${s.name} films nothing: give it a film line` })
|
|
1615
|
+
else if (s.film.kind !== 'grid' && s.film.kind !== 'split' && !isCellFilm(s.film)) out.push({ line: s.film.line, message: `footage ${s.name}: a ${s.film.kind === 'cli' || s.film.kind === 'run' || s.film.kind === 'url' ? 'still' : s.film.kind} is no footage a grid plays` })
|
|
1616
|
+
}
|
|
1617
|
+
return out
|
|
1618
|
+
}
|
|
1619
|
+
|
|
888
1620
|
/** each shot's start on the cut: a cut overlaps the two shots it joins */
|
|
889
1621
|
export function starts(t: Pick<Trailer, 'shots'>): number[] {
|
|
890
1622
|
const out: number[] = []
|
|
@@ -905,9 +1637,14 @@ export function total(t: Pick<Trailer, 'shots'>): number {
|
|
|
905
1637
|
|
|
906
1638
|
/** the music of the cut, cue by cue: each its start on the cut and its end, a crossfade into the next over the next's
|
|
907
1639
|
* fade-in; a `none` cue is silence and carries no song */
|
|
908
|
-
export function musicCues(t: Pick<Trailer, 'music' | 'shots'>): { cue: Music; at: number; end: number }[] {
|
|
1640
|
+
export function musicCues(t: Pick<Trailer, 'music' | 'shots' | 'score' | 'timelineAt'>): { cue: Music; at: number; end: number }[] {
|
|
909
1641
|
const st = starts(t)
|
|
910
|
-
const
|
|
1642
|
+
const sc = t.score
|
|
1643
|
+
const raw: { cue: Music; at: number }[] = [
|
|
1644
|
+
...(t.music ? [{ cue: t.music, at: 0 }] : []),
|
|
1645
|
+
...t.shots.flatMap((s, i) => (s.music ? [{ cue: s.music, at: st[i] ?? 0 }] : [])),
|
|
1646
|
+
...(sc && t.timelineAt !== null ? [{ cue: { line: sc.line, song: SCORE, from: SCORE_LEAD, fadeIn: sc.fadeIn, fadeOut: 0, volume: sc.volume }, at: t.timelineAt }] : []),
|
|
1647
|
+
].sort((a, b) => a.at - b.at)
|
|
911
1648
|
const T = total(t)
|
|
912
1649
|
return raw
|
|
913
1650
|
.map((r, i) => ({ ...r, end: Math.min(T, (raw[i + 1]?.at ?? T) + (raw[i + 1]?.cue.fadeIn ?? 0)) }))
|