rvlib-shipkit 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -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/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -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/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -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 +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -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 +165 -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/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- 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 +45 -13
- package/src/serve/hub-app/icons.tsx +9 -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/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -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 +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -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 +329 -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-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- package/src/trailer/words.ts +254 -0
package/src/trailer/grammar.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { type LineWord, type Moment, slotText, type Typed, typedFilm, type Words } from './words.ts'
|
|
2
|
+
|
|
1
3
|
// the trailer language, its core words: one text file → the shots, their films, titles, sounds and cuts. Pure: no
|
|
2
4
|
// file, no process; a film word that is not a core kind is looked up in the project CLI's film rows the caller
|
|
3
5
|
// passes in. Every error carries its line, all at once, and a file with one error never builds
|
|
4
6
|
|
|
5
|
-
export type TitleStyle = 'logo' | 'big' | 'caption' | 'corner' | 'line'
|
|
6
|
-
|
|
7
|
+
export type TitleStyle = 'logo' | 'big' | 'caption' | 'corner' | 'line' | 'short' | 'progress'
|
|
8
|
+
/** the styles a title line names; `progress` is its own line's */
|
|
9
|
+
export const TITLE_STYLES: readonly TitleStyle[] = ['logo', 'big', 'caption', 'corner', 'line', 'short']
|
|
7
10
|
export const CUT_KINDS = [
|
|
8
11
|
'hard',
|
|
9
12
|
'fade',
|
|
@@ -23,30 +26,71 @@ export const CUT_KINDS = [
|
|
|
23
26
|
'pixel',
|
|
24
27
|
] as const
|
|
25
28
|
export type CutKind = (typeof CUT_KINDS)[number]
|
|
26
|
-
export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url'] as const
|
|
29
|
+
export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url', 'grid', 'split'] as const
|
|
27
30
|
|
|
28
31
|
/** the most frames a second a cut runs at */
|
|
29
32
|
export const FPS_MAX = 60
|
|
30
33
|
const DEFAULT_CUT_S = 0.5
|
|
31
34
|
|
|
35
|
+
/** how long a take films: what the shot shows rounded up to 2 s (`steps`, the default), exactly that (`exact`), or as
|
|
36
|
+
* long as it plays (`own`: no --seconds, the key holds none) */
|
|
37
|
+
export type TakeLength = 'steps' | 'exact' | 'own'
|
|
38
|
+
|
|
32
39
|
/** a film row of the project CLI: a still writes one image at `--out`, a clip a video of `--seconds` */
|
|
33
40
|
export interface FilmVerb {
|
|
34
41
|
path: string[]
|
|
35
42
|
film: 'still' | 'clip'
|
|
43
|
+
/** the words the row takes and their types (words.ts): every word checked at its line, values handed plain */
|
|
44
|
+
words?: Words
|
|
45
|
+
/** the paths the row's takes film: their world, over the unit's `stamp` */
|
|
46
|
+
stamp?: string[]
|
|
47
|
+
/** the row's take version: a bump films every take of the row again */
|
|
48
|
+
version?: number | string
|
|
49
|
+
/** `own`: the row films as long as it plays */
|
|
50
|
+
length?: 'own'
|
|
36
51
|
description?: string
|
|
37
52
|
}
|
|
38
53
|
|
|
54
|
+
/** a grid holds at most this many cells, 3 by 3 */
|
|
55
|
+
export const GRID_MAX = 9
|
|
56
|
+
/** a growing grid eases out over this long before each settle */
|
|
57
|
+
export const GROW_S = 1
|
|
58
|
+
/** a split's seams lean this far from upright by default: two panes top left and bottom right, three slanted columns */
|
|
59
|
+
export const SPLIT_ANGLE: Readonly<Record<number, number>> = { 2: 56, 3: 14 }
|
|
60
|
+
/** a split's panes slide in this long by default */
|
|
61
|
+
export const SPLIT_SLIDE = 0.3
|
|
62
|
+
|
|
39
63
|
export type Film =
|
|
40
64
|
| { kind: 'clip'; line: number; path: string }
|
|
41
65
|
| { kind: 'image'; line: number; path: string }
|
|
42
66
|
| { kind: 'card'; line: number }
|
|
43
|
-
/** any command that writes the take at `{out}`; `still`: it writes an image, held for the shot
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
/** any command that writes the take at `{out}`; `still`: it writes an image, held for the shot; `reads`: the files
|
|
68
|
+
* the command reads, their content part of the take's key */
|
|
69
|
+
| { kind: 'run'; line: number; cmd: string; still?: true; reads?: string[] }
|
|
70
|
+
/** a film row of the project CLI, its words as written: `<cli> <argv> --out <file> [--seconds <n>]`; its row's take
|
|
71
|
+
* version and length when the row has them */
|
|
72
|
+
| {
|
|
73
|
+
kind: 'cli'
|
|
74
|
+
line: number
|
|
75
|
+
argv: string[]
|
|
76
|
+
output: 'still' | 'clip'
|
|
77
|
+
version?: number | string
|
|
78
|
+
length?: 'own'
|
|
79
|
+
/** the row declares its words: argv holds them plain */
|
|
80
|
+
typed?: true
|
|
81
|
+
/** argv slots that are files as written: the build finds them and keys their content */
|
|
82
|
+
files?: number[]
|
|
83
|
+
/** the paths the row's takes film */
|
|
84
|
+
stamp?: string[]
|
|
85
|
+
}
|
|
47
86
|
/** a page of the repo's dev url (a route) or any http url, through the house shot service; `viewport` its css
|
|
48
87
|
* size, `wait` before it films; `still`: a png, else a clip of the page's largest canvas */
|
|
49
88
|
| { kind: 'url'; line: number; route: string; viewport?: [number, number]; wait?: number; still?: true }
|
|
89
|
+
/** other shots' or footage blocks' takes tiled, each cell playing live; `pop`: cell i pops in at i times it; `grow`:
|
|
90
|
+
* nine cells growing out of the first, settled on the 2 by 2 block then the whole at these seconds */
|
|
91
|
+
| { kind: 'grid'; line: number; cells: string[]; pop: number; grow?: [number, number] }
|
|
92
|
+
/** two or three takes side by side, cut by slanted seams `angle` degrees from upright, each pane sliding in */
|
|
93
|
+
| { kind: 'split'; line: number; cells: string[]; angle: number; slide: number }
|
|
50
94
|
|
|
51
95
|
/** the vertical short: a lockup over every frame, a watermark in a bottom corner, then the outro and the end card,
|
|
52
96
|
* each drawn from the brand's `trailer.vertical` data (vertical.ts) */
|
|
@@ -72,6 +116,8 @@ export interface Title {
|
|
|
72
116
|
place?: 'top' | 'bottom'
|
|
73
117
|
/** knocked off the frame from this second of its shot: it tips and flies out */
|
|
74
118
|
knock?: number
|
|
119
|
+
/** a `progress` line's step: the i-th of n notches filled under its name */
|
|
120
|
+
step?: { i: number; n: number }
|
|
75
121
|
}
|
|
76
122
|
|
|
77
123
|
export interface Sfx {
|
|
@@ -159,6 +205,8 @@ export interface Shot {
|
|
|
159
205
|
music: Music | null
|
|
160
206
|
/** its own line failed: nothing else is checked against it */
|
|
161
207
|
broken?: true
|
|
208
|
+
/** a `footage` block: filmed, never cut in */
|
|
209
|
+
offcut?: true
|
|
162
210
|
}
|
|
163
211
|
|
|
164
212
|
/** the tempo a line counts bars and beats in */
|
|
@@ -177,7 +225,8 @@ export interface Mark {
|
|
|
177
225
|
at: number
|
|
178
226
|
}
|
|
179
227
|
|
|
180
|
-
/** a part of the timeline in bars: its shots land on its start, and the stitch caches it as one piece
|
|
228
|
+
/** a part of the timeline in bars: its shots land on its start, and the stitch caches it as one piece. Under a
|
|
229
|
+
* score, it is also a part of the music: what each track plays over it */
|
|
181
230
|
export interface Section {
|
|
182
231
|
line: number
|
|
183
232
|
name: string
|
|
@@ -186,10 +235,48 @@ export interface Section {
|
|
|
186
235
|
at: number
|
|
187
236
|
seconds: number
|
|
188
237
|
marks: Mark[]
|
|
238
|
+
/** `defaults <words>`: what every declared film line of the section starts from */
|
|
239
|
+
defaults?: Word[]
|
|
240
|
+
/** its drum letters over the score's */
|
|
241
|
+
drums: Record<string, string>
|
|
242
|
+
plays: Play[]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `play <track> <expression>`: what a track plays over its section, TypeScript; `*` a record of tracks */
|
|
246
|
+
export interface Play {
|
|
247
|
+
line: number
|
|
248
|
+
track: string
|
|
249
|
+
expr: string
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** the cut's own music, written in the file (score/score.ts compiles it, score/synth.ts renders it) */
|
|
253
|
+
export interface Score {
|
|
254
|
+
line: number
|
|
255
|
+
/** the first section's clock: the score line's own */
|
|
256
|
+
clock: Clock
|
|
257
|
+
reverb?: { seconds: number; mix: number }
|
|
258
|
+
fadeIn: number
|
|
259
|
+
volume: number
|
|
260
|
+
tracks: { line: number; name: string; instrument: string; volume: number }[]
|
|
261
|
+
/** drum letter → instrument, every section */
|
|
262
|
+
drums: Record<string, string>
|
|
263
|
+
/** the `code` block, its lines at their own line numbers (the others blank) */
|
|
264
|
+
code: { line: number; text: string } | null
|
|
265
|
+
/** `stop at <time>`: the score and its room cut dead there, seconds from the first section's start */
|
|
266
|
+
stop?: { line: number; at: number }
|
|
189
267
|
}
|
|
190
268
|
|
|
269
|
+
/** the voices a score holds at most */
|
|
270
|
+
export const TRACKS_MAX = 16
|
|
271
|
+
/** the music a score starts with: the render's lead, so its first note is never cut */
|
|
272
|
+
export const SCORE_LEAD = 0.05
|
|
273
|
+
/** the score in the cut's music cues */
|
|
274
|
+
export const SCORE = '@score'
|
|
275
|
+
|
|
191
276
|
export interface Trailer {
|
|
192
277
|
name: string
|
|
278
|
+
/** `footage` blocks: filmed like shots, never cut in; a grid or a split reads them */
|
|
279
|
+
footage: Shot[]
|
|
193
280
|
size: [number, number]
|
|
194
281
|
fps: number
|
|
195
282
|
music: Music | null
|
|
@@ -200,7 +287,9 @@ export interface Trailer {
|
|
|
200
287
|
fadeIn: number
|
|
201
288
|
shots: Shot[]
|
|
202
289
|
sections: Section[]
|
|
203
|
-
/**
|
|
290
|
+
/** the cut's own music; null: none */
|
|
291
|
+
score: Score | null
|
|
292
|
+
/** where the timeline of the sections (and the score) starts on the cut: the first landed shot's start less its moment */
|
|
204
293
|
timelineAt: number | null
|
|
205
294
|
/** the cut is a vertical short (`vertical`), finished with the brand's lockup, watermark and end card */
|
|
206
295
|
vertical: Vertical | null
|
|
@@ -216,9 +305,12 @@ export interface TrailerError {
|
|
|
216
305
|
interface Word {
|
|
217
306
|
text: string
|
|
218
307
|
quoted: boolean
|
|
308
|
+
/** an option line's word keeps its own line */
|
|
309
|
+
line?: number
|
|
219
310
|
}
|
|
220
311
|
|
|
221
|
-
/** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line
|
|
312
|
+
/** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line. Only double quotes quote, as in
|
|
313
|
+
* the full language, so a word opening on a single quote is refused: it would reach a command with the quotes inside */
|
|
222
314
|
export function tokens(line: string): { words: Word[] } | { error: string } {
|
|
223
315
|
const words: Word[] = []
|
|
224
316
|
let i = 0
|
|
@@ -229,6 +321,11 @@ export function tokens(line: string): { words: Word[] } | { error: string } {
|
|
|
229
321
|
continue
|
|
230
322
|
}
|
|
231
323
|
if (c === '#') break
|
|
324
|
+
if (c === "'") {
|
|
325
|
+
const end = line.indexOf("'", i + 1)
|
|
326
|
+
const word = end < 0 ? line.slice(i).split(/[\s#]/)[0]! : line.slice(i, end + 1)
|
|
327
|
+
return { error: `${word} opens on a single quote: the language quotes with double quotes only, write "${word.slice(1, end < 0 ? undefined : -1)}"` }
|
|
328
|
+
}
|
|
232
329
|
if (c === '"') {
|
|
233
330
|
const end = line.indexOf('"', i + 1)
|
|
234
331
|
if (end < 0) return { error: 'a quote is not closed' }
|
|
@@ -356,7 +453,27 @@ const filmWords = (verbs: readonly FilmVerb[] | null): string =>
|
|
|
356
453
|
|
|
357
454
|
/** a film line: a core kind when its words fit that kind's form (`clip <video file>`), else a film row of the project
|
|
358
455
|
* CLI (its `clip <scene>` row), else the core kind's error */
|
|
359
|
-
|
|
456
|
+
/** what a film line of a declared row reads past its words: its section's defaults, the header's presets, a time */
|
|
457
|
+
interface FilmContext {
|
|
458
|
+
defaults: Word[]
|
|
459
|
+
presets: ReadonlyMap<string, Word[]>
|
|
460
|
+
time: (text: string) => Moment | null
|
|
461
|
+
/** a slot that waits on a moment, resolved once the shots have landed */
|
|
462
|
+
later: (f: Extract<Film, { kind: 'cli' }>, slot: Typed['times'][number]) => void
|
|
463
|
+
errors: TrailerError[]
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** a film line's words with every `+<preset>` put in place */
|
|
467
|
+
function expandPresets(ws: Word[], line: number, presets: ReadonlyMap<string, Word[]>): Word[] {
|
|
468
|
+
return ws.flatMap((w) => {
|
|
469
|
+
if (w.quoted || !/^\+[a-z][a-z0-9-]*$/.test(w.text)) return [w]
|
|
470
|
+
const p = presets.get(w.text.slice(1)) ?? bad(`film: no preset ${w.text.slice(1)} (preset <name> <words> in the header${presets.size ? `: ${[...presets.keys()].join(', ')}` : ''})`)
|
|
471
|
+
return p.map((x) => ({ ...x, line: w.line ?? line }))
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function filmLine(raw: Word[], line: number, verbs: readonly FilmVerb[] | null, c?: FilmContext): Film {
|
|
476
|
+
const ws = c ? expandPresets(raw, line, c.presets) : raw
|
|
360
477
|
const kind = ws[0] && !ws[0].quoted ? ws[0].text : ''
|
|
361
478
|
const argv = ws.map((w) => w.text)
|
|
362
479
|
const v = verbs && verbOf(argv, verbs)
|
|
@@ -369,7 +486,23 @@ function filmLine(ws: Word[], line: number, verbs: readonly FilmVerb[] | null):
|
|
|
369
486
|
}
|
|
370
487
|
if (!v) return bad(`film: unknown kind ${kind || '(none)'} (${filmWords(verbs)})`)
|
|
371
488
|
if (argv.some((a) => a === '--out' || a.startsWith('--out=') || a === '--seconds' || a.startsWith('--seconds='))) bad(`film ${v.path.join(' ')}: the trailer gives --out and --seconds itself`)
|
|
372
|
-
|
|
489
|
+
const marks = { ...(v.version !== undefined ? { version: v.version } : {}), ...(v.length ? { length: v.length } : {}), ...(v.stamp ? { stamp: v.stamp } : {}) }
|
|
490
|
+
if (!v.words || !c) return { kind: 'cli', line, argv, output: v.film, ...marks }
|
|
491
|
+
const at = (w: Word): LineWord => ({ text: w.text, quoted: w.quoted, line: w.line ?? line })
|
|
492
|
+
const typed = typedFilm(v.path, v.words, ws.slice(v.path.length).map(at), c.defaults.map(at), c.time)
|
|
493
|
+
c.errors.push(...typed.errors)
|
|
494
|
+
const f: Extract<Film, { kind: 'cli' }> = { kind: 'cli', line, argv: typed.argv, output: v.film, typed: true, ...(typed.files.length ? { files: typed.files } : {}), ...marks }
|
|
495
|
+
for (const slot of typed.times) c.later(f, slot)
|
|
496
|
+
return f
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** a list of `min` to `max` different shot or footage names */
|
|
500
|
+
function cellList(what: string, w: Word | undefined, min: number, max: number): string[] {
|
|
501
|
+
const list = word(what, w, /^[a-z0-9][a-z0-9-]*(?:,[a-z0-9][a-z0-9-]*)*$/)
|
|
502
|
+
const cells = list.split(',')
|
|
503
|
+
if (cells.length < min || cells.length > max) bad(`${what}: ${min} to ${max} cells, got ${cells.length}`)
|
|
504
|
+
if (new Set(cells).size !== cells.length) bad(`${what}: a cell named twice in ${list}`)
|
|
505
|
+
return cells
|
|
373
506
|
}
|
|
374
507
|
|
|
375
508
|
function coreFilm(kind: string, ws: Word[], line: number): Film {
|
|
@@ -392,8 +525,8 @@ function coreFilm(kind: string, ws: Word[], line: number): Film {
|
|
|
392
525
|
case 'run': {
|
|
393
526
|
const cmd = quoted('film run', ws[1])
|
|
394
527
|
if (!cmd.includes('{out}')) bad('film run wants {out} in its command: the file the command writes the take to')
|
|
395
|
-
const p = pairs('film run', ws.slice(2), {}, ['still'])
|
|
396
|
-
return { kind, line, cmd, ...(p.still ? { still: true as const } : {}) }
|
|
528
|
+
const p = pairs('film run', ws.slice(2), { reads: (w, k) => pathWord(k, w).split(',') }, ['still'])
|
|
529
|
+
return { kind, line, cmd, ...(p.still ? { still: true as const } : {}), ...(p.reads ? { reads: p.reads as string[] } : {}) }
|
|
397
530
|
}
|
|
398
531
|
case 'url': {
|
|
399
532
|
const route = pathWord('film url', ws[1])
|
|
@@ -408,6 +541,25 @@ function coreFilm(kind: string, ws: Word[], line: number): Film {
|
|
|
408
541
|
...(p.still ? { still: true as const } : {}),
|
|
409
542
|
}
|
|
410
543
|
}
|
|
544
|
+
case 'grid': {
|
|
545
|
+
const cells = cellList('film grid', ws[1], 1, GRID_MAX)
|
|
546
|
+
const p = pairs(`film grid ${cells.join(',')}`, ws.slice(2), {
|
|
547
|
+
pop: at,
|
|
548
|
+
grow: (w, k) => {
|
|
549
|
+
const t = word(k, w, /^\d+(?:\.\d+)?s,\d+(?:\.\d+)?s$/)
|
|
550
|
+
const v = t.split(',').map((x) => Number(x.slice(0, -1)))
|
|
551
|
+
return v[0]! >= GROW_S && v[1]! >= v[0]! + GROW_S ? v : bad(`${k} wants two settles at least ${GROW_S}s apart, the first at ${GROW_S}s or later, got ${t}`)
|
|
552
|
+
},
|
|
553
|
+
})
|
|
554
|
+
if (p.grow && cells.length !== 9) bad(`film grid … grow: it grows out of one cell into four, then nine: it wants nine cells, got ${cells.length}`)
|
|
555
|
+
if (p.grow && p.pop) bad('film grid: grow or pop, not both')
|
|
556
|
+
return { kind, line, cells, pop: (p.pop as number) ?? 0, ...(p.grow ? { grow: p.grow as [number, number] } : {}) }
|
|
557
|
+
}
|
|
558
|
+
case 'split': {
|
|
559
|
+
const cells = cellList('film split', ws[1], 2, 3)
|
|
560
|
+
const p = pairs(`film split ${cells.join(',')}`, ws.slice(2), { angle: (w, k) => num(k, w, 0, 70), slide: at })
|
|
561
|
+
return { kind, line, cells, angle: (p.angle as number) ?? SPLIT_ANGLE[cells.length] ?? 0, slide: (p.slide as number) ?? SPLIT_SLIDE }
|
|
562
|
+
}
|
|
411
563
|
}
|
|
412
564
|
return bad(`film: unknown kind ${kind}`)
|
|
413
565
|
}
|
|
@@ -442,45 +594,64 @@ function musicLine(rest: Word[], n: number, opening = false): Music {
|
|
|
442
594
|
}
|
|
443
595
|
}
|
|
444
596
|
|
|
445
|
-
/** words of the full trailer language a repo adds through its own words, not the core's */
|
|
446
|
-
const NOT_CORE: Record<string, string> = {
|
|
447
|
-
score: 'a score the repo synthesizes',
|
|
448
|
-
track: 'a voice of a score',
|
|
449
|
-
drums: 'a score\'s drum letters',
|
|
450
|
-
code: 'a score\'s code block',
|
|
451
|
-
play: 'what a score track plays',
|
|
452
|
-
progress: 'a progress bar title',
|
|
453
|
-
footage: 'a footage block a grid reads',
|
|
454
|
-
}
|
|
455
|
-
const notCore = (w: string): string => `${w} is ${NOT_CORE[w]}: a word of the full trailer language the core does not read yet`
|
|
456
|
-
|
|
457
597
|
/**
|
|
458
|
-
* the
|
|
459
|
-
*
|
|
598
|
+
* the lines the language reads as TypeScript, taken out before the words are read (their lines blank, so every line
|
|
599
|
+
* keeps its number): the `code` block (its indented lines under a margin `code` line) and each `play` line with the
|
|
600
|
+
* deeper indented lines that continue it. A line whose first mark is `#` is off in both.
|
|
460
601
|
*/
|
|
461
|
-
export function
|
|
602
|
+
export function scoreLines(text: string): { lines: string[]; code: Score['code']; plays: (Play & { section: number })[]; errors: TrailerError[] } {
|
|
462
603
|
const lines = text.split('\n')
|
|
463
604
|
const errors: TrailerError[] = []
|
|
605
|
+
const plays: (Play & { section: number })[] = []
|
|
606
|
+
let code: Score['code'] = null
|
|
607
|
+
let section = -1
|
|
464
608
|
const indent = (l: string): number => l.length - l.trimStart().length
|
|
465
609
|
const off = (l: string): boolean => l.trimStart().startsWith('#')
|
|
466
610
|
for (let i = 0; i < lines.length; i++) {
|
|
467
611
|
const l = lines[i]!
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
612
|
+
if (/^section\s/.test(l)) section++
|
|
613
|
+
if (/^code\s*(#.*)?$/.test(l)) {
|
|
614
|
+
const head = i + 1
|
|
615
|
+
if (code) errors.push({ line: head, message: `code is already written on line ${code.line}: one code block a file` })
|
|
616
|
+
const body: string[] = Array.from({ length: head }, () => '')
|
|
617
|
+
lines[i] = ''
|
|
618
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
619
|
+
const b = lines[j]!
|
|
620
|
+
if (b.trim() !== '' && !off(b) && indent(b) === 0) break
|
|
621
|
+
body.push(off(b) ? '' : b.replace(/^( {2}|\t)/, ''))
|
|
622
|
+
lines[j] = ''
|
|
623
|
+
i = j
|
|
624
|
+
}
|
|
625
|
+
code ??= { line: head, text: body.join('\n') }
|
|
626
|
+
continue
|
|
627
|
+
}
|
|
628
|
+
const m = /^(\s*)play(?:\s+(\S+))?(?:\s+(.*))?$/.exec(l)
|
|
629
|
+
if (!m || off(l)) continue
|
|
630
|
+
const at = i + 1
|
|
631
|
+
const parts = [m[3] ?? '']
|
|
472
632
|
lines[i] = ''
|
|
473
|
-
const base = code ? 0 : play![1]!.length
|
|
474
633
|
for (let j = i + 1; j < lines.length; j++) {
|
|
475
634
|
const b = lines[j]!
|
|
476
|
-
|
|
477
|
-
|
|
635
|
+
// deeper lines continue the expression; a closing bracket at the play's own indent ends it, and is its
|
|
636
|
+
const close = indent(b) === (m[1] ?? '').length && /^[)\]}]/.test(b.trimStart())
|
|
637
|
+
if (b.trim() !== '' && !off(b) && indent(b) <= (m[1] ?? '').length && !close) break
|
|
638
|
+
if (!off(b)) parts.push(b)
|
|
639
|
+
if (close) {
|
|
640
|
+
lines[j] = ''
|
|
641
|
+
i = j
|
|
642
|
+
break
|
|
643
|
+
}
|
|
478
644
|
lines[j] = ''
|
|
479
645
|
i = j
|
|
480
|
-
if (close) break
|
|
481
646
|
}
|
|
647
|
+
const track = m[2] ?? ''
|
|
648
|
+
const expr = parts.join('\n').trim()
|
|
649
|
+
if (!/^(\*|[a-zA-Z][a-zA-Z0-9]*)$/.test(track)) errors.push({ line: at, message: `play wants a track and what it plays: play <track> <expression>${track ? `, got ${track}` : ''}` })
|
|
650
|
+
else if (expr === '') errors.push({ line: at, message: `play ${track}: nothing to play` })
|
|
651
|
+
else if (section < 0) errors.push({ line: at, message: `play ${track}: a play belongs to a section, write it under one` })
|
|
652
|
+
else plays.push({ line: at, track, expr, section })
|
|
482
653
|
}
|
|
483
|
-
return { lines, errors }
|
|
654
|
+
return { lines, code, plays, errors }
|
|
484
655
|
}
|
|
485
656
|
|
|
486
657
|
export interface ParseOptions {
|
|
@@ -489,16 +660,37 @@ export interface ParseOptions {
|
|
|
489
660
|
}
|
|
490
661
|
|
|
491
662
|
/** the file as a trailer, and every error with its line */
|
|
663
|
+
/** a film line's option lines: the lines under it indented deeper, each `key value`, taken out before the words are
|
|
664
|
+
* read (their lines blank, so every line keeps its number) and kept by the film line's number */
|
|
665
|
+
export function filmOptionLines(lines: string[]): Map<number, { line: number; text: string }[]> {
|
|
666
|
+
const out = new Map<number, { line: number; text: string }[]>()
|
|
667
|
+
const indent = (l: string): number => l.length - l.trimStart().length
|
|
668
|
+
for (let i = 0; i < lines.length; i++) {
|
|
669
|
+
const m = /^(\s*)film(\s|$)/.exec(lines[i]!)
|
|
670
|
+
if (!m) continue
|
|
671
|
+
const head = i + 1
|
|
672
|
+
const own: { line: number; text: string }[] = []
|
|
673
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
674
|
+
const l = lines[j]!
|
|
675
|
+
if (l.trim() === '' || l.trimStart().startsWith('#')) continue
|
|
676
|
+
if (indent(l) <= m[1]!.length) break
|
|
677
|
+
own.push({ line: j + 1, text: l })
|
|
678
|
+
lines[j] = ''
|
|
679
|
+
i = j
|
|
680
|
+
}
|
|
681
|
+
if (own.length) out.set(head, own)
|
|
682
|
+
}
|
|
683
|
+
return out
|
|
684
|
+
}
|
|
685
|
+
|
|
492
686
|
export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Trailer; errors: TrailerError[] } {
|
|
493
687
|
const verbs = o.verbs ?? null
|
|
494
|
-
const t: Trailer = { name: '', size: [1920, 1080], fps: 30, music: null, duck: -10, duckSfx: null, fadeIn: 0, shots: [], sections: [], timelineAt: null, vertical: null, base: null }
|
|
688
|
+
const t: Trailer = { name: '', footage: [], size: [1920, 1080], fps: 30, music: null, duck: -10, duckSfx: null, fadeIn: 0, shots: [], sections: [], score: null, timelineAt: null, vertical: null, base: null }
|
|
495
689
|
const errors: TrailerError[] = []
|
|
496
690
|
const seen = new Set<string>()
|
|
497
691
|
let shot: Shot | null = null
|
|
498
692
|
let pending: SectionRef | null = null
|
|
499
693
|
let current: Section | null = null
|
|
500
|
-
let scoreClock: Clock | null = null
|
|
501
|
-
let skipping = false
|
|
502
694
|
/** the `at`s that name a mark, set once every shot has landed */
|
|
503
695
|
const refs: { line: number; shot: Shot; ref: MarkAt; set: (at: number) => void }[] = []
|
|
504
696
|
const landAt = (v: unknown, line: number, s: Shot, set: (at: number) => void): number => {
|
|
@@ -507,13 +699,27 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
507
699
|
return 0
|
|
508
700
|
}
|
|
509
701
|
clock = null
|
|
510
|
-
const pre =
|
|
702
|
+
const pre = scoreLines(text)
|
|
511
703
|
errors.push(...pre.errors)
|
|
512
704
|
const lines = pre.lines
|
|
705
|
+
const options = filmOptionLines(lines)
|
|
706
|
+
const presets = new Map<string, Word[]>()
|
|
707
|
+
/** the film slots that wait on a moment: resolved once the shots have landed, each plus its shot's `from` */
|
|
708
|
+
const slots: { shot: Shot; film: Extract<Film, { kind: 'cli' }>; slot: Typed['times'][number] }[] = []
|
|
709
|
+
/** a time as a declared word reads it, at the clock of its line; null when it is none */
|
|
710
|
+
const momentWord = (w: string): Moment | null => {
|
|
711
|
+
const m = new RegExp(`^(${MOMENT})(?:([+-])(\\d.*))?$`).exec(w)
|
|
712
|
+
// a bare number is seconds, as a cue of a take counts them
|
|
713
|
+
if (!m) return /^\d+(\.\d+)?$/.test(w) ? Number(w) : parseTime(w, clock)
|
|
714
|
+
const off = m[3] === undefined ? 0 : parseTime(m[3], clock)
|
|
715
|
+
return off === null ? null : { mark: m[1]!, offset: m[2] === '-' ? -off : off }
|
|
716
|
+
}
|
|
513
717
|
for (let n = 1; n <= lines.length; n++) {
|
|
514
718
|
const tk = tokens(lines[n - 1]!)
|
|
515
719
|
if ('error' in tk) {
|
|
516
720
|
errors.push({ line: n, message: tk.error })
|
|
721
|
+
// its own line failed: the shot is not also told it films nothing
|
|
722
|
+
if (shot && /^\s*film(\s|$)/.test(lines[n - 1]!)) shot.broken = true
|
|
517
723
|
continue
|
|
518
724
|
}
|
|
519
725
|
const ws = tk.words
|
|
@@ -524,25 +730,8 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
524
730
|
if (seen.has(key)) bad(`${key} given twice`)
|
|
525
731
|
seen.add(key)
|
|
526
732
|
}
|
|
527
|
-
// a footage block's own lines belong to it: skipped with it, its one error at its line
|
|
528
|
-
if (skipping && !['shot', 'footage', 'section'].includes(head.text)) continue
|
|
529
|
-
skipping = false
|
|
530
733
|
try {
|
|
531
734
|
if (head.quoted) bad(`a line starts with a word, got "${head.text}"`)
|
|
532
|
-
if (head.text === 'footage') skipping = true
|
|
533
|
-
if (head.text in NOT_CORE) {
|
|
534
|
-
// a score's clock still counts the sections' bars, so one error stands for the score, not one per section
|
|
535
|
-
if (head.text === 'score') {
|
|
536
|
-
const v = (k: string): number | undefined => {
|
|
537
|
-
const i = rest.findIndex((w) => w.text === k)
|
|
538
|
-
const x = i >= 0 ? Number(rest[i + 1]?.text) : Number.NaN
|
|
539
|
-
return Number.isFinite(x) ? x : undefined
|
|
540
|
-
}
|
|
541
|
-
const bpm = v('tempo')
|
|
542
|
-
if (bpm) scoreClock = { bpm, meter: v('meter') ?? 4, grid: v('grid') ?? 4 }
|
|
543
|
-
}
|
|
544
|
-
bad(notCore(head.text))
|
|
545
|
-
}
|
|
546
735
|
if (head.text === 'section') {
|
|
547
736
|
const name = word('section', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
548
737
|
const last = t.sections.at(-1)
|
|
@@ -552,17 +741,44 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
552
741
|
meter: (w, k) => num(k, w, 1, 16),
|
|
553
742
|
grid: (w, k) => num(k, w, 1, 32),
|
|
554
743
|
})
|
|
555
|
-
const base = last?.clock ??
|
|
744
|
+
const base = last?.clock ?? t.score?.clock ?? null
|
|
556
745
|
if (!base && !p.tempo && (p.meter || p.grid)) bad(`section ${name}: meter and grid count in a tempo, give it tempo <bpm>`)
|
|
557
746
|
clock = p.tempo || base ? { ...(base ?? { bpm: 120, meter: 4, grid: 4 }), ...(p.tempo ? { bpm: p.tempo as number } : {}), ...(p.meter ? { meter: p.meter as number } : {}), ...(p.grid ? { grid: p.grid as number } : {}) } : null
|
|
558
747
|
const seconds = +time(`section ${name}`, rest[1]).toFixed(6)
|
|
559
748
|
if (seconds <= 0) bad(`section ${name}: its length must be above 0`)
|
|
560
749
|
const at = last ? +(last.at + last.seconds).toFixed(6) : 0
|
|
561
|
-
current = { line: n, name, clock, at, seconds, marks: [] }
|
|
750
|
+
current = { line: n, name, clock, at, seconds, marks: [], drums: {}, plays: [] }
|
|
562
751
|
t.sections.push(current)
|
|
563
752
|
pending = { line: n, name, at }
|
|
564
753
|
continue
|
|
565
754
|
}
|
|
755
|
+
if (head.text === 'drums') {
|
|
756
|
+
const into = current?.drums ?? (shot === null && t.score ? t.score.drums : bad('drums names the score\'s drum letters: write it after the score line, or under a section'))
|
|
757
|
+
if (rest.length === 0) bad('drums wants <letter>=<instrument> …')
|
|
758
|
+
for (const w of rest) {
|
|
759
|
+
const m = !w.quoted ? /^([A-Za-z])=([a-zA-Z][a-zA-Z0-9]*)$/.exec(w.text) : null
|
|
760
|
+
if (!m) bad(`drums wants <letter>=<instrument>, got ${w.text}`)
|
|
761
|
+
into[m![1]!] = m![2]!
|
|
762
|
+
}
|
|
763
|
+
continue
|
|
764
|
+
}
|
|
765
|
+
if (head.text === 'stop') {
|
|
766
|
+
const sec = current ?? bad('a stop is a moment of a section: write it under one')
|
|
767
|
+
const sc = t.score ?? bad('stop cuts the score dead: write score tempo <bpm> in the header')
|
|
768
|
+
if (sc.stop) bad(`the score already stops on line ${sc.stop.line}`)
|
|
769
|
+
if (rest[0]?.text !== 'at' || rest.length !== 2) bad('stop wants at <time>, from its section\'s start')
|
|
770
|
+
const at = time('stop', rest[1])
|
|
771
|
+
if (at > sec.seconds) bad(`stop at ${+at.toFixed(3)} s is past section ${sec.name}'s ${+sec.seconds.toFixed(3)} s`)
|
|
772
|
+
sc.stop = { line: n, at: +(sec.at + at).toFixed(6) }
|
|
773
|
+
continue
|
|
774
|
+
}
|
|
775
|
+
if (head.text === 'defaults') {
|
|
776
|
+
const sec = current ?? bad('defaults are the film words of a section: write it under one')
|
|
777
|
+
if (sec.defaults) bad(`section ${sec.name} already has its defaults`)
|
|
778
|
+
if (rest.length === 0) bad('defaults wants film words: defaults <word> <value> …')
|
|
779
|
+
sec.defaults = rest.map((w) => ({ ...w, line: n }))
|
|
780
|
+
continue
|
|
781
|
+
}
|
|
566
782
|
if (head.text === 'mark') {
|
|
567
783
|
const sec = current ?? bad('a mark is a moment of a section: write it under one')
|
|
568
784
|
const name = word('mark', rest[0], new RegExp(`^${MOMENT}$`))
|
|
@@ -573,13 +789,28 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
573
789
|
sec.marks.push({ line: n, name, at })
|
|
574
790
|
continue
|
|
575
791
|
}
|
|
792
|
+
if (head.text === 'footage') {
|
|
793
|
+
// a block filmed like a shot and never cut in: only a grid or a split reads it
|
|
794
|
+
const open: Shot = { line: n, name: rest[0]?.text ?? '', length: 0, film: null, titles: [], sfx: [], voices: [], cut: { line: n, kind: 'hard', seconds: 0 }, from: 0, push: null, frame: null, shakes: [], flashes: [], music: null, broken: true, offcut: true }
|
|
795
|
+
shot = open
|
|
796
|
+
t.footage.push(open)
|
|
797
|
+
const name = word('footage', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
798
|
+
if ([...t.shots, ...t.footage].some((s) => s !== open && s.name === name)) bad(`footage ${name}: ${name} is already a shot or footage of this file`)
|
|
799
|
+
const length = time(`footage ${name}`, rest[1])
|
|
800
|
+
if (length <= 0) bad(`footage ${name}: its length must be above 0`)
|
|
801
|
+
if (rest.length > 2) bad(`footage ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a footage line is footage <name> <length>)`)
|
|
802
|
+
open.name = name
|
|
803
|
+
open.length = length
|
|
804
|
+
delete open.broken
|
|
805
|
+
continue
|
|
806
|
+
}
|
|
576
807
|
if (head.text === 'shot') {
|
|
577
808
|
// a bad shot line still opens a shot, so its own lines never read as the header's or the shot before's
|
|
578
809
|
const open: Shot = { line: n, name: rest[0]?.text ?? '', length: 0, film: null, titles: [], sfx: [], voices: [], cut: { line: n, kind: 'hard', seconds: 0 }, from: 0, push: null, frame: null, shakes: [], flashes: [], music: null, broken: true }
|
|
579
810
|
shot = open
|
|
580
811
|
t.shots.push(open)
|
|
581
812
|
const name = word('shot', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
582
|
-
if (t.shots.some((s) => s !== open && s.name === name)) bad(`shot ${name} is already a shot of this file`)
|
|
813
|
+
if ([...t.shots, ...t.footage].some((s) => s !== open && s.name === name)) bad(`shot ${name} is already a shot or footage of this file`)
|
|
583
814
|
const length = time(`shot ${name}`, rest[1])
|
|
584
815
|
if (length <= 0) bad(`shot ${name}: its length must be above 0`)
|
|
585
816
|
if (rest.length > 2) bad(`shot ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a shot line is shot <name> <length>)`)
|
|
@@ -653,21 +884,81 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
653
884
|
t.vertical = { line: n, outro: (p.outro as Vertical['outro'] | undefined) ?? 'card', ...(p.mark ? { mark: p.mark as 'left' | 'right' } : {}) }
|
|
654
885
|
break
|
|
655
886
|
}
|
|
887
|
+
case 'preset': {
|
|
888
|
+
const name = word('preset', rest[0], /^[a-z][a-z0-9-]*$/)
|
|
889
|
+
if (presets.has(name)) bad(`preset ${name} is already a preset of this file`)
|
|
890
|
+
const words = rest.slice(1)[0]?.text === '=' && !rest[1]!.quoted ? rest.slice(2) : rest.slice(1)
|
|
891
|
+
if (words.length === 0) bad(`preset ${name} wants film words: preset ${name} <word> <value> …`)
|
|
892
|
+
presets.set(name, words.map((w) => ({ ...w, line: n })))
|
|
893
|
+
break
|
|
894
|
+
}
|
|
656
895
|
case 'base':
|
|
657
896
|
once('base')
|
|
658
897
|
t.base = word('base', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
659
898
|
if (rest.length > 1) bad(`base ${t.base}: unknown word ${rest[1]?.text} (base <the id of a global process of this repo>)`)
|
|
660
899
|
break
|
|
900
|
+
case 'score': {
|
|
901
|
+
once('score')
|
|
902
|
+
if (t.sections.length > 0) bad('score opens the music the sections play: write it before the first section')
|
|
903
|
+
const sc: Score = { line: n, clock: { bpm: 120, meter: 4, grid: 4 }, fadeIn: 0, volume: 1, tracks: [], drums: {}, code: pre.code }
|
|
904
|
+
// reverb takes two values, so the words are read here rather than by pairs
|
|
905
|
+
const p: Record<string, number> = {}
|
|
906
|
+
for (let i = 0; i < rest.length; i++) {
|
|
907
|
+
const k = rest[i]!.text
|
|
908
|
+
if (rest[i]!.quoted || !['tempo', 'meter', 'grid', 'reverb', 'fade-in', 'volume'].includes(k))
|
|
909
|
+
bad(`score: unknown word ${k} (score takes tempo, meter, grid, reverb, fade-in, volume)`)
|
|
910
|
+
if (k in p) bad(`score: ${k} given twice`)
|
|
911
|
+
if (k === 'reverb') {
|
|
912
|
+
sc.reverb = { seconds: num('score reverb <seconds> <mix>', rest[i + 1], 0.1, 10), mix: num('score reverb <seconds> <mix>', rest[i + 2], 0, 1) }
|
|
913
|
+
p[k] = 1
|
|
914
|
+
i += 2
|
|
915
|
+
} else if (k === 'fade-in') p[k] = time('score fade-in', rest[++i])
|
|
916
|
+
else p[k] = num(`score ${k}`, rest[++i], k === 'tempo' ? 20 : k === 'volume' ? 0 : 1, k === 'tempo' ? 400 : k === 'grid' ? 32 : k === 'volume' ? 4 : 16)
|
|
917
|
+
}
|
|
918
|
+
if (p.tempo === undefined) bad('score wants its tempo: score tempo <bpm>')
|
|
919
|
+
sc.clock = { bpm: p.tempo!, meter: p.meter ?? 4, grid: p.grid ?? 4 }
|
|
920
|
+
sc.fadeIn = p['fade-in'] ?? 0
|
|
921
|
+
sc.volume = p.volume ?? 1
|
|
922
|
+
t.score = sc
|
|
923
|
+
clock = sc.clock
|
|
924
|
+
break
|
|
925
|
+
}
|
|
926
|
+
case 'track': {
|
|
927
|
+
const sc = t.score ?? bad('a track is a voice of the score: write score tempo <bpm> first')
|
|
928
|
+
const name = word('track', rest[0], /^[a-zA-Z][a-zA-Z0-9]*$/)
|
|
929
|
+
const instrument = word(`track ${name}`, rest[1], /^[a-zA-Z][a-zA-Z0-9]*$/)
|
|
930
|
+
if (sc.tracks.some((x) => x.name === name)) bad(`track ${name} is already a track of the score`)
|
|
931
|
+
if (sc.tracks.length >= TRACKS_MAX) bad(`track ${name}: the score holds ${TRACKS_MAX} tracks at most`)
|
|
932
|
+
const p = pairs(`track ${name}`, rest.slice(2), { volume })
|
|
933
|
+
sc.tracks.push({ line: n, name, instrument, volume: (p.volume as number) ?? 1 })
|
|
934
|
+
break
|
|
935
|
+
}
|
|
661
936
|
default:
|
|
662
|
-
bad(`unknown word ${head.text} before the first shot (trailer, size, fps, music, duck, fade-in, vertical, base, section, shot)`)
|
|
937
|
+
bad(`unknown word ${head.text} before the first shot (trailer, size, fps, music, duck, fade-in, vertical, base, preset, score, track, drums, section, defaults, shot)`)
|
|
663
938
|
}
|
|
664
939
|
continue
|
|
665
940
|
}
|
|
666
941
|
const s: Shot = shot
|
|
942
|
+
if (s.offcut && !['film', 'from', 'frame'].includes(head.text)) bad(`footage ${s.name} is never cut in: it takes film, from and frame, never ${head.text}`)
|
|
667
943
|
switch (head.text) {
|
|
668
944
|
case 'film':
|
|
669
945
|
if (s.film) bad(`shot ${s.name} already films on line ${s.film.line}: one film line a shot`)
|
|
670
|
-
|
|
946
|
+
{
|
|
947
|
+
// its option lines, each word keeping its own line
|
|
948
|
+
const more: Word[] = []
|
|
949
|
+
for (const o of options.get(n) ?? []) {
|
|
950
|
+
const tk = tokens(o.text)
|
|
951
|
+
if ('error' in tk) errors.push({ line: o.line, message: tk.error })
|
|
952
|
+
else more.push(...tk.words.map((w) => ({ ...w, line: o.line })))
|
|
953
|
+
}
|
|
954
|
+
s.film = filmLine([...rest.map((w) => ({ ...w, line: n })), ...more], n, verbs, {
|
|
955
|
+
defaults: s.offcut ? [] : (current?.defaults ?? []),
|
|
956
|
+
presets,
|
|
957
|
+
time: momentWord,
|
|
958
|
+
later: (film, slot) => slots.push({ shot: s, film, slot }),
|
|
959
|
+
errors,
|
|
960
|
+
})
|
|
961
|
+
}
|
|
671
962
|
break
|
|
672
963
|
case 'title': {
|
|
673
964
|
const text = quoted('title', rest[0])
|
|
@@ -699,6 +990,25 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
699
990
|
s.titles.push(ti)
|
|
700
991
|
break
|
|
701
992
|
}
|
|
993
|
+
case 'progress': {
|
|
994
|
+
const names = quoted('progress', rest[0]).split('|').map((x) => x.trim())
|
|
995
|
+
if (names.length < 2 || names.some((x) => x === '')) bad('progress wants two or more step names, "<a>|<b>|…"')
|
|
996
|
+
const p = pairs('progress', rest.slice(1), {
|
|
997
|
+
at: when,
|
|
998
|
+
every: at,
|
|
999
|
+
place: (w, k) => {
|
|
1000
|
+
const pl = word(k, w)
|
|
1001
|
+
return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
|
|
1002
|
+
},
|
|
1003
|
+
})
|
|
1004
|
+
const every = (p.every as number | undefined) ?? bad('progress wants every <time>: how long each step stands')
|
|
1005
|
+
names.forEach((text, k) => {
|
|
1006
|
+
const ti: Title = { line: n, text, at: 0, for: every, style: 'progress', step: { i: k + 1, n: names.length }, ...(p.place ? { place: p.place as 'top' | 'bottom' } : {}) }
|
|
1007
|
+
ti.at = landAt(p.at, n, s, (v) => (ti.at = v + k * every)) + k * every
|
|
1008
|
+
s.titles.push(ti)
|
|
1009
|
+
})
|
|
1010
|
+
break
|
|
1011
|
+
}
|
|
702
1012
|
case 'sfx': {
|
|
703
1013
|
const id = pathWord('sfx', rest[0])
|
|
704
1014
|
const p = pairs(`sfx ${id}`, rest.slice(1), { at: when, volume })
|
|
@@ -728,6 +1038,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
728
1038
|
}
|
|
729
1039
|
case 'music':
|
|
730
1040
|
if (s.music) bad(`shot ${s.name} already changes the music on line ${s.music.line}`)
|
|
1041
|
+
if (t.score && t.sections.length > 0) bad('music: the score plays from its first section on; a song plays only before it')
|
|
731
1042
|
s.music = musicLine(rest, n)
|
|
732
1043
|
break
|
|
733
1044
|
case 'starts': {
|
|
@@ -777,7 +1088,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
777
1088
|
break
|
|
778
1089
|
}
|
|
779
1090
|
default:
|
|
780
|
-
bad(`unknown word ${head.text} in shot ${s.name} (film, title, sfx, voice, music, starts, cut, from, push, frame, lift, shake, flash, shot)`)
|
|
1091
|
+
bad(`unknown word ${head.text} in shot ${s.name} (film, title, progress, sfx, voice, music, starts, cut, from, push, frame, lift, shake, flash, shot)`)
|
|
781
1092
|
}
|
|
782
1093
|
} catch (e) {
|
|
783
1094
|
if (!(e instanceof LineError)) throw e
|
|
@@ -790,14 +1101,39 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
790
1101
|
if (t.vertical && !seen.has('size')) t.size = [...VERTICAL_SIZE]
|
|
791
1102
|
else if (t.vertical && t.size[0] >= t.size[1]) errors.push({ line: t.vertical.line, message: `vertical: a short is filmed upright, its size taller than wide (1080x1920), not ${t.size.join('x')}` })
|
|
792
1103
|
clock = null
|
|
1104
|
+
for (const pl of pre.plays) {
|
|
1105
|
+
const sec = t.sections[pl.section]
|
|
1106
|
+
if (!sec) continue
|
|
1107
|
+
if (!t.score) errors.push({ line: pl.line, message: `play ${pl.track}: a play is the score's: write score tempo <bpm> in the header` })
|
|
1108
|
+
else if (pl.track !== '*' && !t.score.tracks.some((x) => x.name === pl.track))
|
|
1109
|
+
errors.push({ line: pl.line, message: `play ${pl.track}: no track ${pl.track} (${t.score.tracks.map((x) => x.name).join(', ') || 'the score has no track: track <name> <instrument>'})` })
|
|
1110
|
+
else if (sec.plays.some((x) => x.track === pl.track)) errors.push({ line: pl.line, message: `play ${pl.track}: section ${sec.name} already plays it` })
|
|
1111
|
+
else sec.plays.push({ line: pl.line, track: pl.track, expr: pl.expr })
|
|
1112
|
+
}
|
|
1113
|
+
if (pre.code && !t.score) errors.push({ line: pre.code.line, message: 'code is the score\'s: write score tempo <bpm> in the header' })
|
|
1114
|
+
if (t.score && t.sections.length === 0) errors.push({ line: t.score.line, message: 'the score has no section: section <name> <length> under the header, its plays under it' })
|
|
793
1115
|
errors.push(...landShots(t))
|
|
1116
|
+
if (t.score && t.sections.length > 0 && t.timelineAt === null && !t.shots.some((s) => s.broken))
|
|
1117
|
+
errors.push({ line: t.score.line, message: 'no shot lands on a section, so the score never plays: put a shot under a section, or starts on <section>' })
|
|
794
1118
|
for (const r of refs) {
|
|
795
1119
|
const i = t.shots.indexOf(r.shot)
|
|
796
1120
|
const m = momentOf(t, r.ref.mark)
|
|
797
1121
|
if (m === null || t.timelineAt === null) errors.push({ line: r.line, message: `at ${r.ref.mark}: no section or mark ${r.ref.mark} in this file` })
|
|
798
1122
|
else r.set(+(t.timelineAt + m + r.ref.offset - (starts(t)[i] ?? 0)).toFixed(4))
|
|
799
1123
|
}
|
|
800
|
-
|
|
1124
|
+
for (const p of slots) {
|
|
1125
|
+
const i = t.shots.indexOf(p.shot)
|
|
1126
|
+
const parts = p.slot.parts.map((x) => {
|
|
1127
|
+
if (typeof x.at === 'number') return { name: x.name, seconds: x.at + p.shot.from }
|
|
1128
|
+
const m = momentOf(t, x.at.mark)
|
|
1129
|
+
if (i < 0) errors.push({ line: x.line, message: `film: ${x.at.mark} is a moment of the cut, and footage ${p.shot.name} is never cut in: write seconds` })
|
|
1130
|
+
else if (m === null || t.timelineAt === null) errors.push({ line: x.line, message: `film: no section or mark ${x.at.mark} in this file` })
|
|
1131
|
+
else return { name: x.name, seconds: +(t.timelineAt + m + x.at.offset - (starts(t)[i] ?? 0) + p.shot.from).toFixed(4) }
|
|
1132
|
+
return { name: x.name, seconds: 0 }
|
|
1133
|
+
})
|
|
1134
|
+
p.film.argv[p.slot.index] = slotText(parts)
|
|
1135
|
+
}
|
|
1136
|
+
errors.push(...structure(t), ...grids(t))
|
|
801
1137
|
return { trailer: t, errors: errors.sort((a, b) => a.line - b.line) }
|
|
802
1138
|
}
|
|
803
1139
|
|
|
@@ -862,10 +1198,12 @@ function structure(t: Trailer): TrailerError[] {
|
|
|
862
1198
|
if (s.broken) return
|
|
863
1199
|
if (!s.film) out.push({ line: s.line, message: `shot ${s.name} films nothing: give it a film line` })
|
|
864
1200
|
if (isStill(s.film) && s.from > 0) out.push({ line: s.line, message: `shot ${s.name}: its film is a still image, no footage to skip (from)` })
|
|
1201
|
+
if ((s.film?.kind === 'grid' || s.film?.kind === 'split') && s.from > 0) out.push({ line: s.line, message: `shot ${s.name}: a ${s.film.kind} has no footage of its own to skip; each cell's from skips its own` })
|
|
865
1202
|
// a title may run on over the shots after its own (a caption over a montage), never past the cut's end
|
|
866
1203
|
const left = total(t) - (starts(t)[i] ?? 0)
|
|
867
1204
|
for (const ti of s.titles) {
|
|
868
|
-
|
|
1205
|
+
// a progress line's later steps run on over the next shots, as a `for` past its shot does
|
|
1206
|
+
if (ti.at >= s.length && !(ti.step && ti.step.i > 1)) out.push({ line: ti.line, message: `title at ${ti.at} s starts past shot ${s.name}'s ${s.length} s` })
|
|
869
1207
|
else if (ti.for !== null && ti.at + ti.for > left + 1e-9) out.push({ line: ti.line, message: `title ends ${+(ti.at + ti.for - left).toFixed(2)} s past the cut's end` })
|
|
870
1208
|
}
|
|
871
1209
|
for (const x of [...s.sfx, ...s.voices]) if (x.at >= s.length) out.push({ line: x.line, message: `${'id' in x ? 'sfx' : 'voice'} at ${x.at} s starts past shot ${s.name}'s ${s.length} s` })
|
|
@@ -885,6 +1223,38 @@ function structure(t: Trailer): TrailerError[] {
|
|
|
885
1223
|
return out
|
|
886
1224
|
}
|
|
887
1225
|
|
|
1226
|
+
/** the shots and footage blocks a grid's or a split's cells name */
|
|
1227
|
+
export function gridCells(t: Pick<Trailer, 'shots' | 'footage'>, cells: readonly string[]): (Shot | undefined)[] {
|
|
1228
|
+
return cells.map((c) => [...t.footage, ...t.shots].find((s) => s.name === c))
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** a cell plays filmed footage: a take that is a clip, never a grid, a split, a card, an image or a still */
|
|
1232
|
+
export const isCellFilm = (f: Film | null): boolean => (f?.kind === 'run' || f?.kind === 'url' || f?.kind === 'cli') && !isStill(f)
|
|
1233
|
+
|
|
1234
|
+
/** what a grid or a split names, and what a footage block films */
|
|
1235
|
+
function grids(t: Trailer): TrailerError[] {
|
|
1236
|
+
const out: TrailerError[] = []
|
|
1237
|
+
for (const s of [...t.shots, ...t.footage]) {
|
|
1238
|
+
if (s.broken || (s.film?.kind !== 'grid' && s.film?.kind !== 'split')) continue
|
|
1239
|
+
const f = s.film
|
|
1240
|
+
if (s.offcut) {
|
|
1241
|
+
out.push({ line: f.line, message: `footage ${s.name}: a ${f.kind} is laid by the stitch, never a footage block` })
|
|
1242
|
+
continue
|
|
1243
|
+
}
|
|
1244
|
+
gridCells(t, f.cells).forEach((c, i) => {
|
|
1245
|
+
const name = f.cells[i]
|
|
1246
|
+
if (!c) out.push({ line: f.line, message: `film ${f.kind}: no shot or footage ${name} in this file` })
|
|
1247
|
+
else if (c.film && !isCellFilm(c.film)) out.push({ line: f.line, message: `film ${f.kind}: ${name} is a ${c.film.kind === 'cli' || c.film.kind === 'run' || c.film.kind === 'url' ? 'still' : c.film.kind}, a cell plays filmed footage` })
|
|
1248
|
+
})
|
|
1249
|
+
}
|
|
1250
|
+
for (const s of t.footage) {
|
|
1251
|
+
if (s.broken) continue
|
|
1252
|
+
if (!s.film) out.push({ line: s.line, message: `footage ${s.name} films nothing: give it a film line` })
|
|
1253
|
+
else if (s.film.kind !== 'grid' && s.film.kind !== 'split' && !isCellFilm(s.film)) out.push({ line: s.film.line, message: `footage ${s.name}: a ${s.film.kind === 'cli' || s.film.kind === 'run' || s.film.kind === 'url' ? 'still' : s.film.kind} is no footage a grid plays` })
|
|
1254
|
+
}
|
|
1255
|
+
return out
|
|
1256
|
+
}
|
|
1257
|
+
|
|
888
1258
|
/** each shot's start on the cut: a cut overlaps the two shots it joins */
|
|
889
1259
|
export function starts(t: Pick<Trailer, 'shots'>): number[] {
|
|
890
1260
|
const out: number[] = []
|
|
@@ -905,9 +1275,14 @@ export function total(t: Pick<Trailer, 'shots'>): number {
|
|
|
905
1275
|
|
|
906
1276
|
/** the music of the cut, cue by cue: each its start on the cut and its end, a crossfade into the next over the next's
|
|
907
1277
|
* fade-in; a `none` cue is silence and carries no song */
|
|
908
|
-
export function musicCues(t: Pick<Trailer, 'music' | 'shots'>): { cue: Music; at: number; end: number }[] {
|
|
1278
|
+
export function musicCues(t: Pick<Trailer, 'music' | 'shots' | 'score' | 'timelineAt'>): { cue: Music; at: number; end: number }[] {
|
|
909
1279
|
const st = starts(t)
|
|
910
|
-
const
|
|
1280
|
+
const sc = t.score
|
|
1281
|
+
const raw: { cue: Music; at: number }[] = [
|
|
1282
|
+
...(t.music ? [{ cue: t.music, at: 0 }] : []),
|
|
1283
|
+
...t.shots.flatMap((s, i) => (s.music ? [{ cue: s.music, at: st[i] ?? 0 }] : [])),
|
|
1284
|
+
...(sc && t.timelineAt !== null ? [{ cue: { line: sc.line, song: SCORE, from: SCORE_LEAD, fadeIn: sc.fadeIn, fadeOut: 0, volume: sc.volume }, at: t.timelineAt }] : []),
|
|
1285
|
+
].sort((a, b) => a.at - b.at)
|
|
911
1286
|
const T = total(t)
|
|
912
1287
|
return raw
|
|
913
1288
|
.map((r, i) => ({ ...r, end: Math.min(T, (raw[i + 1]?.at ?? T) + (raw[i + 1]?.cue.fadeIn ?? 0)) }))
|