rvlib-shipkit 2.1.0 → 2.2.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 +18 -0
- package/package.json +2 -1
- package/src/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_CacheAudit.ts +3 -2
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Ci.ts +4 -3
- package/src/cli/cmd_Commands.ts +158 -43
- package/src/cli/cmd_Fleet.ts +3 -1
- package/src/cli/cmd_GlobalProcesses.ts +7 -2
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Trailer.ts +108 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +157 -0
- package/src/cli/list-flag.ts +3 -0
- package/src/cli.ts +13 -2
- package/src/core/layout.ts +10 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/mv/mv-md.ts +2 -1
- package/src/reflect/prompt-corpus.ts +5 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/shipkit-files.ts +8 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +23 -0
- package/src/trailer/build.ts +344 -0
- package/src/trailer/ffmpeg.ts +50 -0
- package/src/trailer/footage.ts +104 -0
- package/src/trailer/grammar.ts +915 -0
- package/src/trailer/graph.ts +323 -0
- package/src/trailer/load-unit.ts +14 -0
- package/src/trailer/look.ts +110 -0
- package/src/trailer/plan.ts +133 -0
- package/src/trailer/stitch.ts +150 -0
- package/src/trailer/titles.ts +274 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +40 -0
- package/src/ts/rename.ts +9 -0
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
// the trailer language, its core words: one text file → the shots, their films, titles, sounds and cuts. Pure: no
|
|
2
|
+
// 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
|
+
// passes in. Every error carries its line, all at once, and a file with one error never builds
|
|
4
|
+
|
|
5
|
+
export type TitleStyle = 'logo' | 'big' | 'caption' | 'corner' | 'line'
|
|
6
|
+
export const TITLE_STYLES: readonly TitleStyle[] = ['logo', 'big', 'caption', 'corner', 'line']
|
|
7
|
+
export const CUT_KINDS = [
|
|
8
|
+
'hard',
|
|
9
|
+
'fade',
|
|
10
|
+
'dip-black',
|
|
11
|
+
'dip-white',
|
|
12
|
+
'slide-left',
|
|
13
|
+
'slide-right',
|
|
14
|
+
'slide-up',
|
|
15
|
+
'slide-down',
|
|
16
|
+
'whip-left',
|
|
17
|
+
'whip-right',
|
|
18
|
+
'wipe-left',
|
|
19
|
+
'wipe-right',
|
|
20
|
+
'wipe-up',
|
|
21
|
+
'wipe-down',
|
|
22
|
+
'circle',
|
|
23
|
+
'pixel',
|
|
24
|
+
] as const
|
|
25
|
+
export type CutKind = (typeof CUT_KINDS)[number]
|
|
26
|
+
export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url'] as const
|
|
27
|
+
|
|
28
|
+
/** the most frames a second a cut runs at */
|
|
29
|
+
export const FPS_MAX = 60
|
|
30
|
+
const DEFAULT_CUT_S = 0.5
|
|
31
|
+
|
|
32
|
+
/** a film row of the project CLI: a still writes one image at `--out`, a clip a video of `--seconds` */
|
|
33
|
+
export interface FilmVerb {
|
|
34
|
+
path: string[]
|
|
35
|
+
film: 'still' | 'clip'
|
|
36
|
+
description?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type Film =
|
|
40
|
+
| { kind: 'clip'; line: number; path: string }
|
|
41
|
+
| { kind: 'image'; line: number; path: string }
|
|
42
|
+
| { kind: 'card'; line: number }
|
|
43
|
+
/** any command that writes the take at `{out}`; `still`: it writes an image, held for the shot */
|
|
44
|
+
| { kind: 'run'; line: number; cmd: string; still?: true }
|
|
45
|
+
/** a film row of the project CLI, its words as written: `<cli> <argv> --out <file> [--seconds <n>]` */
|
|
46
|
+
| { kind: 'cli'; line: number; argv: string[]; output: 'still' | 'clip' }
|
|
47
|
+
/** a page of the repo's dev url (a route) or any http url, through the house shot service; `viewport` its css
|
|
48
|
+
* size, `wait` before it films; `still`: a png, else a clip of the page's largest canvas */
|
|
49
|
+
| { kind: 'url'; line: number; route: string; viewport?: [number, number]; wait?: number; still?: true }
|
|
50
|
+
|
|
51
|
+
/** the vertical short: a lockup over every frame, a watermark in a bottom corner, then the outro and the end card,
|
|
52
|
+
* each drawn from the brand's `trailer.vertical` data (vertical.ts) */
|
|
53
|
+
export interface Vertical {
|
|
54
|
+
line: number
|
|
55
|
+
/** the watermark's corner, over the brand's own */
|
|
56
|
+
mark?: 'left' | 'right'
|
|
57
|
+
/** what follows the short: the end card (the default), a clip then the end card, or nothing */
|
|
58
|
+
outro: 'card' | 'none' | { clip: string }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** the size a vertical short films at when the file names none */
|
|
62
|
+
export const VERTICAL_SIZE: [number, number] = [1080, 1920]
|
|
63
|
+
|
|
64
|
+
export interface Title {
|
|
65
|
+
line: number
|
|
66
|
+
text: string
|
|
67
|
+
at: number
|
|
68
|
+
/** null: to the shot's end */
|
|
69
|
+
for: number | null
|
|
70
|
+
style: TitleStyle
|
|
71
|
+
/** where a caption sits: the bottom by default */
|
|
72
|
+
place?: 'top' | 'bottom'
|
|
73
|
+
/** knocked off the frame from this second of its shot: it tips and flies out */
|
|
74
|
+
knock?: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Sfx {
|
|
78
|
+
line: number
|
|
79
|
+
/** a file, or an id the repo's sound resolver knows */
|
|
80
|
+
id: string
|
|
81
|
+
at: number
|
|
82
|
+
volume: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface Voice {
|
|
86
|
+
line: number
|
|
87
|
+
/** a line the repo's voice resolver finds by its text, or a recorded file */
|
|
88
|
+
text?: string
|
|
89
|
+
file?: string
|
|
90
|
+
at: number
|
|
91
|
+
volume: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface Shake {
|
|
95
|
+
line: number
|
|
96
|
+
at: number
|
|
97
|
+
for: number
|
|
98
|
+
strength: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface Flash {
|
|
102
|
+
line: number
|
|
103
|
+
at: number
|
|
104
|
+
for: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface Cut {
|
|
108
|
+
line: number
|
|
109
|
+
kind: CutKind
|
|
110
|
+
seconds: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** a section as its first shot sees it: the shot starts when the timeline reaches `at` */
|
|
114
|
+
export interface SectionRef {
|
|
115
|
+
line: number
|
|
116
|
+
name: string
|
|
117
|
+
at: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface Music {
|
|
121
|
+
line: number
|
|
122
|
+
/** a file, `none`, or an id the repo's sound resolver knows */
|
|
123
|
+
song: string
|
|
124
|
+
take?: string
|
|
125
|
+
from: number
|
|
126
|
+
fadeIn: number
|
|
127
|
+
fadeOut: number
|
|
128
|
+
volume: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface Shot {
|
|
132
|
+
line: number
|
|
133
|
+
name: string
|
|
134
|
+
length: number
|
|
135
|
+
/** the shot opens this section: the shot before it is fitted so it lands on its start */
|
|
136
|
+
section?: SectionRef
|
|
137
|
+
/** `starts on`: the moment it lands on, a section or a mark, and a time from it */
|
|
138
|
+
lands?: { line: number; on: string; offset: number }
|
|
139
|
+
/** the length the file wrote, when a section fitted this shot to another */
|
|
140
|
+
written?: number
|
|
141
|
+
film: Film | null
|
|
142
|
+
titles: Title[]
|
|
143
|
+
sfx: Sfx[]
|
|
144
|
+
voices: Voice[]
|
|
145
|
+
cut: Cut
|
|
146
|
+
/** seconds of footage skipped at its start */
|
|
147
|
+
from: number
|
|
148
|
+
/** a slow push in to this zoom over the shot */
|
|
149
|
+
push: number | null
|
|
150
|
+
/** the footage cropped about its middle by this factor */
|
|
151
|
+
frame: number | null
|
|
152
|
+
/** the shot's dark tones lifted by this gamma, 1 to 2 */
|
|
153
|
+
lift?: number
|
|
154
|
+
/** the point the crop is taken about, 0 to 1 across and down the footage; absent: its middle */
|
|
155
|
+
frameAt?: [number, number]
|
|
156
|
+
shakes: Shake[]
|
|
157
|
+
flashes: Flash[]
|
|
158
|
+
/** the music changes as the shot starts: a crossfade over its fade-in, `none` for silence */
|
|
159
|
+
music: Music | null
|
|
160
|
+
/** its own line failed: nothing else is checked against it */
|
|
161
|
+
broken?: true
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** the tempo a line counts bars and beats in */
|
|
165
|
+
export interface Clock {
|
|
166
|
+
bpm: number
|
|
167
|
+
/** beats in a bar */
|
|
168
|
+
meter: number
|
|
169
|
+
/** rows a beat is written in */
|
|
170
|
+
grid: number
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface Mark {
|
|
174
|
+
line: number
|
|
175
|
+
name: string
|
|
176
|
+
/** seconds from its section's start */
|
|
177
|
+
at: number
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** a part of the timeline in bars: its shots land on its start, and the stitch caches it as one piece */
|
|
181
|
+
export interface Section {
|
|
182
|
+
line: number
|
|
183
|
+
name: string
|
|
184
|
+
clock: Clock | null
|
|
185
|
+
/** where it starts on the timeline, seconds */
|
|
186
|
+
at: number
|
|
187
|
+
seconds: number
|
|
188
|
+
marks: Mark[]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface Trailer {
|
|
192
|
+
name: string
|
|
193
|
+
size: [number, number]
|
|
194
|
+
fps: number
|
|
195
|
+
music: Music | null
|
|
196
|
+
/** dB the music drops under a voice line, negative */
|
|
197
|
+
duck: number
|
|
198
|
+
/** dB the music drops under each sfx, negative; null: none */
|
|
199
|
+
duckSfx: number | null
|
|
200
|
+
fadeIn: number
|
|
201
|
+
shots: Shot[]
|
|
202
|
+
sections: Section[]
|
|
203
|
+
/** where the timeline of the sections starts on the cut: the first landed shot's start less its moment */
|
|
204
|
+
timelineAt: number | null
|
|
205
|
+
/** the cut is a vertical short (`vertical`), finished with the brand's lockup, watermark and end card */
|
|
206
|
+
vertical: Vertical | null
|
|
207
|
+
/** the global process whose url a `film url` route resolves against (`base <id>`); null: the repo's one */
|
|
208
|
+
base: string | null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface TrailerError {
|
|
212
|
+
line: number
|
|
213
|
+
message: string
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface Word {
|
|
217
|
+
text: string
|
|
218
|
+
quoted: boolean
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line */
|
|
222
|
+
export function tokens(line: string): { words: Word[] } | { error: string } {
|
|
223
|
+
const words: Word[] = []
|
|
224
|
+
let i = 0
|
|
225
|
+
while (i < line.length) {
|
|
226
|
+
const c = line[i]!
|
|
227
|
+
if (c === ' ' || c === '\t') {
|
|
228
|
+
i++
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
if (c === '#') break
|
|
232
|
+
if (c === '"') {
|
|
233
|
+
const end = line.indexOf('"', i + 1)
|
|
234
|
+
if (end < 0) return { error: 'a quote is not closed' }
|
|
235
|
+
words.push({ text: line.slice(i + 1, end), quoted: true })
|
|
236
|
+
i = end + 1
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
let j = i
|
|
240
|
+
while (j < line.length && line[j] !== ' ' && line[j] !== '\t' && line[j] !== '"' && line[j] !== '#') j++
|
|
241
|
+
words.push({ text: line.slice(i, j), quoted: false })
|
|
242
|
+
i = j
|
|
243
|
+
}
|
|
244
|
+
return { words }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
class LineError extends Error {}
|
|
248
|
+
const bad = (message: string): never => {
|
|
249
|
+
throw new LineError(message)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** the clock the line being read counts bars and beats in: each section's, from its line on */
|
|
253
|
+
let clock: Clock | null = null
|
|
254
|
+
|
|
255
|
+
/** `1.5s`, `400ms`, `m:ss(.f)`, and at a clock `2bars`, `1beat`, as seconds; terms add up with `+` (`1bar+0.5beat`) */
|
|
256
|
+
export function parseTime(s: string, c: Clock | null = null): number | null {
|
|
257
|
+
let total = 0
|
|
258
|
+
for (const term of s.split('+')) {
|
|
259
|
+
let m = /^(\d+(?:\.\d+)?)s$/.exec(term)
|
|
260
|
+
if (m) total += Number(m[1])
|
|
261
|
+
else if ((m = /^(\d+)ms$/.exec(term))) total += Number(m[1]) / 1000
|
|
262
|
+
else if ((m = /^(\d+):([0-5]\d(?:\.\d+)?)$/.exec(term))) total += Number(m[1]) * 60 + Number(m[2])
|
|
263
|
+
else if ((m = /^(\d+(?:\.\d+)?)(bars?|beats?)$/.exec(term)) && c) total += (Number(m[1]) * (m[2]!.startsWith('bar') ? c.meter : 1) * 60) / c.bpm
|
|
264
|
+
else return null
|
|
265
|
+
}
|
|
266
|
+
return total
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const TIMES = '1.5s, 400ms, 0:04, 2bars, 1beat, 1bar+2beats'
|
|
270
|
+
const time = (what: string, w: Word | undefined): number => {
|
|
271
|
+
if (!w || w.quoted) return bad(`${what} wants a time (${TIMES})`)
|
|
272
|
+
const v = parseTime(w.text, clock)
|
|
273
|
+
if (v !== null) return v
|
|
274
|
+
if (/bar|beat/.test(w.text) && !clock) return bad(`${what}: ${w.text} counts bars and beats, which need a tempo (section <name> <length> tempo <bpm>)`)
|
|
275
|
+
return bad(`${what} wants a time (${TIMES}), got ${w.text}`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** a section's or a mark's name as an `at` reads it: words of a letter and more, joined by `-` */
|
|
279
|
+
const MOMENT = '[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*'
|
|
280
|
+
|
|
281
|
+
/** a moment named on the timeline, and a time from it */
|
|
282
|
+
interface MarkAt {
|
|
283
|
+
mark: string
|
|
284
|
+
offset: number
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** an `at`: a time, or a section or mark with an offset (`drop`, `drop-1beat`, `title+0.5s`) */
|
|
288
|
+
const when = (w: Word | undefined, k: string): number | MarkAt => {
|
|
289
|
+
const m = w && !w.quoted ? new RegExp(`^(${MOMENT})(?:([+-])(\\d.*))?$`).exec(w.text) : null
|
|
290
|
+
if (!m) return time(k, w)
|
|
291
|
+
const off = m[3] === undefined ? 0 : (parseTime(m[3], clock) ?? bad(`${k}: ${m[3]} after ${m[1]} is no time (${TIMES})`))
|
|
292
|
+
return { mark: m[1]!, offset: m[2] === '-' ? -off : off }
|
|
293
|
+
}
|
|
294
|
+
const num = (what: string, w: Word | undefined, lo: number, hi: number): number => {
|
|
295
|
+
const n = w && !w.quoted ? Number(w.text) : Number.NaN
|
|
296
|
+
if (!Number.isFinite(n) || n < lo || n > hi) return bad(`${what} wants a number from ${lo} to ${hi}${w ? `, got ${w.text}` : ''}`)
|
|
297
|
+
return n
|
|
298
|
+
}
|
|
299
|
+
const word = (what: string, w: Word | undefined, re = /^[a-z0-9][a-z0-9:_.,=+-]*$/i): string => {
|
|
300
|
+
if (!w || w.quoted || !re.test(w.text)) return bad(`${what} wants a word${w ? `, got ${w.quoted ? `"${w.text}"` : w.text}` : ''}`)
|
|
301
|
+
return w.text
|
|
302
|
+
}
|
|
303
|
+
const quoted = (what: string, w: Word | undefined): string => {
|
|
304
|
+
if (!w || !w.quoted) return bad(`${what} wants a quoted text${w ? `, got ${w.text}` : ''}`)
|
|
305
|
+
if (w.text.trim() === '') return bad(`${what}: the text is empty`)
|
|
306
|
+
return w.text
|
|
307
|
+
}
|
|
308
|
+
/** a file path or an id, bare or quoted */
|
|
309
|
+
const pathWord = (what: string, w: Word | undefined): string => {
|
|
310
|
+
if (!w || w.text.trim() === '' || (!w.quoted && /\s/.test(w.text))) return bad(`${what} wants a file${w ? `, got ${w.text}` : ''}`)
|
|
311
|
+
return w.text
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** `WxH` in pixels, each side from `lo` to `hi` */
|
|
315
|
+
const sizeWord = (what: string, w: Word | undefined, lo: number, hi: number): [number, number] => {
|
|
316
|
+
const m = w && !w.quoted ? /^(\d+)x(\d+)$/.exec(w.text) : null
|
|
317
|
+
const [x, y] = [Number(m?.[1]), Number(m?.[2])]
|
|
318
|
+
if (!m || x < lo || y < lo || x > hi || y > hi) return bad(`${what} wants WxH, from ${lo} to ${hi} a side${w ? `, got ${w.text}` : ''}`)
|
|
319
|
+
return [x, y]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** the `key value` pairs after a line's fixed words; `flags` take no value */
|
|
323
|
+
function pairs(
|
|
324
|
+
what: string,
|
|
325
|
+
rest: Word[],
|
|
326
|
+
valued: Record<string, (w: Word | undefined, key: string) => unknown>,
|
|
327
|
+
flags: readonly string[] = [],
|
|
328
|
+
): Record<string, unknown> {
|
|
329
|
+
const out: Record<string, unknown> = {}
|
|
330
|
+
for (let i = 0; i < rest.length; i++) {
|
|
331
|
+
const k = rest[i]!
|
|
332
|
+
if (k.quoted) bad(`${what}: a quoted text "${k.text}" where a word was expected`)
|
|
333
|
+
if (k.text in out) bad(`${what}: ${k.text} given twice`)
|
|
334
|
+
if (flags.includes(k.text)) {
|
|
335
|
+
out[k.text] = true
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
const f = valued[k.text]
|
|
339
|
+
if (!f) bad(`${what}: unknown word ${k.text} (${what} takes ${[...Object.keys(valued), ...flags].join(', ') || 'nothing more'})`)
|
|
340
|
+
out[k.text] = f!(rest[++i], `${what} ${k.text}`)
|
|
341
|
+
}
|
|
342
|
+
return out
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** the film row a line's words start with: the longest row path at their head */
|
|
346
|
+
export function verbOf(words: readonly string[], verbs: readonly FilmVerb[]): FilmVerb | null {
|
|
347
|
+
let best: FilmVerb | null = null
|
|
348
|
+
for (const v of verbs)
|
|
349
|
+
if (v.path.length <= words.length && v.path.every((p, i) => words[i] === p) && v.path.length > (best?.path.length ?? 0)) best = v
|
|
350
|
+
return best
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** what a film line may start with in this repo, for its errors */
|
|
354
|
+
const filmWords = (verbs: readonly FilmVerb[] | null): string =>
|
|
355
|
+
`${CORE_FILMS.join(', ')}${verbs === null ? '; no project CLI answers `<cli> commands --json` (the `cli` script of package.json)' : verbs.length === 0 ? '; the project CLI has no film row' : `, or a film row of the project CLI: ${verbs.map((v) => v.path.join(' ')).join(', ')}`}`
|
|
356
|
+
|
|
357
|
+
/** 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
|
+
* CLI (its `clip <scene>` row), else the core kind's error */
|
|
359
|
+
function filmLine(ws: Word[], line: number, verbs: readonly FilmVerb[] | null): Film {
|
|
360
|
+
const kind = ws[0] && !ws[0].quoted ? ws[0].text : ''
|
|
361
|
+
const argv = ws.map((w) => w.text)
|
|
362
|
+
const v = verbs && verbOf(argv, verbs)
|
|
363
|
+
if ((CORE_FILMS as readonly string[]).includes(kind)) {
|
|
364
|
+
try {
|
|
365
|
+
return coreFilm(kind, ws, line)
|
|
366
|
+
} catch (e) {
|
|
367
|
+
if (!(e instanceof LineError) || !v) throw e
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!v) return bad(`film: unknown kind ${kind || '(none)'} (${filmWords(verbs)})`)
|
|
371
|
+
if (argv.some((a) => a === '--out' || a.startsWith('--out=') || a === '--seconds' || a.startsWith('--seconds='))) bad(`film ${v.path.join(' ')}: the trailer gives --out and --seconds itself`)
|
|
372
|
+
return { kind: 'cli', line, argv, output: v.film }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function coreFilm(kind: string, ws: Word[], line: number): Film {
|
|
376
|
+
switch (kind) {
|
|
377
|
+
case 'clip': {
|
|
378
|
+
const path = pathWord('film clip', ws[1])
|
|
379
|
+
if (!/\.(mp4|webm|mov|mkv)$/i.test(path)) bad(`film clip wants a video file (.mp4, .webm, .mov, .mkv), got ${path}`)
|
|
380
|
+
pairs(`film clip ${path}`, ws.slice(2), {})
|
|
381
|
+
return { kind, line, path }
|
|
382
|
+
}
|
|
383
|
+
case 'image': {
|
|
384
|
+
const path = pathWord('film image', ws[1])
|
|
385
|
+
if (!/\.(png|jpe?g|webp)$/i.test(path)) bad(`film image wants an image file (.png, .jpg, .webp), got ${path}`)
|
|
386
|
+
pairs(`film image ${path}`, ws.slice(2), {})
|
|
387
|
+
return { kind, line, path }
|
|
388
|
+
}
|
|
389
|
+
case 'card':
|
|
390
|
+
pairs('film card', ws.slice(1), {})
|
|
391
|
+
return { kind, line }
|
|
392
|
+
case 'run': {
|
|
393
|
+
const cmd = quoted('film run', ws[1])
|
|
394
|
+
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 } : {}) }
|
|
397
|
+
}
|
|
398
|
+
case 'url': {
|
|
399
|
+
const route = pathWord('film url', ws[1])
|
|
400
|
+
if (!/^(\/|https?:\/\/)/.test(route)) bad(`film url wants a route of the dev url (/pricing) or an http url, got ${route}`)
|
|
401
|
+
const p = pairs(`film url ${route}`, ws.slice(2), { viewport: (w, k) => sizeWord(k, w, 16, 7680), wait: at }, ['still'])
|
|
402
|
+
return {
|
|
403
|
+
kind,
|
|
404
|
+
line,
|
|
405
|
+
route,
|
|
406
|
+
...(p.viewport ? { viewport: p.viewport as [number, number] } : {}),
|
|
407
|
+
...(p.wait !== undefined ? { wait: p.wait as number } : {}),
|
|
408
|
+
...(p.still ? { still: true as const } : {}),
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return bad(`film: unknown kind ${kind}`)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const volume = (w: Word | undefined, k: string) => num(k, w, 0, 4)
|
|
416
|
+
const at = (w: Word | undefined, k: string) => time(k, w)
|
|
417
|
+
|
|
418
|
+
/** a music cue that is silence */
|
|
419
|
+
export const NO_MUSIC = 'none'
|
|
420
|
+
|
|
421
|
+
/** the cut's opening cue fades in this long when it starts inside its song and names no fade-in */
|
|
422
|
+
export const OPENING_FADE_IN = 0.25
|
|
423
|
+
|
|
424
|
+
/** `music <file|id|none> [take k] [from t] [fade-in t] [fade-out t] [volume v]`; `opening`: the cut's own */
|
|
425
|
+
function musicLine(rest: Word[], n: number, opening = false): Music {
|
|
426
|
+
const song = pathWord('music', rest[0])
|
|
427
|
+
const p = pairs(`music ${song}`, rest.slice(1), {
|
|
428
|
+
take: (w, k) => word(k, w, /^[a-z0-9][a-z0-9-]*$/),
|
|
429
|
+
from: at,
|
|
430
|
+
'fade-in': at,
|
|
431
|
+
'fade-out': at,
|
|
432
|
+
volume,
|
|
433
|
+
})
|
|
434
|
+
return {
|
|
435
|
+
line: n,
|
|
436
|
+
song,
|
|
437
|
+
...(p.take ? { take: p.take as string } : {}),
|
|
438
|
+
from: (p.from as number) ?? 0,
|
|
439
|
+
fadeIn: (p['fade-in'] as number | undefined) ?? (opening && ((p.from as number | undefined) ?? 0) > 0 ? OPENING_FADE_IN : 0),
|
|
440
|
+
fadeOut: (p['fade-out'] as number) ?? 0,
|
|
441
|
+
volume: (p.volume as number) ?? 1,
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
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
|
+
/**
|
|
458
|
+
* the score's TypeScript (a margin `code` line and its indented block, a `play` line and the deeper lines that continue
|
|
459
|
+
* it) blanked before the words are read, each block reported once at its first line: every line keeps its number
|
|
460
|
+
*/
|
|
461
|
+
export function scoreBlocks(text: string): { lines: string[]; errors: TrailerError[] } {
|
|
462
|
+
const lines = text.split('\n')
|
|
463
|
+
const errors: TrailerError[] = []
|
|
464
|
+
const indent = (l: string): number => l.length - l.trimStart().length
|
|
465
|
+
const off = (l: string): boolean => l.trimStart().startsWith('#')
|
|
466
|
+
for (let i = 0; i < lines.length; i++) {
|
|
467
|
+
const l = lines[i]!
|
|
468
|
+
const code = /^code\s*(#.*)?$/.test(l)
|
|
469
|
+
const play = /^(\s*)play(\s|$)/.exec(l)
|
|
470
|
+
if ((!code && !play) || off(l)) continue
|
|
471
|
+
errors.push({ line: i + 1, message: notCore(code ? 'code' : 'play') })
|
|
472
|
+
lines[i] = ''
|
|
473
|
+
const base = code ? 0 : play![1]!.length
|
|
474
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
475
|
+
const b = lines[j]!
|
|
476
|
+
const close = !code && indent(b) === base && /^[)\]}]/.test(b.trimStart())
|
|
477
|
+
if (b.trim() !== '' && !off(b) && indent(b) <= base && !close) break
|
|
478
|
+
lines[j] = ''
|
|
479
|
+
i = j
|
|
480
|
+
if (close) break
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return { lines, errors }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export interface ParseOptions {
|
|
487
|
+
/** the project CLI's film rows; null when the repo has no project CLI */
|
|
488
|
+
verbs?: readonly FilmVerb[] | null
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** the file as a trailer, and every error with its line */
|
|
492
|
+
export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Trailer; errors: TrailerError[] } {
|
|
493
|
+
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 }
|
|
495
|
+
const errors: TrailerError[] = []
|
|
496
|
+
const seen = new Set<string>()
|
|
497
|
+
let shot: Shot | null = null
|
|
498
|
+
let pending: SectionRef | null = null
|
|
499
|
+
let current: Section | null = null
|
|
500
|
+
let scoreClock: Clock | null = null
|
|
501
|
+
let skipping = false
|
|
502
|
+
/** the `at`s that name a mark, set once every shot has landed */
|
|
503
|
+
const refs: { line: number; shot: Shot; ref: MarkAt; set: (at: number) => void }[] = []
|
|
504
|
+
const landAt = (v: unknown, line: number, s: Shot, set: (at: number) => void): number => {
|
|
505
|
+
if (typeof v !== 'object' || v === null) return (v as number | undefined) ?? 0
|
|
506
|
+
refs.push({ line, shot: s, ref: v as MarkAt, set })
|
|
507
|
+
return 0
|
|
508
|
+
}
|
|
509
|
+
clock = null
|
|
510
|
+
const pre = scoreBlocks(text)
|
|
511
|
+
errors.push(...pre.errors)
|
|
512
|
+
const lines = pre.lines
|
|
513
|
+
for (let n = 1; n <= lines.length; n++) {
|
|
514
|
+
const tk = tokens(lines[n - 1]!)
|
|
515
|
+
if ('error' in tk) {
|
|
516
|
+
errors.push({ line: n, message: tk.error })
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
519
|
+
const ws = tk.words
|
|
520
|
+
if (ws.length === 0) continue
|
|
521
|
+
const head = ws[0]!
|
|
522
|
+
const rest = ws.slice(1)
|
|
523
|
+
const once = (key: string): void => {
|
|
524
|
+
if (seen.has(key)) bad(`${key} given twice`)
|
|
525
|
+
seen.add(key)
|
|
526
|
+
}
|
|
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
|
+
try {
|
|
531
|
+
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
|
+
if (head.text === 'section') {
|
|
547
|
+
const name = word('section', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
548
|
+
const last = t.sections.at(-1)
|
|
549
|
+
if (t.sections.some((x) => x.name === name)) bad(`section ${name} is already a section of this file`)
|
|
550
|
+
const p = pairs(`section ${name}`, rest.slice(2), {
|
|
551
|
+
tempo: (w, k) => num(k, w, 20, 400),
|
|
552
|
+
meter: (w, k) => num(k, w, 1, 16),
|
|
553
|
+
grid: (w, k) => num(k, w, 1, 32),
|
|
554
|
+
})
|
|
555
|
+
const base = last?.clock ?? scoreClock
|
|
556
|
+
if (!base && !p.tempo && (p.meter || p.grid)) bad(`section ${name}: meter and grid count in a tempo, give it tempo <bpm>`)
|
|
557
|
+
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
|
+
const seconds = +time(`section ${name}`, rest[1]).toFixed(6)
|
|
559
|
+
if (seconds <= 0) bad(`section ${name}: its length must be above 0`)
|
|
560
|
+
const at = last ? +(last.at + last.seconds).toFixed(6) : 0
|
|
561
|
+
current = { line: n, name, clock, at, seconds, marks: [] }
|
|
562
|
+
t.sections.push(current)
|
|
563
|
+
pending = { line: n, name, at }
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
if (head.text === 'mark') {
|
|
567
|
+
const sec = current ?? bad('a mark is a moment of a section: write it under one')
|
|
568
|
+
const name = word('mark', rest[0], new RegExp(`^${MOMENT}$`))
|
|
569
|
+
if (rest[1]?.text !== 'at' || rest.length !== 3) bad(`mark ${name} wants at <time>, from its section's start`)
|
|
570
|
+
if (t.sections.some((x) => x.name === name || x.marks.some((m) => m.name === name))) bad(`mark ${name}: ${name} already names a section or a mark`)
|
|
571
|
+
const at = time(`mark ${name}`, rest[2])
|
|
572
|
+
if (at >= sec.seconds) bad(`mark ${name} at ${+at.toFixed(3)} s is past section ${sec.name}'s ${+sec.seconds.toFixed(3)} s`)
|
|
573
|
+
sec.marks.push({ line: n, name, at })
|
|
574
|
+
continue
|
|
575
|
+
}
|
|
576
|
+
if (head.text === 'shot') {
|
|
577
|
+
// a bad shot line still opens a shot, so its own lines never read as the header's or the shot before's
|
|
578
|
+
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
|
+
shot = open
|
|
580
|
+
t.shots.push(open)
|
|
581
|
+
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`)
|
|
583
|
+
const length = time(`shot ${name}`, rest[1])
|
|
584
|
+
if (length <= 0) bad(`shot ${name}: its length must be above 0`)
|
|
585
|
+
if (rest.length > 2) bad(`shot ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a shot line is shot <name> <length>)`)
|
|
586
|
+
open.name = name
|
|
587
|
+
open.length = length
|
|
588
|
+
if (pending) open.section = pending
|
|
589
|
+
pending = null
|
|
590
|
+
delete open.broken
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
if (shot === null) {
|
|
594
|
+
switch (head.text) {
|
|
595
|
+
case 'trailer':
|
|
596
|
+
once('trailer')
|
|
597
|
+
t.name = word('trailer', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
598
|
+
if (rest.length > 1) bad(`trailer ${t.name}: unknown word ${rest[1]?.text}`)
|
|
599
|
+
break
|
|
600
|
+
case 'size': {
|
|
601
|
+
once('size')
|
|
602
|
+
const m = rest[0] && !rest[0].quoted ? /^(\d+)x(\d+)$/.exec(rest[0].text) : null
|
|
603
|
+
const w = Number(m?.[1])
|
|
604
|
+
const h = Number(m?.[2])
|
|
605
|
+
if (!m || w < 160 || h < 90 || w > 3840 || h > 3840 || w % 2 || h % 2)
|
|
606
|
+
bad(`size wants WxH, even, from 160x90 to 3840x3840${rest[0] ? `, got ${rest[0].text}` : ''}`)
|
|
607
|
+
t.size = [w, h]
|
|
608
|
+
break
|
|
609
|
+
}
|
|
610
|
+
case 'fps':
|
|
611
|
+
once('fps')
|
|
612
|
+
t.fps = num('fps', rest[0], 1, FPS_MAX)
|
|
613
|
+
if (!Number.isInteger(t.fps)) bad(`fps wants a whole number, got ${rest[0]?.text}`)
|
|
614
|
+
break
|
|
615
|
+
case 'music':
|
|
616
|
+
once('music')
|
|
617
|
+
t.music = musicLine(rest, n, true)
|
|
618
|
+
if (t.music.song === NO_MUSIC) bad('music none is a shot\'s cue, the header names the song the cut opens on')
|
|
619
|
+
break
|
|
620
|
+
case 'duck': {
|
|
621
|
+
once('duck')
|
|
622
|
+
const drop = (w: Word | undefined, what: string): number => {
|
|
623
|
+
const m = w && !w.quoted ? /^(-?\d+(?:\.\d+)?)(?:dB)?$/.exec(w.text) : null
|
|
624
|
+
const db = Number(m?.[1])
|
|
625
|
+
if (!m || db > 0 || db < -40) bad(`${what} wants a drop in dB from -40 to 0 (-10dB)${w ? `, got ${w.text}` : ''}`)
|
|
626
|
+
return db
|
|
627
|
+
}
|
|
628
|
+
t.duck = drop(rest[0], 'duck')
|
|
629
|
+
if (rest.length > 1) {
|
|
630
|
+
if (rest[1]!.text !== 'sfx' || rest.length !== 3) bad(`duck takes its drop, then sfx and the drop under each sfx (duck -10dB sfx -5dB), got ${rest.slice(1).map((w) => w.text).join(' ')}`)
|
|
631
|
+
t.duckSfx = drop(rest[2], 'duck sfx')
|
|
632
|
+
}
|
|
633
|
+
break
|
|
634
|
+
}
|
|
635
|
+
case 'fade-in':
|
|
636
|
+
once('fade-in')
|
|
637
|
+
t.fadeIn = time('fade-in', rest[0])
|
|
638
|
+
break
|
|
639
|
+
case 'vertical': {
|
|
640
|
+
once('vertical')
|
|
641
|
+
const p = pairs('vertical', rest, {
|
|
642
|
+
mark: (w, k) => {
|
|
643
|
+
const c = word(k, w)
|
|
644
|
+
return c === 'left' || c === 'right' ? c : bad(`${k} wants left|right, got ${c}`)
|
|
645
|
+
},
|
|
646
|
+
outro: (w, k) => {
|
|
647
|
+
const o = pathWord(k, w)
|
|
648
|
+
if (o === 'card' || o === 'none') return o
|
|
649
|
+
if (!/\.(mp4|webm|mov|mkv)$/i.test(o)) bad(`${k} wants card, none or a video file, got ${o}`)
|
|
650
|
+
return { clip: o }
|
|
651
|
+
},
|
|
652
|
+
})
|
|
653
|
+
t.vertical = { line: n, outro: (p.outro as Vertical['outro'] | undefined) ?? 'card', ...(p.mark ? { mark: p.mark as 'left' | 'right' } : {}) }
|
|
654
|
+
break
|
|
655
|
+
}
|
|
656
|
+
case 'base':
|
|
657
|
+
once('base')
|
|
658
|
+
t.base = word('base', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
659
|
+
if (rest.length > 1) bad(`base ${t.base}: unknown word ${rest[1]?.text} (base <the id of a global process of this repo>)`)
|
|
660
|
+
break
|
|
661
|
+
default:
|
|
662
|
+
bad(`unknown word ${head.text} before the first shot (trailer, size, fps, music, duck, fade-in, vertical, base, section, shot)`)
|
|
663
|
+
}
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
const s: Shot = shot
|
|
667
|
+
switch (head.text) {
|
|
668
|
+
case 'film':
|
|
669
|
+
if (s.film) bad(`shot ${s.name} already films on line ${s.film.line}: one film line a shot`)
|
|
670
|
+
s.film = filmLine(rest, n, verbs)
|
|
671
|
+
break
|
|
672
|
+
case 'title': {
|
|
673
|
+
const text = quoted('title', rest[0])
|
|
674
|
+
const p = pairs('title', rest.slice(1), {
|
|
675
|
+
at: when,
|
|
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
|
+
})
|
|
687
|
+
const ti: Title = {
|
|
688
|
+
line: n,
|
|
689
|
+
text,
|
|
690
|
+
at: 0,
|
|
691
|
+
for: (p.for as number) ?? null,
|
|
692
|
+
style: (p.style as TitleStyle) ?? 'caption',
|
|
693
|
+
...(p.place ? { place: p.place as 'top' | 'bottom' } : {}),
|
|
694
|
+
...(p.knock !== undefined ? { knock: p.knock as number } : {}),
|
|
695
|
+
}
|
|
696
|
+
ti.at = landAt(p.at, n, s, (v) => (ti.at = v))
|
|
697
|
+
if (ti.knock !== undefined && (ti.knock <= ti.at || (ti.for !== null && ti.knock >= ti.at + ti.for)))
|
|
698
|
+
bad(`title knock ${ti.knock}s: inside the title's own span, after its at`)
|
|
699
|
+
s.titles.push(ti)
|
|
700
|
+
break
|
|
701
|
+
}
|
|
702
|
+
case 'sfx': {
|
|
703
|
+
const id = pathWord('sfx', rest[0])
|
|
704
|
+
const p = pairs(`sfx ${id}`, rest.slice(1), { at: when, volume })
|
|
705
|
+
const x: Sfx = { line: n, id, at: 0, volume: (p.volume as number) ?? 1 }
|
|
706
|
+
x.at = landAt(p.at, n, s, (v) => (x.at = v))
|
|
707
|
+
s.sfx.push(x)
|
|
708
|
+
break
|
|
709
|
+
}
|
|
710
|
+
case 'voice': {
|
|
711
|
+
const fileForm = rest[0] && !rest[0].quoted && rest[0].text === 'file'
|
|
712
|
+
const src = fileForm ? { file: pathWord('voice file', rest[1]) } : { text: quoted('voice', rest[0]) }
|
|
713
|
+
const p = pairs('voice', rest.slice(fileForm ? 2 : 1), { at: when, volume })
|
|
714
|
+
const v: Voice = { line: n, ...src, at: 0, volume: (p.volume as number) ?? 1 }
|
|
715
|
+
v.at = landAt(p.at, n, s, (x) => (v.at = x))
|
|
716
|
+
s.voices.push(v)
|
|
717
|
+
break
|
|
718
|
+
}
|
|
719
|
+
case 'cut': {
|
|
720
|
+
if (s.cut.line !== s.line) bad(`shot ${s.name} already cuts on line ${s.cut.line}`)
|
|
721
|
+
const k = word('cut', rest[0])
|
|
722
|
+
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
|
|
724
|
+
if (k === 'hard' && rest[1]) bad('cut hard takes no time')
|
|
725
|
+
if (rest.length > 2) bad(`cut ${k}: unknown word ${rest[2]?.text}`)
|
|
726
|
+
s.cut = { line: n, kind: k as CutKind, seconds }
|
|
727
|
+
break
|
|
728
|
+
}
|
|
729
|
+
case 'music':
|
|
730
|
+
if (s.music) bad(`shot ${s.name} already changes the music on line ${s.music.line}`)
|
|
731
|
+
s.music = musicLine(rest, n)
|
|
732
|
+
break
|
|
733
|
+
case 'starts': {
|
|
734
|
+
if (s.lands) bad(`shot ${s.name} already lands on line ${s.lands.line}`)
|
|
735
|
+
if (rest[0]?.text !== 'on' || !rest[1] || rest.length > 3) bad('starts wants on <section|mark> [+|-<time>]')
|
|
736
|
+
const on = word('starts on', rest[1], /^[a-z0-9][a-z0-9-]*$/)
|
|
737
|
+
const o2 = rest[2] && !rest[2].quoted ? /^([+-])(.+)$/.exec(rest[2].text) : null
|
|
738
|
+
if (rest[2] && !o2) bad(`starts on ${on}: the time from it is signed, +0.5s or -1beat, got ${rest[2].text}`)
|
|
739
|
+
const off = o2 ? time(`starts on ${on}`, { text: o2[2]!, quoted: false }) : 0
|
|
740
|
+
s.lands = { line: n, on, offset: o2?.[1] === '-' ? -off : off }
|
|
741
|
+
break
|
|
742
|
+
}
|
|
743
|
+
case 'from':
|
|
744
|
+
s.from = time('from', rest[0])
|
|
745
|
+
if (rest.length > 1) bad(`from: unknown word ${rest[1]?.text}`)
|
|
746
|
+
break
|
|
747
|
+
case 'push':
|
|
748
|
+
s.push = num('push', rest[0], 1, 3)
|
|
749
|
+
if (rest.length > 1) bad(`push: unknown word ${rest[1]?.text}`)
|
|
750
|
+
break
|
|
751
|
+
case 'lift':
|
|
752
|
+
s.lift = num('lift', rest[0], 1, 2)
|
|
753
|
+
if (rest.length > 1) bad(`lift: unknown word ${rest[1]?.text}`)
|
|
754
|
+
break
|
|
755
|
+
case 'frame':
|
|
756
|
+
if (s.frame !== null) bad(`shot ${s.name} already frames its footage`)
|
|
757
|
+
s.frame = num('frame', rest[0], 1, 3)
|
|
758
|
+
if (rest[1]?.text === 'at' && !rest[1].quoted) {
|
|
759
|
+
const m = /^(\d*\.?\d+),(\d*\.?\d+)$/.exec(rest[2] && !rest[2].quoted ? rest[2].text : '')
|
|
760
|
+
if (!m || Number(m[1]) > 1 || Number(m[2]) > 1) bad(`frame at wants a point <x>,<y>, each 0 to 1 across and down the frame${rest[2] ? `, got ${rest[2].text}` : ''}`)
|
|
761
|
+
else s.frameAt = [Number(m[1]), Number(m[2])]
|
|
762
|
+
if (rest.length > 3) bad(`frame: unknown word ${rest[3]?.text}`)
|
|
763
|
+
} else if (rest.length > 1) bad(`frame: unknown word ${rest[1]?.text}`)
|
|
764
|
+
break
|
|
765
|
+
case 'shake': {
|
|
766
|
+
const p = pairs('shake', rest, { at: when, for: at, strength: (w, k) => num(k, w, 0.2, 3) })
|
|
767
|
+
const x: Shake = { line: n, at: 0, for: (p.for as number) ?? 0.4, strength: (p.strength as number) ?? 1 }
|
|
768
|
+
x.at = landAt(p.at, n, s, (v) => (x.at = v))
|
|
769
|
+
s.shakes.push(x)
|
|
770
|
+
break
|
|
771
|
+
}
|
|
772
|
+
case 'flash': {
|
|
773
|
+
const p = pairs('flash', rest, { at: when, for: at })
|
|
774
|
+
const x: Flash = { line: n, at: 0, for: (p.for as number) ?? 0.25 }
|
|
775
|
+
x.at = landAt(p.at, n, s, (v) => (x.at = v))
|
|
776
|
+
s.flashes.push(x)
|
|
777
|
+
break
|
|
778
|
+
}
|
|
779
|
+
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)`)
|
|
781
|
+
}
|
|
782
|
+
} catch (e) {
|
|
783
|
+
if (!(e instanceof LineError)) throw e
|
|
784
|
+
errors.push({ line: n, message: e.message })
|
|
785
|
+
if (shot && head.text === 'film') shot.broken = true
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (!seen.has('trailer')) errors.push({ line: 1, message: 'the file names its trailer first: trailer <name>' })
|
|
789
|
+
if (t.shots.length === 0) errors.push({ line: lines.length, message: 'no shot: a trailer has at least one shot <name> <length>' })
|
|
790
|
+
if (t.vertical && !seen.has('size')) t.size = [...VERTICAL_SIZE]
|
|
791
|
+
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
|
+
clock = null
|
|
793
|
+
errors.push(...landShots(t))
|
|
794
|
+
for (const r of refs) {
|
|
795
|
+
const i = t.shots.indexOf(r.shot)
|
|
796
|
+
const m = momentOf(t, r.ref.mark)
|
|
797
|
+
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
|
+
else r.set(+(t.timelineAt + m + r.ref.offset - (starts(t)[i] ?? 0)).toFixed(4))
|
|
799
|
+
}
|
|
800
|
+
errors.push(...structure(t))
|
|
801
|
+
return { trailer: t, errors: errors.sort((a, b) => a.line - b.line) }
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** the least a fitted shot keeps past its cut out */
|
|
805
|
+
const FIT_MIN_S = 0.3
|
|
806
|
+
|
|
807
|
+
/** a section's start, or a mark's moment, in the timeline's seconds; null when no section or mark has that name */
|
|
808
|
+
function momentOf(t: Pick<Trailer, 'sections'>, name: string): number | null {
|
|
809
|
+
for (const sec of t.sections) {
|
|
810
|
+
if (sec.name === name) return sec.at
|
|
811
|
+
const m = sec.marks.find((x) => x.name === name)
|
|
812
|
+
if (m) return sec.at + m.at
|
|
813
|
+
}
|
|
814
|
+
return null
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* every shot that lands on the timeline starts at its moment: the first one sets where the timeline starts on the cut,
|
|
819
|
+
* and the shot before each later one takes the length that lands it (fitted). An edit inside a section moves nothing
|
|
820
|
+
* past the next landed shot
|
|
821
|
+
*/
|
|
822
|
+
function landShots(t: Trailer): TrailerError[] {
|
|
823
|
+
const out: TrailerError[] = []
|
|
824
|
+
t.shots.forEach((s, i) => {
|
|
825
|
+
if (s.broken) return
|
|
826
|
+
const land = s.lands ?? (s.section ? { line: s.section.line, on: s.section.name, offset: 0 } : null)
|
|
827
|
+
if (!land) return
|
|
828
|
+
const moment = momentOf(t, land.on)
|
|
829
|
+
if (moment === null) {
|
|
830
|
+
out.push({ line: land.line, message: `starts on ${land.on}: no section or mark ${land.on} in this file` })
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
const st = starts(t)
|
|
834
|
+
if (t.timelineAt === null) {
|
|
835
|
+
t.timelineAt = +((st[i] ?? 0) - moment - land.offset).toFixed(4)
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
const prev = t.shots[i - 1]
|
|
839
|
+
if (!prev || prev.broken) {
|
|
840
|
+
out.push({ line: land.line, message: `shot ${s.name}: no shot before it to fit` })
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
const length = prev.length + (t.timelineAt + moment + land.offset - (st[i] ?? 0))
|
|
844
|
+
if (length - prev.cut.seconds < FIT_MIN_S) {
|
|
845
|
+
out.push({ line: land.line, message: `shot ${s.name} lands on ${land.on}: shot ${prev.name} would last ${+length.toFixed(2)} s, too short; the shots before run ${+(prev.length - length).toFixed(2)} s long` })
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
prev.written ??= prev.length
|
|
849
|
+
prev.length = +length.toFixed(4)
|
|
850
|
+
})
|
|
851
|
+
return out
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** a film that is one picture held for the shot */
|
|
855
|
+
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')
|
|
857
|
+
|
|
858
|
+
/** what the lines say together: a film per shot, titles inside their shot, cuts shorter than what they join */
|
|
859
|
+
function structure(t: Trailer): TrailerError[] {
|
|
860
|
+
const out: TrailerError[] = []
|
|
861
|
+
t.shots.forEach((s, i) => {
|
|
862
|
+
if (s.broken) return
|
|
863
|
+
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)` })
|
|
865
|
+
// a title may run on over the shots after its own (a caption over a montage), never past the cut's end
|
|
866
|
+
const left = total(t) - (starts(t)[i] ?? 0)
|
|
867
|
+
for (const ti of s.titles) {
|
|
868
|
+
if (ti.at >= s.length) out.push({ line: ti.line, message: `title at ${ti.at} s starts past shot ${s.name}'s ${s.length} s` })
|
|
869
|
+
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
|
+
}
|
|
871
|
+
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` })
|
|
872
|
+
for (const x of [...s.shakes, ...s.flashes]) if (x.at >= s.length) out.push({ line: x.line, message: `${'strength' in x ? 'shake' : 'flash'} at ${x.at} s starts past shot ${s.name}'s ${s.length} s` })
|
|
873
|
+
const next = t.shots[i + 1]
|
|
874
|
+
if (s.cut.seconds > 0) {
|
|
875
|
+
if (s.cut.seconds >= s.length) out.push({ line: s.cut.line, message: `cut ${s.cut.kind} ${s.cut.seconds} s is as long as shot ${s.name} or longer` })
|
|
876
|
+
else if (next && !next.broken && s.cut.seconds >= next.length) out.push({ line: s.cut.line, message: `cut ${s.cut.kind} ${s.cut.seconds} s is as long as the next shot ${next.name} or longer` })
|
|
877
|
+
if (!next && !['fade', 'dip-black', 'dip-white'].includes(s.cut.kind)) out.push({ line: s.cut.line, message: `the last shot ends on fade, dip-black or dip-white, never ${s.cut.kind}` })
|
|
878
|
+
}
|
|
879
|
+
const prev = t.shots[i - 1]
|
|
880
|
+
if (prev && next && prev.cut.seconds < s.length && s.cut.seconds < s.length && prev.cut.seconds + s.cut.seconds >= s.length)
|
|
881
|
+
out.push({ line: s.line, message: `shot ${s.name} is all cut: the cuts in and out (${prev.cut.seconds} s + ${s.cut.seconds} s) leave nothing of its ${s.length} s` })
|
|
882
|
+
})
|
|
883
|
+
const music = t.music
|
|
884
|
+
if (music && music.fadeIn + music.fadeOut > total(t)) out.push({ line: music.line, message: `music fades for longer than the cut's ${total(t).toFixed(2)} s` })
|
|
885
|
+
return out
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** each shot's start on the cut: a cut overlaps the two shots it joins */
|
|
889
|
+
export function starts(t: Pick<Trailer, 'shots'>): number[] {
|
|
890
|
+
const out: number[] = []
|
|
891
|
+
let at = 0
|
|
892
|
+
for (const s of t.shots) {
|
|
893
|
+
out.push(at)
|
|
894
|
+
at += s.length - s.cut.seconds
|
|
895
|
+
}
|
|
896
|
+
return out
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** the cut's length: the last shot's ending is inside its own length */
|
|
900
|
+
export function total(t: Pick<Trailer, 'shots'>): number {
|
|
901
|
+
const last = t.shots.at(-1)
|
|
902
|
+
if (!last) return 0
|
|
903
|
+
return (starts(t).at(-1) ?? 0) + last.length
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** 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
|
+
* 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 }[] {
|
|
909
|
+
const st = starts(t)
|
|
910
|
+
const raw: { cue: Music; at: number }[] = [...(t.music ? [{ cue: t.music, at: 0 }] : []), ...t.shots.flatMap((s, i) => (s.music ? [{ cue: s.music, at: st[i] ?? 0 }] : []))].sort((a, b) => a.at - b.at)
|
|
911
|
+
const T = total(t)
|
|
912
|
+
return raw
|
|
913
|
+
.map((r, i) => ({ ...r, end: Math.min(T, (raw[i + 1]?.at ?? T) + (raw[i + 1]?.cue.fadeIn ?? 0)) }))
|
|
914
|
+
.filter((r) => r.cue.song !== NO_MUSIC && r.end > r.at)
|
|
915
|
+
}
|