rvlib-shipkit 2.1.1 → 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 +50 -0
- package/package.json +2 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +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_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -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 +167 -0
- package/src/cli.ts +15 -2
- 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 +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- 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/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/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 +12 -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 +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- 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 +233 -0
- package/src/trailer/titles.ts +379 -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 +59 -0
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
import { type LineWord, type Moment, slotText, type Typed, typedFilm, type Words } from './words.ts'
|
|
2
|
+
|
|
3
|
+
// the trailer language, its core words: one text file → the shots, their films, titles, sounds and cuts. Pure: no
|
|
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
|
|
5
|
+
// passes in. Every error carries its line, all at once, and a file with one error never builds
|
|
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']
|
|
10
|
+
export const CUT_KINDS = [
|
|
11
|
+
'hard',
|
|
12
|
+
'fade',
|
|
13
|
+
'dip-black',
|
|
14
|
+
'dip-white',
|
|
15
|
+
'slide-left',
|
|
16
|
+
'slide-right',
|
|
17
|
+
'slide-up',
|
|
18
|
+
'slide-down',
|
|
19
|
+
'whip-left',
|
|
20
|
+
'whip-right',
|
|
21
|
+
'wipe-left',
|
|
22
|
+
'wipe-right',
|
|
23
|
+
'wipe-up',
|
|
24
|
+
'wipe-down',
|
|
25
|
+
'circle',
|
|
26
|
+
'pixel',
|
|
27
|
+
] as const
|
|
28
|
+
export type CutKind = (typeof CUT_KINDS)[number]
|
|
29
|
+
export const CORE_FILMS = ['clip', 'image', 'card', 'run', 'url', 'grid', 'split'] as const
|
|
30
|
+
|
|
31
|
+
/** the most frames a second a cut runs at */
|
|
32
|
+
export const FPS_MAX = 60
|
|
33
|
+
const DEFAULT_CUT_S = 0.5
|
|
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
|
+
|
|
39
|
+
/** a film row of the project CLI: a still writes one image at `--out`, a clip a video of `--seconds` */
|
|
40
|
+
export interface FilmVerb {
|
|
41
|
+
path: string[]
|
|
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'
|
|
51
|
+
description?: string
|
|
52
|
+
}
|
|
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
|
+
|
|
63
|
+
export type Film =
|
|
64
|
+
| { kind: 'clip'; line: number; path: string }
|
|
65
|
+
| { kind: 'image'; line: number; path: string }
|
|
66
|
+
| { kind: 'card'; line: number }
|
|
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
|
+
}
|
|
86
|
+
/** a page of the repo's dev url (a route) or any http url, through the house shot service; `viewport` its css
|
|
87
|
+
* size, `wait` before it films; `still`: a png, else a clip of the page's largest canvas */
|
|
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 }
|
|
94
|
+
|
|
95
|
+
/** the vertical short: a lockup over every frame, a watermark in a bottom corner, then the outro and the end card,
|
|
96
|
+
* each drawn from the brand's `trailer.vertical` data (vertical.ts) */
|
|
97
|
+
export interface Vertical {
|
|
98
|
+
line: number
|
|
99
|
+
/** the watermark's corner, over the brand's own */
|
|
100
|
+
mark?: 'left' | 'right'
|
|
101
|
+
/** what follows the short: the end card (the default), a clip then the end card, or nothing */
|
|
102
|
+
outro: 'card' | 'none' | { clip: string }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** the size a vertical short films at when the file names none */
|
|
106
|
+
export const VERTICAL_SIZE: [number, number] = [1080, 1920]
|
|
107
|
+
|
|
108
|
+
export interface Title {
|
|
109
|
+
line: number
|
|
110
|
+
text: string
|
|
111
|
+
at: number
|
|
112
|
+
/** null: to the shot's end */
|
|
113
|
+
for: number | null
|
|
114
|
+
style: TitleStyle
|
|
115
|
+
/** where a caption sits: the bottom by default */
|
|
116
|
+
place?: 'top' | 'bottom'
|
|
117
|
+
/** knocked off the frame from this second of its shot: it tips and flies out */
|
|
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 }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface Sfx {
|
|
124
|
+
line: number
|
|
125
|
+
/** a file, or an id the repo's sound resolver knows */
|
|
126
|
+
id: string
|
|
127
|
+
at: number
|
|
128
|
+
volume: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface Voice {
|
|
132
|
+
line: number
|
|
133
|
+
/** a line the repo's voice resolver finds by its text, or a recorded file */
|
|
134
|
+
text?: string
|
|
135
|
+
file?: string
|
|
136
|
+
at: number
|
|
137
|
+
volume: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface Shake {
|
|
141
|
+
line: number
|
|
142
|
+
at: number
|
|
143
|
+
for: number
|
|
144
|
+
strength: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface Flash {
|
|
148
|
+
line: number
|
|
149
|
+
at: number
|
|
150
|
+
for: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface Cut {
|
|
154
|
+
line: number
|
|
155
|
+
kind: CutKind
|
|
156
|
+
seconds: number
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** a section as its first shot sees it: the shot starts when the timeline reaches `at` */
|
|
160
|
+
export interface SectionRef {
|
|
161
|
+
line: number
|
|
162
|
+
name: string
|
|
163
|
+
at: number
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface Music {
|
|
167
|
+
line: number
|
|
168
|
+
/** a file, `none`, or an id the repo's sound resolver knows */
|
|
169
|
+
song: string
|
|
170
|
+
take?: string
|
|
171
|
+
from: number
|
|
172
|
+
fadeIn: number
|
|
173
|
+
fadeOut: number
|
|
174
|
+
volume: number
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface Shot {
|
|
178
|
+
line: number
|
|
179
|
+
name: string
|
|
180
|
+
length: number
|
|
181
|
+
/** the shot opens this section: the shot before it is fitted so it lands on its start */
|
|
182
|
+
section?: SectionRef
|
|
183
|
+
/** `starts on`: the moment it lands on, a section or a mark, and a time from it */
|
|
184
|
+
lands?: { line: number; on: string; offset: number }
|
|
185
|
+
/** the length the file wrote, when a section fitted this shot to another */
|
|
186
|
+
written?: number
|
|
187
|
+
film: Film | null
|
|
188
|
+
titles: Title[]
|
|
189
|
+
sfx: Sfx[]
|
|
190
|
+
voices: Voice[]
|
|
191
|
+
cut: Cut
|
|
192
|
+
/** seconds of footage skipped at its start */
|
|
193
|
+
from: number
|
|
194
|
+
/** a slow push in to this zoom over the shot */
|
|
195
|
+
push: number | null
|
|
196
|
+
/** the footage cropped about its middle by this factor */
|
|
197
|
+
frame: number | null
|
|
198
|
+
/** the shot's dark tones lifted by this gamma, 1 to 2 */
|
|
199
|
+
lift?: number
|
|
200
|
+
/** the point the crop is taken about, 0 to 1 across and down the footage; absent: its middle */
|
|
201
|
+
frameAt?: [number, number]
|
|
202
|
+
shakes: Shake[]
|
|
203
|
+
flashes: Flash[]
|
|
204
|
+
/** the music changes as the shot starts: a crossfade over its fade-in, `none` for silence */
|
|
205
|
+
music: Music | null
|
|
206
|
+
/** its own line failed: nothing else is checked against it */
|
|
207
|
+
broken?: true
|
|
208
|
+
/** a `footage` block: filmed, never cut in */
|
|
209
|
+
offcut?: true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** the tempo a line counts bars and beats in */
|
|
213
|
+
export interface Clock {
|
|
214
|
+
bpm: number
|
|
215
|
+
/** beats in a bar */
|
|
216
|
+
meter: number
|
|
217
|
+
/** rows a beat is written in */
|
|
218
|
+
grid: number
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface Mark {
|
|
222
|
+
line: number
|
|
223
|
+
name: string
|
|
224
|
+
/** seconds from its section's start */
|
|
225
|
+
at: number
|
|
226
|
+
}
|
|
227
|
+
|
|
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 */
|
|
230
|
+
export interface Section {
|
|
231
|
+
line: number
|
|
232
|
+
name: string
|
|
233
|
+
clock: Clock | null
|
|
234
|
+
/** where it starts on the timeline, seconds */
|
|
235
|
+
at: number
|
|
236
|
+
seconds: number
|
|
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 }
|
|
267
|
+
}
|
|
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
|
+
|
|
276
|
+
export interface Trailer {
|
|
277
|
+
name: string
|
|
278
|
+
/** `footage` blocks: filmed like shots, never cut in; a grid or a split reads them */
|
|
279
|
+
footage: Shot[]
|
|
280
|
+
size: [number, number]
|
|
281
|
+
fps: number
|
|
282
|
+
music: Music | null
|
|
283
|
+
/** dB the music drops under a voice line, negative */
|
|
284
|
+
duck: number
|
|
285
|
+
/** dB the music drops under each sfx, negative; null: none */
|
|
286
|
+
duckSfx: number | null
|
|
287
|
+
fadeIn: number
|
|
288
|
+
shots: Shot[]
|
|
289
|
+
sections: Section[]
|
|
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 */
|
|
293
|
+
timelineAt: number | null
|
|
294
|
+
/** the cut is a vertical short (`vertical`), finished with the brand's lockup, watermark and end card */
|
|
295
|
+
vertical: Vertical | null
|
|
296
|
+
/** the global process whose url a `film url` route resolves against (`base <id>`); null: the repo's one */
|
|
297
|
+
base: string | null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface TrailerError {
|
|
301
|
+
line: number
|
|
302
|
+
message: string
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
interface Word {
|
|
306
|
+
text: string
|
|
307
|
+
quoted: boolean
|
|
308
|
+
/** an option line's word keeps its own line */
|
|
309
|
+
line?: number
|
|
310
|
+
}
|
|
311
|
+
|
|
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 */
|
|
314
|
+
export function tokens(line: string): { words: Word[] } | { error: string } {
|
|
315
|
+
const words: Word[] = []
|
|
316
|
+
let i = 0
|
|
317
|
+
while (i < line.length) {
|
|
318
|
+
const c = line[i]!
|
|
319
|
+
if (c === ' ' || c === '\t') {
|
|
320
|
+
i++
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
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
|
+
}
|
|
329
|
+
if (c === '"') {
|
|
330
|
+
const end = line.indexOf('"', i + 1)
|
|
331
|
+
if (end < 0) return { error: 'a quote is not closed' }
|
|
332
|
+
words.push({ text: line.slice(i + 1, end), quoted: true })
|
|
333
|
+
i = end + 1
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
let j = i
|
|
337
|
+
while (j < line.length && line[j] !== ' ' && line[j] !== '\t' && line[j] !== '"' && line[j] !== '#') j++
|
|
338
|
+
words.push({ text: line.slice(i, j), quoted: false })
|
|
339
|
+
i = j
|
|
340
|
+
}
|
|
341
|
+
return { words }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
class LineError extends Error {}
|
|
345
|
+
const bad = (message: string): never => {
|
|
346
|
+
throw new LineError(message)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** the clock the line being read counts bars and beats in: each section's, from its line on */
|
|
350
|
+
let clock: Clock | null = null
|
|
351
|
+
|
|
352
|
+
/** `1.5s`, `400ms`, `m:ss(.f)`, and at a clock `2bars`, `1beat`, as seconds; terms add up with `+` (`1bar+0.5beat`) */
|
|
353
|
+
export function parseTime(s: string, c: Clock | null = null): number | null {
|
|
354
|
+
let total = 0
|
|
355
|
+
for (const term of s.split('+')) {
|
|
356
|
+
let m = /^(\d+(?:\.\d+)?)s$/.exec(term)
|
|
357
|
+
if (m) total += Number(m[1])
|
|
358
|
+
else if ((m = /^(\d+)ms$/.exec(term))) total += Number(m[1]) / 1000
|
|
359
|
+
else if ((m = /^(\d+):([0-5]\d(?:\.\d+)?)$/.exec(term))) total += Number(m[1]) * 60 + Number(m[2])
|
|
360
|
+
else if ((m = /^(\d+(?:\.\d+)?)(bars?|beats?)$/.exec(term)) && c) total += (Number(m[1]) * (m[2]!.startsWith('bar') ? c.meter : 1) * 60) / c.bpm
|
|
361
|
+
else return null
|
|
362
|
+
}
|
|
363
|
+
return total
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const TIMES = '1.5s, 400ms, 0:04, 2bars, 1beat, 1bar+2beats'
|
|
367
|
+
const time = (what: string, w: Word | undefined): number => {
|
|
368
|
+
if (!w || w.quoted) return bad(`${what} wants a time (${TIMES})`)
|
|
369
|
+
const v = parseTime(w.text, clock)
|
|
370
|
+
if (v !== null) return v
|
|
371
|
+
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>)`)
|
|
372
|
+
return bad(`${what} wants a time (${TIMES}), got ${w.text}`)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** a section's or a mark's name as an `at` reads it: words of a letter and more, joined by `-` */
|
|
376
|
+
const MOMENT = '[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*'
|
|
377
|
+
|
|
378
|
+
/** a moment named on the timeline, and a time from it */
|
|
379
|
+
interface MarkAt {
|
|
380
|
+
mark: string
|
|
381
|
+
offset: number
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** an `at`: a time, or a section or mark with an offset (`drop`, `drop-1beat`, `title+0.5s`) */
|
|
385
|
+
const when = (w: Word | undefined, k: string): number | MarkAt => {
|
|
386
|
+
const m = w && !w.quoted ? new RegExp(`^(${MOMENT})(?:([+-])(\\d.*))?$`).exec(w.text) : null
|
|
387
|
+
if (!m) return time(k, w)
|
|
388
|
+
const off = m[3] === undefined ? 0 : (parseTime(m[3], clock) ?? bad(`${k}: ${m[3]} after ${m[1]} is no time (${TIMES})`))
|
|
389
|
+
return { mark: m[1]!, offset: m[2] === '-' ? -off : off }
|
|
390
|
+
}
|
|
391
|
+
const num = (what: string, w: Word | undefined, lo: number, hi: number): number => {
|
|
392
|
+
const n = w && !w.quoted ? Number(w.text) : Number.NaN
|
|
393
|
+
if (!Number.isFinite(n) || n < lo || n > hi) return bad(`${what} wants a number from ${lo} to ${hi}${w ? `, got ${w.text}` : ''}`)
|
|
394
|
+
return n
|
|
395
|
+
}
|
|
396
|
+
const word = (what: string, w: Word | undefined, re = /^[a-z0-9][a-z0-9:_.,=+-]*$/i): string => {
|
|
397
|
+
if (!w || w.quoted || !re.test(w.text)) return bad(`${what} wants a word${w ? `, got ${w.quoted ? `"${w.text}"` : w.text}` : ''}`)
|
|
398
|
+
return w.text
|
|
399
|
+
}
|
|
400
|
+
const quoted = (what: string, w: Word | undefined): string => {
|
|
401
|
+
if (!w || !w.quoted) return bad(`${what} wants a quoted text${w ? `, got ${w.text}` : ''}`)
|
|
402
|
+
if (w.text.trim() === '') return bad(`${what}: the text is empty`)
|
|
403
|
+
return w.text
|
|
404
|
+
}
|
|
405
|
+
/** a file path or an id, bare or quoted */
|
|
406
|
+
const pathWord = (what: string, w: Word | undefined): string => {
|
|
407
|
+
if (!w || w.text.trim() === '' || (!w.quoted && /\s/.test(w.text))) return bad(`${what} wants a file${w ? `, got ${w.text}` : ''}`)
|
|
408
|
+
return w.text
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** `WxH` in pixels, each side from `lo` to `hi` */
|
|
412
|
+
const sizeWord = (what: string, w: Word | undefined, lo: number, hi: number): [number, number] => {
|
|
413
|
+
const m = w && !w.quoted ? /^(\d+)x(\d+)$/.exec(w.text) : null
|
|
414
|
+
const [x, y] = [Number(m?.[1]), Number(m?.[2])]
|
|
415
|
+
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}` : ''}`)
|
|
416
|
+
return [x, y]
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** the `key value` pairs after a line's fixed words; `flags` take no value */
|
|
420
|
+
function pairs(
|
|
421
|
+
what: string,
|
|
422
|
+
rest: Word[],
|
|
423
|
+
valued: Record<string, (w: Word | undefined, key: string) => unknown>,
|
|
424
|
+
flags: readonly string[] = [],
|
|
425
|
+
): Record<string, unknown> {
|
|
426
|
+
const out: Record<string, unknown> = {}
|
|
427
|
+
for (let i = 0; i < rest.length; i++) {
|
|
428
|
+
const k = rest[i]!
|
|
429
|
+
if (k.quoted) bad(`${what}: a quoted text "${k.text}" where a word was expected`)
|
|
430
|
+
if (k.text in out) bad(`${what}: ${k.text} given twice`)
|
|
431
|
+
if (flags.includes(k.text)) {
|
|
432
|
+
out[k.text] = true
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
const f = valued[k.text]
|
|
436
|
+
if (!f) bad(`${what}: unknown word ${k.text} (${what} takes ${[...Object.keys(valued), ...flags].join(', ') || 'nothing more'})`)
|
|
437
|
+
out[k.text] = f!(rest[++i], `${what} ${k.text}`)
|
|
438
|
+
}
|
|
439
|
+
return out
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** the film row a line's words start with: the longest row path at their head */
|
|
443
|
+
export function verbOf(words: readonly string[], verbs: readonly FilmVerb[]): FilmVerb | null {
|
|
444
|
+
let best: FilmVerb | null = null
|
|
445
|
+
for (const v of verbs)
|
|
446
|
+
if (v.path.length <= words.length && v.path.every((p, i) => words[i] === p) && v.path.length > (best?.path.length ?? 0)) best = v
|
|
447
|
+
return best
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** what a film line may start with in this repo, for its errors */
|
|
451
|
+
const filmWords = (verbs: readonly FilmVerb[] | null): string =>
|
|
452
|
+
`${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(', ')}`}`
|
|
453
|
+
|
|
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
|
|
455
|
+
* CLI (its `clip <scene>` row), else the core kind's error */
|
|
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
|
|
477
|
+
const kind = ws[0] && !ws[0].quoted ? ws[0].text : ''
|
|
478
|
+
const argv = ws.map((w) => w.text)
|
|
479
|
+
const v = verbs && verbOf(argv, verbs)
|
|
480
|
+
if ((CORE_FILMS as readonly string[]).includes(kind)) {
|
|
481
|
+
try {
|
|
482
|
+
return coreFilm(kind, ws, line)
|
|
483
|
+
} catch (e) {
|
|
484
|
+
if (!(e instanceof LineError) || !v) throw e
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!v) return bad(`film: unknown kind ${kind || '(none)'} (${filmWords(verbs)})`)
|
|
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`)
|
|
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
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function coreFilm(kind: string, ws: Word[], line: number): Film {
|
|
509
|
+
switch (kind) {
|
|
510
|
+
case 'clip': {
|
|
511
|
+
const path = pathWord('film clip', ws[1])
|
|
512
|
+
if (!/\.(mp4|webm|mov|mkv)$/i.test(path)) bad(`film clip wants a video file (.mp4, .webm, .mov, .mkv), got ${path}`)
|
|
513
|
+
pairs(`film clip ${path}`, ws.slice(2), {})
|
|
514
|
+
return { kind, line, path }
|
|
515
|
+
}
|
|
516
|
+
case 'image': {
|
|
517
|
+
const path = pathWord('film image', ws[1])
|
|
518
|
+
if (!/\.(png|jpe?g|webp)$/i.test(path)) bad(`film image wants an image file (.png, .jpg, .webp), got ${path}`)
|
|
519
|
+
pairs(`film image ${path}`, ws.slice(2), {})
|
|
520
|
+
return { kind, line, path }
|
|
521
|
+
}
|
|
522
|
+
case 'card':
|
|
523
|
+
pairs('film card', ws.slice(1), {})
|
|
524
|
+
return { kind, line }
|
|
525
|
+
case 'run': {
|
|
526
|
+
const cmd = quoted('film run', ws[1])
|
|
527
|
+
if (!cmd.includes('{out}')) bad('film run wants {out} in its command: the file the command writes the take to')
|
|
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[] } : {}) }
|
|
530
|
+
}
|
|
531
|
+
case 'url': {
|
|
532
|
+
const route = pathWord('film url', ws[1])
|
|
533
|
+
if (!/^(\/|https?:\/\/)/.test(route)) bad(`film url wants a route of the dev url (/pricing) or an http url, got ${route}`)
|
|
534
|
+
const p = pairs(`film url ${route}`, ws.slice(2), { viewport: (w, k) => sizeWord(k, w, 16, 7680), wait: at }, ['still'])
|
|
535
|
+
return {
|
|
536
|
+
kind,
|
|
537
|
+
line,
|
|
538
|
+
route,
|
|
539
|
+
...(p.viewport ? { viewport: p.viewport as [number, number] } : {}),
|
|
540
|
+
...(p.wait !== undefined ? { wait: p.wait as number } : {}),
|
|
541
|
+
...(p.still ? { still: true as const } : {}),
|
|
542
|
+
}
|
|
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
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return bad(`film: unknown kind ${kind}`)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const volume = (w: Word | undefined, k: string) => num(k, w, 0, 4)
|
|
568
|
+
const at = (w: Word | undefined, k: string) => time(k, w)
|
|
569
|
+
|
|
570
|
+
/** a music cue that is silence */
|
|
571
|
+
export const NO_MUSIC = 'none'
|
|
572
|
+
|
|
573
|
+
/** the cut's opening cue fades in this long when it starts inside its song and names no fade-in */
|
|
574
|
+
export const OPENING_FADE_IN = 0.25
|
|
575
|
+
|
|
576
|
+
/** `music <file|id|none> [take k] [from t] [fade-in t] [fade-out t] [volume v]`; `opening`: the cut's own */
|
|
577
|
+
function musicLine(rest: Word[], n: number, opening = false): Music {
|
|
578
|
+
const song = pathWord('music', rest[0])
|
|
579
|
+
const p = pairs(`music ${song}`, rest.slice(1), {
|
|
580
|
+
take: (w, k) => word(k, w, /^[a-z0-9][a-z0-9-]*$/),
|
|
581
|
+
from: at,
|
|
582
|
+
'fade-in': at,
|
|
583
|
+
'fade-out': at,
|
|
584
|
+
volume,
|
|
585
|
+
})
|
|
586
|
+
return {
|
|
587
|
+
line: n,
|
|
588
|
+
song,
|
|
589
|
+
...(p.take ? { take: p.take as string } : {}),
|
|
590
|
+
from: (p.from as number) ?? 0,
|
|
591
|
+
fadeIn: (p['fade-in'] as number | undefined) ?? (opening && ((p.from as number | undefined) ?? 0) > 0 ? OPENING_FADE_IN : 0),
|
|
592
|
+
fadeOut: (p['fade-out'] as number) ?? 0,
|
|
593
|
+
volume: (p.volume as number) ?? 1,
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
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.
|
|
601
|
+
*/
|
|
602
|
+
export function scoreLines(text: string): { lines: string[]; code: Score['code']; plays: (Play & { section: number })[]; errors: TrailerError[] } {
|
|
603
|
+
const lines = text.split('\n')
|
|
604
|
+
const errors: TrailerError[] = []
|
|
605
|
+
const plays: (Play & { section: number })[] = []
|
|
606
|
+
let code: Score['code'] = null
|
|
607
|
+
let section = -1
|
|
608
|
+
const indent = (l: string): number => l.length - l.trimStart().length
|
|
609
|
+
const off = (l: string): boolean => l.trimStart().startsWith('#')
|
|
610
|
+
for (let i = 0; i < lines.length; i++) {
|
|
611
|
+
const l = lines[i]!
|
|
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] ?? '']
|
|
632
|
+
lines[i] = ''
|
|
633
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
634
|
+
const b = lines[j]!
|
|
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
|
+
}
|
|
644
|
+
lines[j] = ''
|
|
645
|
+
i = j
|
|
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 })
|
|
653
|
+
}
|
|
654
|
+
return { lines, code, plays, errors }
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export interface ParseOptions {
|
|
658
|
+
/** the project CLI's film rows; null when the repo has no project CLI */
|
|
659
|
+
verbs?: readonly FilmVerb[] | null
|
|
660
|
+
}
|
|
661
|
+
|
|
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
|
+
|
|
686
|
+
export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Trailer; errors: TrailerError[] } {
|
|
687
|
+
const verbs = o.verbs ?? null
|
|
688
|
+
const t: Trailer = { name: '', footage: [], size: [1920, 1080], fps: 30, music: null, duck: -10, duckSfx: null, fadeIn: 0, shots: [], sections: [], score: null, timelineAt: null, vertical: null, base: null }
|
|
689
|
+
const errors: TrailerError[] = []
|
|
690
|
+
const seen = new Set<string>()
|
|
691
|
+
let shot: Shot | null = null
|
|
692
|
+
let pending: SectionRef | null = null
|
|
693
|
+
let current: Section | null = null
|
|
694
|
+
/** the `at`s that name a mark, set once every shot has landed */
|
|
695
|
+
const refs: { line: number; shot: Shot; ref: MarkAt; set: (at: number) => void }[] = []
|
|
696
|
+
const landAt = (v: unknown, line: number, s: Shot, set: (at: number) => void): number => {
|
|
697
|
+
if (typeof v !== 'object' || v === null) return (v as number | undefined) ?? 0
|
|
698
|
+
refs.push({ line, shot: s, ref: v as MarkAt, set })
|
|
699
|
+
return 0
|
|
700
|
+
}
|
|
701
|
+
clock = null
|
|
702
|
+
const pre = scoreLines(text)
|
|
703
|
+
errors.push(...pre.errors)
|
|
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
|
+
}
|
|
717
|
+
for (let n = 1; n <= lines.length; n++) {
|
|
718
|
+
const tk = tokens(lines[n - 1]!)
|
|
719
|
+
if ('error' in tk) {
|
|
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
|
|
723
|
+
continue
|
|
724
|
+
}
|
|
725
|
+
const ws = tk.words
|
|
726
|
+
if (ws.length === 0) continue
|
|
727
|
+
const head = ws[0]!
|
|
728
|
+
const rest = ws.slice(1)
|
|
729
|
+
const once = (key: string): void => {
|
|
730
|
+
if (seen.has(key)) bad(`${key} given twice`)
|
|
731
|
+
seen.add(key)
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
if (head.quoted) bad(`a line starts with a word, got "${head.text}"`)
|
|
735
|
+
if (head.text === 'section') {
|
|
736
|
+
const name = word('section', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
737
|
+
const last = t.sections.at(-1)
|
|
738
|
+
if (t.sections.some((x) => x.name === name)) bad(`section ${name} is already a section of this file`)
|
|
739
|
+
const p = pairs(`section ${name}`, rest.slice(2), {
|
|
740
|
+
tempo: (w, k) => num(k, w, 20, 400),
|
|
741
|
+
meter: (w, k) => num(k, w, 1, 16),
|
|
742
|
+
grid: (w, k) => num(k, w, 1, 32),
|
|
743
|
+
})
|
|
744
|
+
const base = last?.clock ?? t.score?.clock ?? null
|
|
745
|
+
if (!base && !p.tempo && (p.meter || p.grid)) bad(`section ${name}: meter and grid count in a tempo, give it tempo <bpm>`)
|
|
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
|
|
747
|
+
const seconds = +time(`section ${name}`, rest[1]).toFixed(6)
|
|
748
|
+
if (seconds <= 0) bad(`section ${name}: its length must be above 0`)
|
|
749
|
+
const at = last ? +(last.at + last.seconds).toFixed(6) : 0
|
|
750
|
+
current = { line: n, name, clock, at, seconds, marks: [], drums: {}, plays: [] }
|
|
751
|
+
t.sections.push(current)
|
|
752
|
+
pending = { line: n, name, at }
|
|
753
|
+
continue
|
|
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
|
+
}
|
|
782
|
+
if (head.text === 'mark') {
|
|
783
|
+
const sec = current ?? bad('a mark is a moment of a section: write it under one')
|
|
784
|
+
const name = word('mark', rest[0], new RegExp(`^${MOMENT}$`))
|
|
785
|
+
if (rest[1]?.text !== 'at' || rest.length !== 3) bad(`mark ${name} wants at <time>, from its section's start`)
|
|
786
|
+
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`)
|
|
787
|
+
const at = time(`mark ${name}`, rest[2])
|
|
788
|
+
if (at >= sec.seconds) bad(`mark ${name} at ${+at.toFixed(3)} s is past section ${sec.name}'s ${+sec.seconds.toFixed(3)} s`)
|
|
789
|
+
sec.marks.push({ line: n, name, at })
|
|
790
|
+
continue
|
|
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
|
+
}
|
|
807
|
+
if (head.text === 'shot') {
|
|
808
|
+
// a bad shot line still opens a shot, so its own lines never read as the header's or the shot before's
|
|
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 }
|
|
810
|
+
shot = open
|
|
811
|
+
t.shots.push(open)
|
|
812
|
+
const name = word('shot', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
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`)
|
|
814
|
+
const length = time(`shot ${name}`, rest[1])
|
|
815
|
+
if (length <= 0) bad(`shot ${name}: its length must be above 0`)
|
|
816
|
+
if (rest.length > 2) bad(`shot ${name} ${rest[1]?.text}: unknown word ${rest[2]?.text} (a shot line is shot <name> <length>)`)
|
|
817
|
+
open.name = name
|
|
818
|
+
open.length = length
|
|
819
|
+
if (pending) open.section = pending
|
|
820
|
+
pending = null
|
|
821
|
+
delete open.broken
|
|
822
|
+
continue
|
|
823
|
+
}
|
|
824
|
+
if (shot === null) {
|
|
825
|
+
switch (head.text) {
|
|
826
|
+
case 'trailer':
|
|
827
|
+
once('trailer')
|
|
828
|
+
t.name = word('trailer', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
829
|
+
if (rest.length > 1) bad(`trailer ${t.name}: unknown word ${rest[1]?.text}`)
|
|
830
|
+
break
|
|
831
|
+
case 'size': {
|
|
832
|
+
once('size')
|
|
833
|
+
const m = rest[0] && !rest[0].quoted ? /^(\d+)x(\d+)$/.exec(rest[0].text) : null
|
|
834
|
+
const w = Number(m?.[1])
|
|
835
|
+
const h = Number(m?.[2])
|
|
836
|
+
if (!m || w < 160 || h < 90 || w > 3840 || h > 3840 || w % 2 || h % 2)
|
|
837
|
+
bad(`size wants WxH, even, from 160x90 to 3840x3840${rest[0] ? `, got ${rest[0].text}` : ''}`)
|
|
838
|
+
t.size = [w, h]
|
|
839
|
+
break
|
|
840
|
+
}
|
|
841
|
+
case 'fps':
|
|
842
|
+
once('fps')
|
|
843
|
+
t.fps = num('fps', rest[0], 1, FPS_MAX)
|
|
844
|
+
if (!Number.isInteger(t.fps)) bad(`fps wants a whole number, got ${rest[0]?.text}`)
|
|
845
|
+
break
|
|
846
|
+
case 'music':
|
|
847
|
+
once('music')
|
|
848
|
+
t.music = musicLine(rest, n, true)
|
|
849
|
+
if (t.music.song === NO_MUSIC) bad('music none is a shot\'s cue, the header names the song the cut opens on')
|
|
850
|
+
break
|
|
851
|
+
case 'duck': {
|
|
852
|
+
once('duck')
|
|
853
|
+
const drop = (w: Word | undefined, what: string): number => {
|
|
854
|
+
const m = w && !w.quoted ? /^(-?\d+(?:\.\d+)?)(?:dB)?$/.exec(w.text) : null
|
|
855
|
+
const db = Number(m?.[1])
|
|
856
|
+
if (!m || db > 0 || db < -40) bad(`${what} wants a drop in dB from -40 to 0 (-10dB)${w ? `, got ${w.text}` : ''}`)
|
|
857
|
+
return db
|
|
858
|
+
}
|
|
859
|
+
t.duck = drop(rest[0], 'duck')
|
|
860
|
+
if (rest.length > 1) {
|
|
861
|
+
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(' ')}`)
|
|
862
|
+
t.duckSfx = drop(rest[2], 'duck sfx')
|
|
863
|
+
}
|
|
864
|
+
break
|
|
865
|
+
}
|
|
866
|
+
case 'fade-in':
|
|
867
|
+
once('fade-in')
|
|
868
|
+
t.fadeIn = time('fade-in', rest[0])
|
|
869
|
+
break
|
|
870
|
+
case 'vertical': {
|
|
871
|
+
once('vertical')
|
|
872
|
+
const p = pairs('vertical', rest, {
|
|
873
|
+
mark: (w, k) => {
|
|
874
|
+
const c = word(k, w)
|
|
875
|
+
return c === 'left' || c === 'right' ? c : bad(`${k} wants left|right, got ${c}`)
|
|
876
|
+
},
|
|
877
|
+
outro: (w, k) => {
|
|
878
|
+
const o = pathWord(k, w)
|
|
879
|
+
if (o === 'card' || o === 'none') return o
|
|
880
|
+
if (!/\.(mp4|webm|mov|mkv)$/i.test(o)) bad(`${k} wants card, none or a video file, got ${o}`)
|
|
881
|
+
return { clip: o }
|
|
882
|
+
},
|
|
883
|
+
})
|
|
884
|
+
t.vertical = { line: n, outro: (p.outro as Vertical['outro'] | undefined) ?? 'card', ...(p.mark ? { mark: p.mark as 'left' | 'right' } : {}) }
|
|
885
|
+
break
|
|
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
|
+
}
|
|
895
|
+
case 'base':
|
|
896
|
+
once('base')
|
|
897
|
+
t.base = word('base', rest[0], /^[a-z0-9][a-z0-9-]*$/)
|
|
898
|
+
if (rest.length > 1) bad(`base ${t.base}: unknown word ${rest[1]?.text} (base <the id of a global process of this repo>)`)
|
|
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
|
+
}
|
|
936
|
+
default:
|
|
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)`)
|
|
938
|
+
}
|
|
939
|
+
continue
|
|
940
|
+
}
|
|
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}`)
|
|
943
|
+
switch (head.text) {
|
|
944
|
+
case 'film':
|
|
945
|
+
if (s.film) bad(`shot ${s.name} already films on line ${s.film.line}: one film line a shot`)
|
|
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
|
+
}
|
|
962
|
+
break
|
|
963
|
+
case 'title': {
|
|
964
|
+
const text = quoted('title', rest[0])
|
|
965
|
+
const p = pairs('title', rest.slice(1), {
|
|
966
|
+
at: when,
|
|
967
|
+
for: at,
|
|
968
|
+
style: (w, k) => {
|
|
969
|
+
const st = word(k, w)
|
|
970
|
+
return (TITLE_STYLES as readonly string[]).includes(st) ? st : bad(`${k} wants ${TITLE_STYLES.join('|')}, got ${st}`)
|
|
971
|
+
},
|
|
972
|
+
place: (w, k) => {
|
|
973
|
+
const pl = word(k, w)
|
|
974
|
+
return pl === 'top' || pl === 'bottom' ? pl : bad(`${k} wants top|bottom, got ${pl}`)
|
|
975
|
+
},
|
|
976
|
+
knock: at,
|
|
977
|
+
})
|
|
978
|
+
const ti: Title = {
|
|
979
|
+
line: n,
|
|
980
|
+
text,
|
|
981
|
+
at: 0,
|
|
982
|
+
for: (p.for as number) ?? null,
|
|
983
|
+
style: (p.style as TitleStyle) ?? 'caption',
|
|
984
|
+
...(p.place ? { place: p.place as 'top' | 'bottom' } : {}),
|
|
985
|
+
...(p.knock !== undefined ? { knock: p.knock as number } : {}),
|
|
986
|
+
}
|
|
987
|
+
ti.at = landAt(p.at, n, s, (v) => (ti.at = v))
|
|
988
|
+
if (ti.knock !== undefined && (ti.knock <= ti.at || (ti.for !== null && ti.knock >= ti.at + ti.for)))
|
|
989
|
+
bad(`title knock ${ti.knock}s: inside the title's own span, after its at`)
|
|
990
|
+
s.titles.push(ti)
|
|
991
|
+
break
|
|
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
|
+
}
|
|
1012
|
+
case 'sfx': {
|
|
1013
|
+
const id = pathWord('sfx', rest[0])
|
|
1014
|
+
const p = pairs(`sfx ${id}`, rest.slice(1), { at: when, volume })
|
|
1015
|
+
const x: Sfx = { line: n, id, at: 0, volume: (p.volume as number) ?? 1 }
|
|
1016
|
+
x.at = landAt(p.at, n, s, (v) => (x.at = v))
|
|
1017
|
+
s.sfx.push(x)
|
|
1018
|
+
break
|
|
1019
|
+
}
|
|
1020
|
+
case 'voice': {
|
|
1021
|
+
const fileForm = rest[0] && !rest[0].quoted && rest[0].text === 'file'
|
|
1022
|
+
const src = fileForm ? { file: pathWord('voice file', rest[1]) } : { text: quoted('voice', rest[0]) }
|
|
1023
|
+
const p = pairs('voice', rest.slice(fileForm ? 2 : 1), { at: when, volume })
|
|
1024
|
+
const v: Voice = { line: n, ...src, at: 0, volume: (p.volume as number) ?? 1 }
|
|
1025
|
+
v.at = landAt(p.at, n, s, (x) => (v.at = x))
|
|
1026
|
+
s.voices.push(v)
|
|
1027
|
+
break
|
|
1028
|
+
}
|
|
1029
|
+
case 'cut': {
|
|
1030
|
+
if (s.cut.line !== s.line) bad(`shot ${s.name} already cuts on line ${s.cut.line}`)
|
|
1031
|
+
const k = word('cut', rest[0])
|
|
1032
|
+
if (!(CUT_KINDS as readonly string[]).includes(k)) bad(`cut: unknown kind ${k} (${CUT_KINDS.join(', ')})`)
|
|
1033
|
+
const seconds = k === 'hard' ? 0 : rest[1] ? time(`cut ${k}`, rest[1]) : DEFAULT_CUT_S
|
|
1034
|
+
if (k === 'hard' && rest[1]) bad('cut hard takes no time')
|
|
1035
|
+
if (rest.length > 2) bad(`cut ${k}: unknown word ${rest[2]?.text}`)
|
|
1036
|
+
s.cut = { line: n, kind: k as CutKind, seconds }
|
|
1037
|
+
break
|
|
1038
|
+
}
|
|
1039
|
+
case 'music':
|
|
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')
|
|
1042
|
+
s.music = musicLine(rest, n)
|
|
1043
|
+
break
|
|
1044
|
+
case 'starts': {
|
|
1045
|
+
if (s.lands) bad(`shot ${s.name} already lands on line ${s.lands.line}`)
|
|
1046
|
+
if (rest[0]?.text !== 'on' || !rest[1] || rest.length > 3) bad('starts wants on <section|mark> [+|-<time>]')
|
|
1047
|
+
const on = word('starts on', rest[1], /^[a-z0-9][a-z0-9-]*$/)
|
|
1048
|
+
const o2 = rest[2] && !rest[2].quoted ? /^([+-])(.+)$/.exec(rest[2].text) : null
|
|
1049
|
+
if (rest[2] && !o2) bad(`starts on ${on}: the time from it is signed, +0.5s or -1beat, got ${rest[2].text}`)
|
|
1050
|
+
const off = o2 ? time(`starts on ${on}`, { text: o2[2]!, quoted: false }) : 0
|
|
1051
|
+
s.lands = { line: n, on, offset: o2?.[1] === '-' ? -off : off }
|
|
1052
|
+
break
|
|
1053
|
+
}
|
|
1054
|
+
case 'from':
|
|
1055
|
+
s.from = time('from', rest[0])
|
|
1056
|
+
if (rest.length > 1) bad(`from: unknown word ${rest[1]?.text}`)
|
|
1057
|
+
break
|
|
1058
|
+
case 'push':
|
|
1059
|
+
s.push = num('push', rest[0], 1, 3)
|
|
1060
|
+
if (rest.length > 1) bad(`push: unknown word ${rest[1]?.text}`)
|
|
1061
|
+
break
|
|
1062
|
+
case 'lift':
|
|
1063
|
+
s.lift = num('lift', rest[0], 1, 2)
|
|
1064
|
+
if (rest.length > 1) bad(`lift: unknown word ${rest[1]?.text}`)
|
|
1065
|
+
break
|
|
1066
|
+
case 'frame':
|
|
1067
|
+
if (s.frame !== null) bad(`shot ${s.name} already frames its footage`)
|
|
1068
|
+
s.frame = num('frame', rest[0], 1, 3)
|
|
1069
|
+
if (rest[1]?.text === 'at' && !rest[1].quoted) {
|
|
1070
|
+
const m = /^(\d*\.?\d+),(\d*\.?\d+)$/.exec(rest[2] && !rest[2].quoted ? rest[2].text : '')
|
|
1071
|
+
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}` : ''}`)
|
|
1072
|
+
else s.frameAt = [Number(m[1]), Number(m[2])]
|
|
1073
|
+
if (rest.length > 3) bad(`frame: unknown word ${rest[3]?.text}`)
|
|
1074
|
+
} else if (rest.length > 1) bad(`frame: unknown word ${rest[1]?.text}`)
|
|
1075
|
+
break
|
|
1076
|
+
case 'shake': {
|
|
1077
|
+
const p = pairs('shake', rest, { at: when, for: at, strength: (w, k) => num(k, w, 0.2, 3) })
|
|
1078
|
+
const x: Shake = { line: n, at: 0, for: (p.for as number) ?? 0.4, strength: (p.strength as number) ?? 1 }
|
|
1079
|
+
x.at = landAt(p.at, n, s, (v) => (x.at = v))
|
|
1080
|
+
s.shakes.push(x)
|
|
1081
|
+
break
|
|
1082
|
+
}
|
|
1083
|
+
case 'flash': {
|
|
1084
|
+
const p = pairs('flash', rest, { at: when, for: at })
|
|
1085
|
+
const x: Flash = { line: n, at: 0, for: (p.for as number) ?? 0.25 }
|
|
1086
|
+
x.at = landAt(p.at, n, s, (v) => (x.at = v))
|
|
1087
|
+
s.flashes.push(x)
|
|
1088
|
+
break
|
|
1089
|
+
}
|
|
1090
|
+
default:
|
|
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)`)
|
|
1092
|
+
}
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
if (!(e instanceof LineError)) throw e
|
|
1095
|
+
errors.push({ line: n, message: e.message })
|
|
1096
|
+
if (shot && head.text === 'film') shot.broken = true
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (!seen.has('trailer')) errors.push({ line: 1, message: 'the file names its trailer first: trailer <name>' })
|
|
1100
|
+
if (t.shots.length === 0) errors.push({ line: lines.length, message: 'no shot: a trailer has at least one shot <name> <length>' })
|
|
1101
|
+
if (t.vertical && !seen.has('size')) t.size = [...VERTICAL_SIZE]
|
|
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')}` })
|
|
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' })
|
|
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>' })
|
|
1118
|
+
for (const r of refs) {
|
|
1119
|
+
const i = t.shots.indexOf(r.shot)
|
|
1120
|
+
const m = momentOf(t, r.ref.mark)
|
|
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` })
|
|
1122
|
+
else r.set(+(t.timelineAt + m + r.ref.offset - (starts(t)[i] ?? 0)).toFixed(4))
|
|
1123
|
+
}
|
|
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))
|
|
1137
|
+
return { trailer: t, errors: errors.sort((a, b) => a.line - b.line) }
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** the least a fitted shot keeps past its cut out */
|
|
1141
|
+
const FIT_MIN_S = 0.3
|
|
1142
|
+
|
|
1143
|
+
/** a section's start, or a mark's moment, in the timeline's seconds; null when no section or mark has that name */
|
|
1144
|
+
function momentOf(t: Pick<Trailer, 'sections'>, name: string): number | null {
|
|
1145
|
+
for (const sec of t.sections) {
|
|
1146
|
+
if (sec.name === name) return sec.at
|
|
1147
|
+
const m = sec.marks.find((x) => x.name === name)
|
|
1148
|
+
if (m) return sec.at + m.at
|
|
1149
|
+
}
|
|
1150
|
+
return null
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* every shot that lands on the timeline starts at its moment: the first one sets where the timeline starts on the cut,
|
|
1155
|
+
* and the shot before each later one takes the length that lands it (fitted). An edit inside a section moves nothing
|
|
1156
|
+
* past the next landed shot
|
|
1157
|
+
*/
|
|
1158
|
+
function landShots(t: Trailer): TrailerError[] {
|
|
1159
|
+
const out: TrailerError[] = []
|
|
1160
|
+
t.shots.forEach((s, i) => {
|
|
1161
|
+
if (s.broken) return
|
|
1162
|
+
const land = s.lands ?? (s.section ? { line: s.section.line, on: s.section.name, offset: 0 } : null)
|
|
1163
|
+
if (!land) return
|
|
1164
|
+
const moment = momentOf(t, land.on)
|
|
1165
|
+
if (moment === null) {
|
|
1166
|
+
out.push({ line: land.line, message: `starts on ${land.on}: no section or mark ${land.on} in this file` })
|
|
1167
|
+
return
|
|
1168
|
+
}
|
|
1169
|
+
const st = starts(t)
|
|
1170
|
+
if (t.timelineAt === null) {
|
|
1171
|
+
t.timelineAt = +((st[i] ?? 0) - moment - land.offset).toFixed(4)
|
|
1172
|
+
return
|
|
1173
|
+
}
|
|
1174
|
+
const prev = t.shots[i - 1]
|
|
1175
|
+
if (!prev || prev.broken) {
|
|
1176
|
+
out.push({ line: land.line, message: `shot ${s.name}: no shot before it to fit` })
|
|
1177
|
+
return
|
|
1178
|
+
}
|
|
1179
|
+
const length = prev.length + (t.timelineAt + moment + land.offset - (st[i] ?? 0))
|
|
1180
|
+
if (length - prev.cut.seconds < FIT_MIN_S) {
|
|
1181
|
+
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` })
|
|
1182
|
+
return
|
|
1183
|
+
}
|
|
1184
|
+
prev.written ??= prev.length
|
|
1185
|
+
prev.length = +length.toFixed(4)
|
|
1186
|
+
})
|
|
1187
|
+
return out
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** a film that is one picture held for the shot */
|
|
1191
|
+
export const isStill = (f: Film | null): boolean =>
|
|
1192
|
+
f?.kind === 'card' || f?.kind === 'image' || ((f?.kind === 'run' || f?.kind === 'url') && f.still === true) || (f?.kind === 'cli' && f.output === 'still')
|
|
1193
|
+
|
|
1194
|
+
/** what the lines say together: a film per shot, titles inside their shot, cuts shorter than what they join */
|
|
1195
|
+
function structure(t: Trailer): TrailerError[] {
|
|
1196
|
+
const out: TrailerError[] = []
|
|
1197
|
+
t.shots.forEach((s, i) => {
|
|
1198
|
+
if (s.broken) return
|
|
1199
|
+
if (!s.film) out.push({ line: s.line, message: `shot ${s.name} films nothing: give it a film line` })
|
|
1200
|
+
if (isStill(s.film) && s.from > 0) out.push({ line: s.line, message: `shot ${s.name}: its film is a still image, no footage to skip (from)` })
|
|
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` })
|
|
1202
|
+
// a title may run on over the shots after its own (a caption over a montage), never past the cut's end
|
|
1203
|
+
const left = total(t) - (starts(t)[i] ?? 0)
|
|
1204
|
+
for (const ti of s.titles) {
|
|
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` })
|
|
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` })
|
|
1208
|
+
}
|
|
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` })
|
|
1210
|
+
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` })
|
|
1211
|
+
const next = t.shots[i + 1]
|
|
1212
|
+
if (s.cut.seconds > 0) {
|
|
1213
|
+
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` })
|
|
1214
|
+
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` })
|
|
1215
|
+
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}` })
|
|
1216
|
+
}
|
|
1217
|
+
const prev = t.shots[i - 1]
|
|
1218
|
+
if (prev && next && prev.cut.seconds < s.length && s.cut.seconds < s.length && prev.cut.seconds + s.cut.seconds >= s.length)
|
|
1219
|
+
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` })
|
|
1220
|
+
})
|
|
1221
|
+
const music = t.music
|
|
1222
|
+
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` })
|
|
1223
|
+
return out
|
|
1224
|
+
}
|
|
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
|
+
|
|
1258
|
+
/** each shot's start on the cut: a cut overlaps the two shots it joins */
|
|
1259
|
+
export function starts(t: Pick<Trailer, 'shots'>): number[] {
|
|
1260
|
+
const out: number[] = []
|
|
1261
|
+
let at = 0
|
|
1262
|
+
for (const s of t.shots) {
|
|
1263
|
+
out.push(at)
|
|
1264
|
+
at += s.length - s.cut.seconds
|
|
1265
|
+
}
|
|
1266
|
+
return out
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** the cut's length: the last shot's ending is inside its own length */
|
|
1270
|
+
export function total(t: Pick<Trailer, 'shots'>): number {
|
|
1271
|
+
const last = t.shots.at(-1)
|
|
1272
|
+
if (!last) return 0
|
|
1273
|
+
return (starts(t).at(-1) ?? 0) + last.length
|
|
1274
|
+
}
|
|
1275
|
+
|
|
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
|
|
1277
|
+
* fade-in; a `none` cue is silence and carries no song */
|
|
1278
|
+
export function musicCues(t: Pick<Trailer, 'music' | 'shots' | 'score' | 'timelineAt'>): { cue: Music; at: number; end: number }[] {
|
|
1279
|
+
const st = starts(t)
|
|
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)
|
|
1286
|
+
const T = total(t)
|
|
1287
|
+
return raw
|
|
1288
|
+
.map((r, i) => ({ ...r, end: Math.min(T, (raw[i + 1]?.at ?? T) + (raw[i + 1]?.cue.fadeIn ?? 0)) }))
|
|
1289
|
+
.filter((r) => r.cue.song !== NO_MUSIC && r.end > r.at)
|
|
1290
|
+
}
|