rvlib-shipkit 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +183 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +83 -14
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +389 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
package/src/trailer/build.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
// a cut's .trailer file → its mp4: load (parse, resolve every file and sound), film what is missing, draw the titles,
|
|
2
2
|
// stitch the pieces, lay the strip, write the montage page. `sk trailer` and its verbs call these
|
|
3
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
-
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
5
|
-
import
|
|
3
|
+
import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
5
|
+
import type { SoundRequest, TrailerCheckInput, TrailerUnit } from './adapter.ts'
|
|
6
|
+
import { type FilmVerb, type Film, gridCells, namedStyles, parseTrailer, placeOf, resolveStyles, type Title, type SoundGen, type SoundRun, SCORE, SCORE_LEAD, type Shot, starts, total, type Trailer, type TrailerError, musicCues, NO_MUSIC } from './grammar.ts'
|
|
7
|
+
import { filmLineOf, type Recipe, takesOf, trailerCache } from './footage.ts'
|
|
6
8
|
import type { BrandLike, TrailerLook } from './look.ts'
|
|
7
|
-
import { fresh, isTake, type Refilm, type Take, takesToFilm } from './plan.ts'
|
|
9
|
+
import { fresh, isTake, type Refilm, recipeWords, type Take, takeIsStill, takesToFilm, type World, worldFor } from './plan.ts'
|
|
10
|
+
import { decodeStates, edgeChanges } from './states.ts'
|
|
11
|
+
import { type CompiledScore, compileScore } from './score/score.ts'
|
|
12
|
+
import { GROW_RES, type Source, titleMotions } from './graph.ts'
|
|
13
|
+
import type { TitleMotion } from './motion.ts'
|
|
14
|
+
import type { TitleRequest } from './titles.ts'
|
|
8
15
|
import type { Stitched } from './stitch.ts'
|
|
9
16
|
import { projectCli, readVocab } from './vocab.ts'
|
|
10
17
|
|
|
@@ -15,6 +22,14 @@ const refuse = (why: string): never => {
|
|
|
15
22
|
throw new Refused(why)
|
|
16
23
|
}
|
|
17
24
|
|
|
25
|
+
/** every cut a build can name: each `.trailer` under `trailer/`, at any depth, a kept version's and `out/` left out */
|
|
26
|
+
export function cutFiles(root: string, dir = join(root, 'trailer')): string[] {
|
|
27
|
+
if (!existsSync(dir)) return []
|
|
28
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
29
|
+
.flatMap((e) => (e.isDirectory() ? (e.name === 'versions' || e.name === 'out' ? [] : cutFiles(root, join(dir, e.name))) : e.name.endsWith('.trailer') ? [join(dir, e.name)] : []))
|
|
30
|
+
.sort()
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
/** every `.trailer` under `trailer/<trailer>/` */
|
|
19
34
|
export function trailerFiles(root: string): string[] {
|
|
20
35
|
const dir = join(root, 'trailer')
|
|
@@ -50,9 +65,17 @@ export interface Loaded {
|
|
|
50
65
|
dir: string
|
|
51
66
|
t: Trailer
|
|
52
67
|
errors: TrailerError[]
|
|
68
|
+
/** what the trailer unit's check said and never blocks */
|
|
69
|
+
warnings: TrailerError[]
|
|
53
70
|
/** the project CLI's argv, null when the repo has none */
|
|
54
71
|
cli: string[] | null
|
|
55
72
|
verbs: FilmVerb[] | null
|
|
73
|
+
/** the score's notes, null when the cut has none or it failed */
|
|
74
|
+
score: CompiledScore | null
|
|
75
|
+
/** each `@<file>` word of a film line, found */
|
|
76
|
+
recipes: Map<string, Recipe>
|
|
77
|
+
/** the repo's trailer unit, null when it has none */
|
|
78
|
+
unit: TrailerUnit | null
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
/** a file a line names: beside the .trailer first, then from the repo root */
|
|
@@ -62,30 +85,165 @@ export function fileOf(path: string, l: Pick<Loaded, 'dir' | 'root'>): string |
|
|
|
62
85
|
return null
|
|
63
86
|
}
|
|
64
87
|
|
|
65
|
-
/** the file parsed and checked against the repo: every error at its line, all at once
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
/** the file parsed and checked against the repo: every error at its line, all at once; the trailer unit's check with
|
|
89
|
+
* it unless `o.check` is false (a cut read only for its keys); `o.fresh` reads the rows and the unit again */
|
|
90
|
+
export async function load(file: string, root: string, o: { text?: string; dir?: string; check?: boolean; fresh?: boolean; variant?: string } = {}): Promise<Loaded> {
|
|
91
|
+
const text = o.text ?? readFileSync(file, 'utf8')
|
|
92
|
+
// a watch's build reads the rows and the unit again: a bumped version or an edited unit keys as a fresh run would
|
|
93
|
+
if (o.fresh) {
|
|
94
|
+
;(await import('./vocab.ts')).forgetVocab()
|
|
95
|
+
;(await import('./load-unit.ts')).forgetTrailerUnit(root)
|
|
96
|
+
}
|
|
68
97
|
const cli = projectCli(root)
|
|
69
98
|
let verbs: FilmVerb[] | null = null
|
|
70
99
|
const errors: TrailerError[] = []
|
|
71
|
-
|
|
100
|
+
const { loadTrailerUnit } = await import('./load-unit.ts')
|
|
101
|
+
const unit = await loadTrailerUnit(root)
|
|
102
|
+
const variant = { ...(o.variant !== undefined ? { variant: o.variant } : {}), ...(unit?.page ? { pageWords: unit.page.words } : {}) }
|
|
103
|
+
let parsed = parseTrailer(text, { verbs: null, ...variant })
|
|
72
104
|
// the project CLI is asked only when a film line does not read as a core film
|
|
73
105
|
const lines = text.split('\n')
|
|
74
106
|
if (cli !== null && parsed.errors.some((e) => /^\s*film\s/.test(lines[e.line - 1] ?? ''))) {
|
|
75
107
|
try {
|
|
76
108
|
verbs = readVocab(root, cli)
|
|
77
|
-
parsed = parseTrailer(text, { verbs })
|
|
109
|
+
parsed = parseTrailer(text, { verbs, ...variant })
|
|
78
110
|
} catch (e) {
|
|
79
111
|
errors.push({ line: 1, message: (e as Error).message })
|
|
80
112
|
}
|
|
81
113
|
}
|
|
82
114
|
const t = parsed.trailer
|
|
83
|
-
const l: Loaded = { root, file, dir: dirname(file), t, errors: [...errors, ...parsed.errors], cli, verbs }
|
|
115
|
+
const l: Loaded = { root, file, dir: o.dir ?? dirname(file), t, errors: [...errors, ...parsed.errors], warnings: [], cli, verbs, score: null, recipes: new Map(), unit }
|
|
116
|
+
l.errors.push(...findRecipes(l))
|
|
117
|
+
// the brand is read only for a cut that names one of its text styles: a check stays quick
|
|
118
|
+
if (namedStyles(t).length > 0) l.errors.push(...resolveStyles(t, (await brandStyles(root)) ?? {}))
|
|
84
119
|
if (l.errors.length === 0) l.errors.push(...checkFiles(l))
|
|
120
|
+
if (l.errors.length === 0 && t.score) {
|
|
121
|
+
const c = compileScore(t.score, t.sections, trailerCache(root, 'score'))
|
|
122
|
+
l.score = c.score
|
|
123
|
+
l.errors.push(...c.errors)
|
|
124
|
+
}
|
|
125
|
+
if (l.errors.length === 0 && l.unit?.check && o.check !== false) {
|
|
126
|
+
for (const f of await l.unit.check(checkInput(l))) (f.warn ? l.warnings : l.errors).push({ line: f.line, message: f.message })
|
|
127
|
+
l.warnings.sort((a, b) => a.line - b.line)
|
|
128
|
+
}
|
|
85
129
|
l.errors.sort((a, b) => a.line - b.line)
|
|
86
130
|
return l
|
|
87
131
|
}
|
|
88
132
|
|
|
133
|
+
/** each `@<file>` word of a film line, and each file a `film run` reads: beside the .trailer first, then from the repo
|
|
134
|
+
* root; its repo relative path */
|
|
135
|
+
function findRecipes(l: Loaded): TrailerError[] {
|
|
136
|
+
const out: TrailerError[] = []
|
|
137
|
+
for (const s of [...l.t.shots, ...l.t.footage]) {
|
|
138
|
+
const f = s.film
|
|
139
|
+
if (f?.kind === 'run')
|
|
140
|
+
for (const p of f.reads ?? []) {
|
|
141
|
+
const abs = fileOf(p, l)
|
|
142
|
+
if (!abs) out.push({ line: f.line, message: `film run: no file ${p} beside the .trailer or from the repo root (reads)` })
|
|
143
|
+
// its bytes by their digest: an image read as text would lose them
|
|
144
|
+
else l.recipes.set(`reads:${p}`, { path: relative(l.root, abs), text: new Bun.CryptoHasher('sha1').update(readFileSync(abs)).digest('hex') })
|
|
145
|
+
}
|
|
146
|
+
if (f?.kind !== 'cli') continue
|
|
147
|
+
for (const i of f.files ?? []) {
|
|
148
|
+
const w = f.argv[i]!
|
|
149
|
+
if (l.recipes.has(`file:${w}`)) continue
|
|
150
|
+
const abs = fileOf(w, l)
|
|
151
|
+
if (!abs) out.push({ line: f.line, message: `film ${f.argv[0]}: no file ${w} beside the .trailer or from the repo root` })
|
|
152
|
+
else l.recipes.set(`file:${w}`, { path: abs.startsWith(`${l.root}/`) ? relative(l.root, abs) : abs, text: new Bun.CryptoHasher('sha1').update(readFileSync(abs)).digest('hex') })
|
|
153
|
+
}
|
|
154
|
+
for (const w of recipeWords(f.argv)) {
|
|
155
|
+
if (l.recipes.has(w)) continue
|
|
156
|
+
const abs = fileOf(w.slice(1), l)
|
|
157
|
+
if (!abs) out.push({ line: f.line, message: `film: no recipe ${w.slice(1)} beside the .trailer or from the repo root` })
|
|
158
|
+
else l.recipes.set(w, { path: abs.startsWith(`${l.root}/`) ? relative(l.root, abs) : abs, text: readFileSync(abs, 'utf8') })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** what the trailer unit's check reads: the film lines of the project CLI, the sounds named by id */
|
|
165
|
+
export function checkInput(l: Loaded): TrailerCheckInput {
|
|
166
|
+
const ids = soundIds(l.t)
|
|
167
|
+
return {
|
|
168
|
+
file: l.file,
|
|
169
|
+
dir: l.dir,
|
|
170
|
+
size: l.t.size,
|
|
171
|
+
fps: l.t.fps,
|
|
172
|
+
films: [...l.t.shots, ...l.t.footage].flatMap((s) => (s.film?.kind === 'cli' ? [filmLineOf(s, s.film, l)] : [])),
|
|
173
|
+
music: [l.t.music, ...l.t.shots.map((s) => s.music)].flatMap((m) =>
|
|
174
|
+
m && m.song !== NO_MUSIC && !m.prompt && !m.run && !isFileWord(m.song) ? [{ line: m.line, id: m.song, ...(m.take ? { take: m.take } : {}), from: m.from, opening: m === l.t.music }] : [],
|
|
175
|
+
),
|
|
176
|
+
sfx: ids.filter((x) => x.kind === 'sfx').map((x) => ({ line: x.line, id: x.id })),
|
|
177
|
+
voices: ids.filter((x) => x.kind === 'voice').map((x) => ({ line: x.line, text: x.id })),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** every take of the cut, its spec read through the repo's unit */
|
|
182
|
+
export const takesOfLoaded = (l: Loaded): Take[] => takesOf(l.t, l.root, l)
|
|
183
|
+
|
|
184
|
+
/** the world each take shows: its row's `stamp` paths, else the unit's `stamp`, else the whole repo; each measured once */
|
|
185
|
+
export async function worldOf(l: Pick<Loaded, 'root' | 'unit'>): Promise<(tk: Take) => string> {
|
|
186
|
+
const { worldStamp } = await import('./footage.ts')
|
|
187
|
+
const seen = new Map<string, string>()
|
|
188
|
+
return (tk) => {
|
|
189
|
+
const paths = tk.spec.stamp ?? l.unit?.stamp
|
|
190
|
+
const k = JSON.stringify(paths ?? null)
|
|
191
|
+
let w = seen.get(k)
|
|
192
|
+
if (w === undefined) seen.set(k, (w = worldStamp(l.root, paths)))
|
|
193
|
+
return w
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** the brand's text styles, null with no brand */
|
|
198
|
+
async function brandStyles(root: string): Promise<Record<string, string> | null> {
|
|
199
|
+
const brand = await (await import('../assets/run-assets.ts')).loadBrand(root)
|
|
200
|
+
if (typeof brand === 'string') throw new Refused(brand)
|
|
201
|
+
return brand?.trailer?.styles ?? null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** what a title's `font` word names: a font file found beside the .trailer or from the repo root, else a Google
|
|
205
|
+
* Fonts family at its weight (400 unless it says) */
|
|
206
|
+
function fontOf(l: Pick<Loaded, 'dir' | 'root'>, ti: Title): { file: string | null; family?: string; weight: number } | null {
|
|
207
|
+
const f = ti.fx?.font
|
|
208
|
+
if (f === undefined) return null
|
|
209
|
+
const weight = ti.fx?.weight ?? 400
|
|
210
|
+
return /\.(ttf|otf|woff2?)$/i.test(f) ? { file: fileOf(f, l), weight } : { file: null, family: f, weight }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** the font file a title draws with, fetched from Google Fonts once when it names a family; a family Google lacks
|
|
214
|
+
* fails at the title's line */
|
|
215
|
+
async function titleFont(l: Loaded, ti: Title): Promise<string | null> {
|
|
216
|
+
const f = fontOf(l, ti)
|
|
217
|
+
if (!f) return null
|
|
218
|
+
if (f.file) return f.file
|
|
219
|
+
if (!f.family) throw new Refused(`${relative(l.root, l.file)}:${ti.line}: title font ${ti.fx?.font}: no such font file beside the .trailer or from the repo root`)
|
|
220
|
+
const { googleFont } = await import('./fonts.ts')
|
|
221
|
+
try {
|
|
222
|
+
return await googleFont(f.family, f.weight)
|
|
223
|
+
} catch (e) {
|
|
224
|
+
throw new Refused(`${relative(l.root, l.file)}:${ti.line}: ${(e as Error).message}`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** what a title draws, but its font file (titleFont, or googleFile where nothing is fetched) */
|
|
229
|
+
export function titleRequest(l: Pick<Loaded, 'root' | 't'>, ti: Title, look: TrailerLook, motion: TitleMotion): TitleRequest {
|
|
230
|
+
return { root: l.root, text: ti.text, style: ti.style, size: l.t.size, place: placeOf(ti), look, motion, ...(ti.step ? { step: ti.step } : {}), ...(ti.fx ? { fx: ti.fx } : {}) }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** one line per font a title names: a file found, a Google Fonts family cached or to fetch */
|
|
234
|
+
export async function fontLines(l: Loaded): Promise<string[]> {
|
|
235
|
+
const seen = new Map<string, string>()
|
|
236
|
+
for (const s of l.t.shots)
|
|
237
|
+
for (const ti of s.titles) {
|
|
238
|
+
const f = fontOf(l, ti)
|
|
239
|
+
if (!f || f.file) continue
|
|
240
|
+
const { googleFile } = await import('./fonts.ts')
|
|
241
|
+
const k = `${f.family} ${f.weight}`
|
|
242
|
+
if (!seen.has(k)) seen.set(k, `font "${f.family}" ${f.weight}: ${existsSync(googleFile(f.family!, f.weight)) ? 'cached' : 'to fetch from Google Fonts'}`)
|
|
243
|
+
}
|
|
244
|
+
return [...seen.values()]
|
|
245
|
+
}
|
|
246
|
+
|
|
89
247
|
/** the clips, images and sound files the lines name, each found where it should be */
|
|
90
248
|
function checkFiles(l: Loaded): TrailerError[] {
|
|
91
249
|
const out: TrailerError[] = []
|
|
@@ -94,28 +252,108 @@ function checkFiles(l: Loaded): TrailerError[] {
|
|
|
94
252
|
}
|
|
95
253
|
for (const s of l.t.shots) {
|
|
96
254
|
if (s.film?.kind === 'clip' || s.film?.kind === 'image') need(s.film.line, `film ${s.film.kind}`, s.film.path)
|
|
97
|
-
for (const
|
|
255
|
+
for (const ti of s.titles) if (ti.fx?.font && /\.(ttf|otf|woff2?)$/i.test(ti.fx.font)) need(ti.line, 'title font', ti.fx.font)
|
|
256
|
+
for (const x of s.sfx) if (isFileSound(x.id, x)) need(x.line, 'sfx', x.id)
|
|
98
257
|
for (const v of s.voices) if (v.file) need(v.line, 'voice file', v.file)
|
|
258
|
+
for (const x of [...s.sfx, ...s.voices]) for (const f of x.run?.reads ?? []) need(x.line, `${'id' in x ? 'sfx' : 'voice'} run reads`, f)
|
|
259
|
+
}
|
|
260
|
+
for (const m of [l.t.music, ...l.t.shots.map((s) => s.music)]) {
|
|
261
|
+
if (m && m.song !== NO_MUSIC && isFileSound(m.song, m)) need(m.line, 'music', m.song)
|
|
262
|
+
for (const f of m?.run?.reads ?? []) need(m!.line, 'music run reads', f)
|
|
99
263
|
}
|
|
100
|
-
for (const m of [l.t.music, ...l.t.shots.map((s) => s.music)]) if (m && m.song !== NO_MUSIC && isFileWord(m.song)) need(m.line, 'music', m.song)
|
|
101
264
|
const v = l.t.vertical
|
|
102
265
|
if (v && typeof v.outro === 'object') need(v.line, 'vertical outro', v.outro.clip)
|
|
103
266
|
return out
|
|
104
267
|
}
|
|
105
268
|
|
|
106
|
-
/**
|
|
269
|
+
/** a sound word that names a file: never a quoted prompt nor a run's */
|
|
270
|
+
const isFileSound = (w: string, x: { prompt?: true; run?: unknown }): boolean => !x.prompt && !x.run && isFileWord(w)
|
|
271
|
+
|
|
272
|
+
/** the ids that are not files, prompts nor runs: what the repo's trailer unit resolves (else the house) */
|
|
107
273
|
export function soundIds(t: Trailer): { line: number; kind: 'music' | 'sfx' | 'voice'; id: string }[] {
|
|
274
|
+
const id = (x: { prompt?: true; run?: unknown }): boolean => !x.prompt && !x.run
|
|
108
275
|
return [
|
|
109
|
-
...[t.music, ...t.shots.map((s) => s.music)].flatMap((m) => (m && m.song !== NO_MUSIC && !isFileWord(m.song) ? [{ line: m.line, kind: 'music' as const, id: m.song }] : [])),
|
|
276
|
+
...[t.music, ...t.shots.map((s) => s.music)].flatMap((m) => (m && m.song !== NO_MUSIC && id(m) && !isFileWord(m.song) ? [{ line: m.line, kind: 'music' as const, id: m.song }] : [])),
|
|
110
277
|
...t.shots.flatMap((s) => [
|
|
111
|
-
...s.sfx.filter((x) => !isFileWord(x.id)).map((x) => ({ line: x.line, kind: 'sfx' as const, id: x.id })),
|
|
112
|
-
...s.voices.filter((v) => v.text !== undefined).map((v) => ({ line: v.line, kind: 'voice' as const, id: v.text! })),
|
|
278
|
+
...s.sfx.filter((x) => id(x) && !isFileWord(x.id)).map((x) => ({ line: x.line, kind: 'sfx' as const, id: x.id })),
|
|
279
|
+
...s.voices.filter((v) => id(v) && v.text !== undefined).map((v) => ({ line: v.line, kind: 'voice' as const, id: v.text! })),
|
|
113
280
|
]),
|
|
114
281
|
]
|
|
115
282
|
}
|
|
116
283
|
|
|
284
|
+
/** a sound that is no plain file: the repo's unit first (an id it knows), else the house's maker (a prompt, a run, a
|
|
285
|
+
* `.mid`, an id the unit answers null) */
|
|
286
|
+
export async function soundFile(l: Pick<Loaded, 'root' | 'file' | 'unit'>, req: SoundRequest, say: Say): Promise<string> {
|
|
287
|
+
const unit = l.unit
|
|
288
|
+
const own = houseOnly(req) ? null : req.kind === 'music' ? unit?.sound?.music && ((x: string) => unit.sound!.music!(x, { ...(req.take ? { take: req.take } : {}), seconds: req.seconds ?? 0 })) : req.kind === 'sfx' ? unit?.sound?.sfx : unit?.sound?.voice
|
|
289
|
+
const got = own ? await own(req.text) : null
|
|
290
|
+
if (got) return got
|
|
291
|
+
const house = await houseSound()
|
|
292
|
+
if (!house) throw new Refused(`${relative(l.root, l.file)}:${req.line}: ${req.kind} ${req.run ? 'run' : req.prompt ? `"${req.text}"` : req.text} is no file, and neither a trailer unit (a <name>.shipkit.ts default-exporting trailer({ sound })) nor the house's sound maker makes it`)
|
|
293
|
+
return house.resolveSound(l.root, req, say)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** a request the repo's unit never sees: a prompt, a run, a `.mid` */
|
|
297
|
+
const houseOnly = (r: SoundRequest): boolean => r.prompt === true || r.run !== undefined || r.text.endsWith('.mid')
|
|
298
|
+
|
|
299
|
+
/** a sound line as the house reads it */
|
|
300
|
+
export function soundRequest(kind: SoundRequest['kind'], text: string, x: { prompt?: true; gen?: SoundGen; run?: SoundRun; line: number }, dir: string, seconds?: number, take?: string): SoundRequest {
|
|
301
|
+
const g = x.gen ?? {}
|
|
302
|
+
return {
|
|
303
|
+
kind,
|
|
304
|
+
text,
|
|
305
|
+
...(g.seed !== undefined ? { seed: g.seed } : {}),
|
|
306
|
+
...(g.by ? { by: g.by } : {}),
|
|
307
|
+
...(g.for !== undefined ? { for: g.for } : {}),
|
|
308
|
+
...(g.hit ? { hit: true } : {}),
|
|
309
|
+
...(seconds !== undefined ? { seconds } : {}),
|
|
310
|
+
line: x.line,
|
|
311
|
+
...(x.run ? { run: x.run } : {}),
|
|
312
|
+
dir,
|
|
313
|
+
...(x.prompt ? { prompt: true } : {}),
|
|
314
|
+
...(take ? { take } : {}),
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** the sounds of the cut the house makes: every prompt, run and `.mid`, and every id whose kind the unit has no
|
|
319
|
+
* function for (an id the unit answers null is known only when it answers) */
|
|
320
|
+
export function houseRequests(l: Loaded): SoundRequest[] {
|
|
321
|
+
const s = l.unit?.sound
|
|
322
|
+
const out: SoundRequest[] = []
|
|
323
|
+
const add = (r: SoundRequest): void => void (houseOnly(r) || !(r.kind === 'music' ? s?.music : r.kind === 'sfx' ? s?.sfx : s?.voice) ? out.push(r) : undefined)
|
|
324
|
+
for (const { cue, at, end } of musicCues(l.t)) if (cue.song !== SCORE && (!isFileSound(cue.song, cue) || cue.song.endsWith('.mid'))) add(soundRequest('music', cue.song.endsWith('.mid') && !cue.prompt && !cue.run ? (fileOf(cue.song, l) ?? cue.song) : cue.song, cue, l.dir, cue.from + end - at, cue.take))
|
|
325
|
+
for (const sh of l.t.shots) {
|
|
326
|
+
for (const x of sh.sfx) if (!isFileSound(x.id, x)) add(soundRequest('sfx', x.id, x, l.dir))
|
|
327
|
+
for (const v of sh.voices) if (!v.file) add(soundRequest('voice', v.text ?? 'run', v, l.dir))
|
|
328
|
+
}
|
|
329
|
+
return out
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** one line per sound the house makes for this cut (its command, made or to make, a paid voice's characters), from
|
|
333
|
+
* the house's `soundStates`; none until it exists. A failure of the house is said, never thrown past the check */
|
|
334
|
+
export async function soundLines(l: Loaded): Promise<string[]> {
|
|
335
|
+
const p = join(import.meta.dir, 'sound', 'index.ts')
|
|
336
|
+
const reqs = houseRequests(l)
|
|
337
|
+
if (!existsSync(p) || reqs.length === 0) return []
|
|
338
|
+
try {
|
|
339
|
+
const m = (await import(p)) as { soundStates?: (root: string, rs: SoundRequest[]) => Promise<unknown[]>; stateLine?: (s: unknown) => string }
|
|
340
|
+
if (!m.soundStates) return []
|
|
341
|
+
return (await m.soundStates(l.root, reqs)).map((s) => (m.stateLine ? m.stateLine(s) : JSON.stringify(s)))
|
|
342
|
+
} catch (e) {
|
|
343
|
+
return [`WARN the house's sound states failed: ${(e as Error).message}`]
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** the house's sound maker (design call 9, src/trailer/sound/index.ts), null until it exists */
|
|
348
|
+
async function houseSound(): Promise<{ resolveSound(root: string, req: SoundRequest, say: Say): Promise<string> } | null> {
|
|
349
|
+
const p = join(import.meta.dir, 'sound', 'index.ts')
|
|
350
|
+
return existsSync(p) ? ((await import(p)) as { resolveSound(root: string, req: SoundRequest, say: Say): Promise<string> }) : null
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** the errors, which stop the build, then the warnings, which do not */
|
|
117
354
|
export function sayErrors(l: Loaded, say: Say, cwd: string): boolean {
|
|
118
355
|
for (const e of l.errors) say(`${relative(cwd, l.file)}:${e.line}: ${e.message}`)
|
|
356
|
+
for (const w of l.warnings) say(`WARN ${relative(cwd, l.file)}:${w.line}: ${w.message}`)
|
|
119
357
|
return l.errors.length === 0
|
|
120
358
|
}
|
|
121
359
|
|
|
@@ -130,30 +368,57 @@ const filmText = (f: Film | null): string => {
|
|
|
130
368
|
if (f.kind === 'run') return `run "${f.cmd}"${f.still ? ' still' : ''}`
|
|
131
369
|
if (f.kind === 'cli') return f.argv.join(' ')
|
|
132
370
|
if (f.kind === 'url') return `url ${f.route}${f.viewport ? ` viewport ${f.viewport.join('x')}` : ''}${f.still ? ' still' : ''}`
|
|
371
|
+
if (f.kind === 'page') return `page ${f.route}${f.query.length ? ` ${f.query.join(' ')}` : ''}, stepped by ${f.step}${f.still ? ', still' : ''}`
|
|
372
|
+
if (f.kind === 'grid') return `grid of ${f.cells.join(', ')}${f.pop ? `, a cell every ${secs(f.pop)}` : ''}${f.grow ? `, growing to ${f.grow.map(secs).join(' and ')}` : ''}`
|
|
373
|
+
if (f.kind === 'split') return `split of ${f.cells.join(', ')}, seams at ${f.angle}°${f.slide ? `, sliding in over ${secs(f.slide)}` : ''}`
|
|
133
374
|
return 'card'
|
|
134
375
|
}
|
|
135
376
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
377
|
+
/** what changes at a shot's edges in its take (states.ts): a box rising or a change playing on a cut */
|
|
378
|
+
function edgesOf(s: Shot, tk: Take, fps: number, rule: TrailerUnit['states']): string {
|
|
379
|
+
const states = tk.stamp?.states
|
|
380
|
+
if (!states) return ''
|
|
381
|
+
const first = Math.ceil(s.from * fps - 1e-6)
|
|
382
|
+
const found = edgeChanges(decodeStates(states), first, first + Math.round(s.length * fps) - 1, rule ?? {})
|
|
383
|
+
return found.length > 0 ? `, AT ITS EDGES: ${found.join('; ')}` : ''
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** the timeline, one line a shot then one a footage block, each take's state against the world: a take's last frame
|
|
387
|
+
* held, a change at its edges */
|
|
388
|
+
export function timeline(l: Loaded, takes: readonly Take[], world: World | null, cwd: string): string[] {
|
|
138
389
|
const st = starts(l.t)
|
|
139
390
|
const out = [`${relative(cwd, l.file)}: ${l.t.shots.length} shots, ${total(l.t).toFixed(2)} s, ${l.t.size.join('x')} at ${l.t.fps} fps`]
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
?
|
|
148
|
-
:
|
|
391
|
+
const takeOf = (s: Shot): Take | undefined => takes.find((x) => x.shot === s)
|
|
392
|
+
const line = (s: Shot, head: string): void => {
|
|
393
|
+
const tk = takeOf(s)
|
|
394
|
+
const f = s.film
|
|
395
|
+
const cells = f?.kind === 'grid' || f?.kind === 'split' ? gridCells(l.t, f.cells).map((c) => (c ? takeOf(c) : undefined)) : []
|
|
396
|
+
const state =
|
|
397
|
+
f?.kind === 'grid' || f?.kind === 'split'
|
|
398
|
+
? `a ${f.kind} of ${cells.length}, ${cells.filter((c) => c?.stamp).length} filmed`
|
|
399
|
+
: !isTake(f)
|
|
400
|
+
? f?.kind === 'card' ? 'drawn' : 'a file'
|
|
401
|
+
: !tk?.stamp
|
|
402
|
+
? 'NOT FILMED'
|
|
403
|
+
: world === null || fresh(tk.stamp, worldFor(world, tk))
|
|
404
|
+
? 'filmed'
|
|
405
|
+
: `filmed on an older world, ${ago(tk.stamp.filmedAt)} (--refilm ${s.name})`
|
|
406
|
+
const held = tk?.stamp && !takeIsStill(tk.film) && tk.stamp.seconds - s.from < s.length - 0.05 ? `, its last frame held ${(s.length - (tk.stamp.seconds - s.from)).toFixed(2)} s` : ''
|
|
407
|
+
const edges = tk ? edgesOf(s, tk, l.t.fps, l.unit?.states) : ''
|
|
149
408
|
const sec = s.section ? ` [section ${s.section.name} at ${+s.section.at.toFixed(3)} s]` : ''
|
|
150
409
|
const fit = s.written !== undefined ? ` (fitted, written ${s.written} s)` : ''
|
|
151
|
-
out.push(` ${
|
|
152
|
-
}
|
|
410
|
+
out.push(` ${head} ${s.name.padEnd(20)} ${s.length.toFixed(2).padStart(5)} s${fit} ${state}${held}${edges}${sec}`)
|
|
411
|
+
}
|
|
412
|
+
l.t.shots.forEach((s, i) => line(s, `${String(i + 1).padStart(2)} ${st[i]!.toFixed(2).padStart(6)} s `))
|
|
413
|
+
for (const s of l.t.footage) line(s, ' footage ')
|
|
414
|
+
const sc = l.score
|
|
415
|
+
if (sc) out.push(` score: ${sc.sections.length} sections, ${sc.notes.length} notes, ${(sc.seconds - SCORE_LEAD).toFixed(2)} s from the cut's ${(l.t.timelineAt ?? 0).toFixed(2)} s${sc.stop !== null ? `, cut dead at its ${(sc.stop - SCORE_LEAD).toFixed(2)} s` : ''}`)
|
|
153
416
|
return out
|
|
154
417
|
}
|
|
155
418
|
|
|
156
|
-
|
|
419
|
+
function secs(x: number): string {
|
|
420
|
+
return `${+x.toFixed(2)} s`
|
|
421
|
+
}
|
|
157
422
|
const clockText = (x: number): string => `${Math.floor(x / 60)}:${(x % 60).toFixed(2).padStart(5, '0')}`
|
|
158
423
|
const cell = (s: string): string => s.replaceAll('|', '\\|').replaceAll('\n', ' ')
|
|
159
424
|
|
|
@@ -168,12 +433,12 @@ export function montage(t: Trailer, file: string): string {
|
|
|
168
433
|
'',
|
|
169
434
|
`Written from \`${file}\` by every \`sk trailer\` build: edit the .trailer, never this file.`,
|
|
170
435
|
'',
|
|
171
|
-
`${secs(total(t))}, ${t.size[0]}x${t.size[1]} at ${t.fps} fps, ${t.shots.length} shots${m ? `, music \`${m.song}\` from ${clockText(m.from)}` : ', no music'}${t.sections.length ? `, ${t.sections.length} sections: ${t.sections.map((x) => x.name).join(', ')}` : ''}${t.fadeIn ? `, opens from black over ${secs(t.fadeIn)}` : ''}.`,
|
|
436
|
+
`${secs(total(t))}, ${t.size[0]}x${t.size[1]} at ${t.fps} fps, ${t.shots.length} shots${m ? `, music \`${m.song}\` from ${clockText(m.from)}` : ''}${t.score ? `, its own score from ${clockText(t.timelineAt ?? 0)}` : ''}${!m && !t.score ? ', no music' : ''}${t.sections.length ? `, ${t.sections.length} sections: ${t.sections.map((x) => x.name).join(', ')}` : ''}${t.fadeIn ? `, opens from black over ${secs(t.fadeIn)}` : ''}.`,
|
|
172
437
|
'',
|
|
173
438
|
'| # | at | shot | length | film | on screen | heard | cut out |',
|
|
174
439
|
'|---|---|---|---|---|---|---|---|',
|
|
175
440
|
...t.shots.map((s, i) =>
|
|
176
|
-
`| ${i + 1} | ${clockText(st[i] ?? 0)} | ${s.name} | ${secs(s.length)} | ${cell(filmText(s.film) + (s.from ? `, from ${secs(s.from)}` : '') + (s.frame ? `, frame ${s.frame}` : '') + (s.push ? `, push ${s.push}` : ''))} | ${cell(s.titles.map((ti) => `${ti.style} "${ti.text}" at ${secs(ti.at)}`).join('<br>'))} | ${cell(heard(s))} | ${s.cut.kind === 'hard' ? 'hard' : `${s.cut.kind} ${secs(s.cut.seconds)}`} |`,
|
|
441
|
+
`| ${i + 1} | ${clockText(st[i] ?? 0)} | ${s.name} | ${secs(s.length)} | ${cell(filmText(s.film) + (s.from ? `, from ${secs(s.from)}` : '') + (s.frame ? `, frame ${s.frame}${s.frameAt ? ` at ${s.frameAt.join(',')}` : ''}` : '') + (s.push ? `, push ${s.push}` : ''))} | ${cell([...s.titles.map((ti) => `${ti.style} "${ti.text}" at ${secs(ti.at)}`), ...s.shakes.map((x) => `shake at ${secs(x.at)}`), ...s.flashes.map((x) => `flash at ${secs(x.at)}`)].join('<br>'))} | ${cell(heard(s))} | ${s.cut.kind === 'hard' ? 'hard' : `${s.cut.kind} ${secs(s.cut.seconds)}`} |`,
|
|
177
442
|
),
|
|
178
443
|
'',
|
|
179
444
|
].join('\n')
|
|
@@ -186,16 +451,23 @@ export interface BuildOptions {
|
|
|
186
451
|
cwd: string
|
|
187
452
|
}
|
|
188
453
|
|
|
454
|
+
/** the shots `names` names and the cells their grids and splits read: what a preview films */
|
|
455
|
+
function withCells(l: Loaded, names: Iterable<string>): Set<string> {
|
|
456
|
+
const out = new Set(names)
|
|
457
|
+
for (const s of l.t.shots) if (out.has(s.name) && (s.film?.kind === 'grid' || s.film?.kind === 'split')) for (const c of s.film.cells) out.add(c)
|
|
458
|
+
return out
|
|
459
|
+
}
|
|
460
|
+
|
|
189
461
|
/** film the takes missing (and what --refilm names), two at once; every shot is tried, the failures said together */
|
|
190
|
-
async function filmAll(l: Loaded, world:
|
|
191
|
-
const { filmTake
|
|
192
|
-
const takes =
|
|
462
|
+
async function filmAll(l: Loaded, world: World, refilm: Refilm, say: Say, only?: ReadonlySet<string>): Promise<Take[]> {
|
|
463
|
+
const { filmTake } = await import('./footage.ts')
|
|
464
|
+
const takes = takesOfLoaded(l)
|
|
193
465
|
const ask = takesToFilm(takes, world, refilm, only)
|
|
194
466
|
const failed: string[] = []
|
|
195
467
|
const lane = async (): Promise<void> => {
|
|
196
468
|
for (let group = ask.shift(); group; group = ask.shift()) {
|
|
197
469
|
try {
|
|
198
|
-
const got = await filmTake(group[0]!, { root: l.root, t: l.t, world, cli: l.cli, say })
|
|
470
|
+
const got = await filmTake(group[0]!, { root: l.root, t: l.t, world: worldFor(world, group[0]!), cli: l.cli, holes: l.unit?.holes === true, say })
|
|
199
471
|
for (const tk of group) {
|
|
200
472
|
const i = takes.indexOf(tk)
|
|
201
473
|
takes[i] = { ...tk, stamp: got.stamp }
|
|
@@ -210,9 +482,29 @@ async function filmAll(l: Loaded, world: string, refilm: Refilm, say: Say, only?
|
|
|
210
482
|
return takes
|
|
211
483
|
}
|
|
212
484
|
|
|
485
|
+
/** the score's wav of `seconds`, rendered by the synth when the cache misses it; each section's level said, and a
|
|
486
|
+
* section whose notes are silent refuses the build */
|
|
487
|
+
export async function scoreFile(l: Loaded, seconds: number, say: Say): Promise<string> {
|
|
488
|
+
const { levelLine, scoreWav } = await import('./score/sound.ts')
|
|
489
|
+
const sc = l.score ?? refuse(`${relative(l.root, l.file)}: the score did not compile`)
|
|
490
|
+
const w = scoreWav(sc, seconds, trailerCache(l.root, 'sound'))
|
|
491
|
+
say(w.made ? ` score: ${sc.notes.length} notes, ${Math.ceil(seconds)} s rendered in ${(w.ms / 1000).toFixed(1)} s` : ' score: cached')
|
|
492
|
+
const silent: string[] = []
|
|
493
|
+
for (const x of w.levels) {
|
|
494
|
+
const { line, bad } = levelLine(x)
|
|
495
|
+
say(` ${bad ? 'WARN ' : ''}${line}`)
|
|
496
|
+
if (bad === 'silent') silent.push(x.name)
|
|
497
|
+
}
|
|
498
|
+
if (silent.length > 0) refuse(`the score is silent in ${silent.join(', ')} although notes play there: an instrument at volume 0, a track volume 0, or notes out of hearing`)
|
|
499
|
+
const m = await (await import('./ffmpeg.ts')).loudnessKept(w.file)
|
|
500
|
+
say(` score: ${m.lufs.toFixed(1)} LUFS integrated, true peak ${m.truePeak.toFixed(1)} dBTP`)
|
|
501
|
+
return w.file
|
|
502
|
+
}
|
|
503
|
+
|
|
213
504
|
/** everything the stitch reads but its output: sources, titles, sounds, the look */
|
|
214
|
-
async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Shot[], silent: boolean) {
|
|
215
|
-
const
|
|
505
|
+
async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Shot[], silent: boolean, say: Say = () => undefined) {
|
|
506
|
+
const { gridMask, splitMasks } = await import('./stitch.ts')
|
|
507
|
+
const [{ cardPng, titlePng }, { lookOf }, { duration }, { trailerCache }, { loadBrand, identityLogo }] = await Promise.all([
|
|
216
508
|
import('./titles.ts'),
|
|
217
509
|
import('./look.ts'),
|
|
218
510
|
import('./ffmpeg.ts'),
|
|
@@ -224,33 +516,50 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
|
|
|
224
516
|
const look = lookOf(brand, await identityLogo(l.root))
|
|
225
517
|
const base = { look, brand }
|
|
226
518
|
const titles = trailerCache(l.root, 'titles')
|
|
227
|
-
const
|
|
519
|
+
const pieces = trailerCache(l.root, 'pieces')
|
|
520
|
+
const takeOf = (s: Shot): Take | undefined => takes.find((x) => x.shot === s) ?? takes.find((x) => x.shot.name === s.name)
|
|
521
|
+
const sources = await Promise.all(shots.map(async (s): Promise<Source> => {
|
|
228
522
|
const f = s.film!
|
|
523
|
+
if (f.kind === 'grid' || f.kind === 'split') {
|
|
524
|
+
const cells = gridCells(l.t, f.cells).map((c, j) => {
|
|
525
|
+
const tk = c && takeOf(c)
|
|
526
|
+
if (!c || !tk?.stamp) throw new Refused(`shot ${s.name}: its cell ${f.cells[j]} is not filmed`)
|
|
527
|
+
return { footage: tk.file, seconds: tk.stamp.seconds, from: c.from, frame: c.frame, ...(f.kind === 'grid' && c.frameAt ? { at: c.frameAt } : {}) }
|
|
528
|
+
})
|
|
529
|
+
if (f.kind === 'split') return { split: cells, angle: f.angle, slide: f.slide, masks: await splitMasks(pieces, cells.length, f.angle, l.t.size, look) }
|
|
530
|
+
const k = f.grow ? GROW_RES : 1
|
|
531
|
+
return { grid: cells, pop: f.pop, ...(f.grow ? { grow: f.grow } : {}), mask: await gridMask(pieces, cells.length, [l.t.size[0] * k, l.t.size[1] * k], look) }
|
|
532
|
+
}
|
|
229
533
|
if (f.kind === 'card') return { image: cardPng(titles, l.t.size, look) }
|
|
230
534
|
if (f.kind === 'image') return { image: fileOf(f.path, l)! }
|
|
231
535
|
if (f.kind === 'clip') {
|
|
232
536
|
const file = fileOf(f.path, l)!
|
|
233
537
|
return { footage: file, seconds: duration(file) }
|
|
234
538
|
}
|
|
235
|
-
const tk =
|
|
539
|
+
const tk = takeOf(s)!
|
|
236
540
|
return tk.stamp!.seconds === 0 ? { image: tk.file } : { footage: tk.file, seconds: tk.stamp!.seconds }
|
|
237
|
-
})
|
|
238
|
-
const
|
|
541
|
+
}))
|
|
542
|
+
const styleErrors = resolveStyles(l.t, brand?.trailer?.styles ?? {})
|
|
543
|
+
if (styleErrors.length > 0) throw new Refused(styleErrors.map((e) => `${relative(l.root, l.file)}:${e.line}: ${e.message}`).join('\n'))
|
|
544
|
+
const motions = titleMotions({ ...l.t, shots: [...shots] })
|
|
545
|
+
const drawn = await Promise.all(shots.map((s, i) => Promise.all(s.titles.map(async (ti, j) => titlePng(titles, { ...titleRequest(l, ti, look, motions[i]![j]!), font: await titleFont(l, ti) })))))
|
|
239
546
|
if (silent) return { ...base, sources, titles: drawn, music: [], sfx: shots.map(() => []), voices: shots.map(() => []) }
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
547
|
+
const resolve = (req: SoundRequest): Promise<string> => soundFile(l, req, say)
|
|
548
|
+
const ask = (kind: SoundRequest['kind'], text: string, x: { prompt?: true; gen?: SoundGen; run?: SoundRun; line: number }, seconds?: number, take?: string): SoundRequest => soundRequest(kind, text, x, l.dir, seconds, take)
|
|
549
|
+
const music = await Promise.all(
|
|
550
|
+
musicCues(l.t).map(({ cue, at, end }) => {
|
|
551
|
+
if (cue.song === SCORE) return scoreFile(l, cue.from + end - at, say)
|
|
552
|
+
const seconds = cue.from + end - at
|
|
553
|
+
if (isFileSound(cue.song, cue)) return cue.song.endsWith('.mid') ? resolve(ask('music', fileOf(cue.song, l)!, cue, seconds)) : fileOf(cue.song, l)!
|
|
554
|
+
return resolve(ask('music', cue.song, cue, seconds, cue.take))
|
|
555
|
+
}),
|
|
556
|
+
)
|
|
248
557
|
const sfx = await Promise.all(shots.map((s) => Promise.all(s.sfx.map(async (x) => {
|
|
249
|
-
const file =
|
|
558
|
+
const file = isFileSound(x.id, x) ? fileOf(x.id, l)! : await resolve(ask('sfx', x.id, x))
|
|
250
559
|
return { file, seconds: duration(file) }
|
|
251
560
|
}))))
|
|
252
561
|
const voices = await Promise.all(shots.map((s) => Promise.all(s.voices.map(async (v) => {
|
|
253
|
-
const file = v.file ? fileOf(v.file, l)! : await
|
|
562
|
+
const file = v.file ? fileOf(v.file, l)! : await resolve(ask('voice', v.text ?? 'run', v))
|
|
254
563
|
return { file, seconds: duration(file) }
|
|
255
564
|
}))))
|
|
256
565
|
return { ...base, sources, titles: drawn, music, sfx, voices }
|
|
@@ -259,14 +568,17 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
|
|
|
259
568
|
/** a vertical cut finished: what the brand's vertical look draws over the stitched cut, the outro, the end card. The
|
|
260
569
|
* finish is cached on the pieces and the mix it reads, so a build that changed nothing encodes nothing */
|
|
261
570
|
async function finishVertical(l: Loaded, input: { look: TrailerLook; brand: BrandLike | null }, done: Stitched, cut: string, out: string, final: boolean): Promise<{ note: string; seconds: number }> {
|
|
262
|
-
const [{ finishArgs, finishSeconds, verticalOf }, { drawEnd, drawMark, drawWear }, { cachedAs }, { duration, hasAudio }, { trailerCache }] = await Promise.all([
|
|
571
|
+
const [{ finishArgs, finishSeconds, verticalOf }, { drawEnd, drawMark, drawWear }, { cachedAs }, { duration, hasAudio }, { trailerCache }, { shortLook }] = await Promise.all([
|
|
263
572
|
import('./vertical.ts'),
|
|
264
573
|
import('./vertical-draw.ts'),
|
|
265
574
|
import('./stitch.ts'),
|
|
266
575
|
import('./ffmpeg.ts'),
|
|
267
576
|
import('./footage.ts'),
|
|
577
|
+
import('./look.ts'),
|
|
268
578
|
])
|
|
269
579
|
const v = l.t.vertical!
|
|
580
|
+
// the vertical's words wear the short's face and colours
|
|
581
|
+
input = { ...input, look: shortLook(input.look) }
|
|
270
582
|
const vl = verticalOf(input.brand, input.look, basename(l.root), v.mark)
|
|
271
583
|
const dir = trailerCache(l.root, 'titles')
|
|
272
584
|
const clip = typeof v.outro === 'object' ? fileOf(v.outro.clip, l)! : null
|
|
@@ -301,16 +613,42 @@ function outDir(l: Loaded): string {
|
|
|
301
613
|
return dir
|
|
302
614
|
}
|
|
303
615
|
|
|
304
|
-
/**
|
|
616
|
+
/** each shot's take beside the cut, in cut order and by name (`out/<cut>-shots/07-town.mp4`): clones, free on APFS;
|
|
617
|
+
* never a hard link, which a take filmed again in place would turn into the new picture */
|
|
618
|
+
function shelve(l: Loaded, takes: readonly Take[]): void {
|
|
619
|
+
const dir = join(outDir(l), `${l.t.name}-shots`)
|
|
620
|
+
rmSync(dir, { recursive: true, force: true })
|
|
621
|
+
mkdirSync(dir, { recursive: true })
|
|
622
|
+
l.t.shots.forEach((s, i) => {
|
|
623
|
+
const f = s.film
|
|
624
|
+
const from = f?.kind === 'clip' || f?.kind === 'image' ? fileOf(f.path, l) : takes.find((x) => x.shot === s)?.file
|
|
625
|
+
if (!from || !existsSync(from)) return
|
|
626
|
+
copyFileSync(from, join(dir, `${String(i + 1).padStart(2, '0')}-${s.name}${extname(from)}`), constants.COPYFILE_FICLONE)
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** the cache trimmed after a stitch, said in one line */
|
|
631
|
+
async function pruneAfter(root: string, say: Say): Promise<void> {
|
|
632
|
+
const { prune, sayPruned } = await import('./prune.ts')
|
|
633
|
+
// the cut is written: a prune that fails is said, never the build's failure
|
|
634
|
+
try {
|
|
635
|
+
for (const line of sayPruned(await prune(root, { dry: false }), false, true)) say(line)
|
|
636
|
+
} catch (e) {
|
|
637
|
+
say(`WARN the cache prune failed, the cut is written: ${(e as Error).message}`)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** the whole cut: out/<cut>.mp4, its strip, montage page and shelf of takes; the timeline and what was rendered, said */
|
|
305
642
|
export async function build(l: Loaded, o: BuildOptions): Promise<string> {
|
|
306
643
|
const t0 = performance.now()
|
|
307
|
-
const [{
|
|
644
|
+
const [{ trailerCache }, { stitch, strip, rendered }, { ffmpegMissing }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts')])
|
|
308
645
|
const missing = ffmpegMissing()
|
|
309
646
|
if (missing) throw new Refused(missing)
|
|
310
|
-
const world =
|
|
647
|
+
const world = await worldOf(l)
|
|
311
648
|
const takes = await filmAll(l, world, o.refilm, o.say)
|
|
649
|
+
shelve(l, takes)
|
|
312
650
|
const tFilm = performance.now()
|
|
313
|
-
const input = await stitchInput(l, takes, l.t.shots, false)
|
|
651
|
+
const input = await stitchInput(l, takes, l.t.shots, false, o.say)
|
|
314
652
|
const out = join(outDir(l), `${l.t.name}.mp4`)
|
|
315
653
|
// a vertical cut stitches into the cache, and its finish writes the mp4
|
|
316
654
|
const cut = l.t.vertical ? join(trailerCache(l.root, 'pieces'), `${l.t.name}-cut.mp4`) : out
|
|
@@ -322,23 +660,85 @@ export async function build(l: Loaded, o: BuildOptions): Promise<string> {
|
|
|
322
660
|
for (const line of timeline(l, takes, world, o.cwd)) o.say(line)
|
|
323
661
|
o.say(`ok: ${out} (${(finished?.seconds ?? total(l.t)).toFixed(2)} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s: filming ${((tFilm - t0) / 1000).toFixed(1)} s, ${rendered(done)}${finished ? `, ${finished.note}` : ''}`)
|
|
324
662
|
o.say(`ok: ${stripFile}`)
|
|
663
|
+
// the vertical's finish can lay an outro's own sound after the mix: its mp4 is what is measured then
|
|
664
|
+
const heard = finished ? out : done.mix
|
|
665
|
+
if (heard) {
|
|
666
|
+
const [{ loudness, loudnessKept }, { MIX_TRUE_PEAK }] = await Promise.all([import('./ffmpeg.ts'), import('./graph.ts')])
|
|
667
|
+
const { lufs, truePeak } = await (finished ? loudness(heard) : loudnessKept(heard))
|
|
668
|
+
const line = `the mix: ${lufs.toFixed(1)} LUFS integrated, true peak ${truePeak.toFixed(1)} dBTP`
|
|
669
|
+
if (truePeak > 0) refuse(`${line}, past 0 dBTP: it clips on a player. ${out} is written; lower the loudest sounds (a score's volume, a sfx or voice volume)`)
|
|
670
|
+
o.say(truePeak > MIX_TRUE_PEAK ? `WARN ${line}, past the limiter's ${MIX_TRUE_PEAK} dBTP` : `ok: ${line}`)
|
|
671
|
+
}
|
|
672
|
+
await pruneAfter(l.root, o.say)
|
|
325
673
|
return out
|
|
326
674
|
}
|
|
327
675
|
|
|
676
|
+
/** one section with its window of the sound: out/<cut>-<section>.mp4; `start` the shots before the first section */
|
|
677
|
+
export async function previewSection(l: Loaded, name: string, o: BuildOptions): Promise<void> {
|
|
678
|
+
const t0 = performance.now()
|
|
679
|
+
const [{ trailerCache }, { stitchPiece, rendered }, { ffmpegMissing }, { pieces }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts'), import('./plan.ts')])
|
|
680
|
+
const missing = ffmpegMissing()
|
|
681
|
+
if (missing) throw new Refused(missing)
|
|
682
|
+
const p = pieces(l.t).find((x) => x.name === name) ?? refuse(`no section ${name} in ${relative(o.cwd, l.file)}: ${pieces(l.t).map((x) => x.name).join(', ')}`)
|
|
683
|
+
const takes = await filmAll(l, await worldOf(l), null, o.say, withCells(l, l.t.shots.slice(p.chain, p.end).map((s) => s.name)))
|
|
684
|
+
const input = await stitchInput(l, takes, l.t.shots, false, o.say)
|
|
685
|
+
const out = join(outDir(l), `${l.t.name}-${name}.mp4`)
|
|
686
|
+
const done = await stitchPiece({ ...input, t: l.t, out, cache: trailerCache(l.root, 'pieces'), encoder: o.final ? 'final' : 'draft' }, name)
|
|
687
|
+
await pruneAfter(l.root, o.say)
|
|
688
|
+
o.say(`ok: ${out} (${((p.to - p.from) / l.t.fps).toFixed(2)} s, frames ${p.from} to ${p.to}) in ${((performance.now() - t0) / 1000).toFixed(1)} s (${rendered(done)})`)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** the score alone, no picture: out/<cut>.score.wav, its waveform with a line at each section's start on
|
|
692
|
+
* out/<cut>.score.png, each section's level and the whole's loudness said */
|
|
693
|
+
export async function auditionScore(l: Loaded, o: BuildOptions): Promise<void> {
|
|
694
|
+
const t0 = performance.now()
|
|
695
|
+
const sc = l.score ?? refuse(`${relative(o.cwd, l.file)} has no score: score tempo <bpm> in the header, sections with their plays`)
|
|
696
|
+
const { ffmpeg, ffmpegMissing } = await import('./ffmpeg.ts')
|
|
697
|
+
const missing = ffmpegMissing()
|
|
698
|
+
if (missing) throw new Refused(missing)
|
|
699
|
+
const src = await scoreFile(l, sc.seconds, o.say)
|
|
700
|
+
const wav = join(outDir(l), `${l.t.name}.score.wav`)
|
|
701
|
+
copyFileSync(src, wav)
|
|
702
|
+
const png = join(outDir(l), `${l.t.name}.score.png`)
|
|
703
|
+
const [w, h] = [1600, 320]
|
|
704
|
+
const { TAIL_S } = await import('./score/synth.ts')
|
|
705
|
+
const span = Math.ceil(sc.seconds) + TAIL_S
|
|
706
|
+
const line = (at: number, colour: string): string => `drawbox=x=${Math.round((at / span) * w)}:y=0:w=2:h=ih:color=${colour}@0.9:t=fill`
|
|
707
|
+
const marks = [...sc.sections.map((x) => line(x.at, '0xff5050')), ...(sc.stop !== null ? [line(sc.stop, '0xffd040')] : [])]
|
|
708
|
+
// the waveform over a dark ground, the spectrum under it (low notes at the bottom), a line at each section's start
|
|
709
|
+
const graph = [
|
|
710
|
+
`color=c=0x14171c:s=${w}x${h}[bg]`,
|
|
711
|
+
`[0:a]asplit[a][b]`,
|
|
712
|
+
`[a]showwavespic=s=${w}x${h}:split_channels=1:colors=0x9ad0ff|0x9ad0ff[wv]`,
|
|
713
|
+
`[bg][wv]overlay=format=auto[top]`,
|
|
714
|
+
`[b]showspectrumpic=s=${w}x${h}:legend=0:scale=log:fscale=log[sp]`,
|
|
715
|
+
`[top][sp]vstack,${marks.join(',')}`,
|
|
716
|
+
].join(';')
|
|
717
|
+
await ffmpeg(['-i', wav, '-filter_complex', graph, '-frames:v', '1', png])
|
|
718
|
+
// the score as General MIDI, for a DAW or a soundfont (score/midi.ts), once it exists
|
|
719
|
+
const midi = join(import.meta.dir, 'score', 'midi.ts')
|
|
720
|
+
const mid = join(outDir(l), `${l.t.name}.score.mid`)
|
|
721
|
+
if (existsSync(midi)) writeFileSync(mid, ((await import(midi)) as { scoreMidi(s: CompiledScore): Uint8Array }).scoreMidi(sc))
|
|
722
|
+
o.say(`ok: ${wav} (${span.toFixed(1)} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
|
|
723
|
+
o.say(`ok: ${png} (the waveform over the spectrum; red: each section's start${sc.stop !== null ? ', yellow: the stop' : ''})`)
|
|
724
|
+
if (existsSync(mid)) o.say(`ok: ${mid} (the score as General MIDI)`)
|
|
725
|
+
}
|
|
726
|
+
|
|
328
727
|
/** one shot alone with its titles, no sound: out/shot-<name>.mp4 and its nine frames on out/shot-<name>.png */
|
|
329
728
|
export async function previewShot(l: Loaded, name: string, o: BuildOptions): Promise<void> {
|
|
330
729
|
const t0 = performance.now()
|
|
331
730
|
const s = l.t.shots.find((x) => x.name === name) ?? refuse(`no shot ${name} in ${relative(o.cwd, l.file)}: ${l.t.shots.map((x) => x.name).join(', ')}`)
|
|
332
|
-
const [{
|
|
731
|
+
const [{ trailerCache }, { stitch }, { sheetOf, ffmpegMissing }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts')])
|
|
333
732
|
const missing = ffmpegMissing()
|
|
334
733
|
if (missing) throw new Refused(missing)
|
|
335
734
|
const one: Trailer = { ...l.t, name: `${l.t.name}-shot-${name}`, music: null, fadeIn: 0, shots: [{ ...s, cut: { line: s.line, kind: 'hard', seconds: 0 }, sfx: [], voices: [], music: null }] }
|
|
336
|
-
const takes = await filmAll(l,
|
|
735
|
+
const takes = await filmAll(l, await worldOf(l), o.refilm, o.say, withCells(l, [name]))
|
|
337
736
|
const input = await stitchInput(l, takes, one.shots, true)
|
|
338
737
|
const out = join(outDir(l), `shot-${name}.mp4`)
|
|
339
738
|
await stitch({ ...input, t: one, out, cache: trailerCache(l.root, 'pieces'), silent: true })
|
|
340
739
|
const sheet = out.replace(/\.mp4$/, '.png')
|
|
341
740
|
await sheetOf(out, s.length, sheet)
|
|
741
|
+
await pruneAfter(l.root, o.say)
|
|
342
742
|
o.say(`ok: ${out} (${s.length} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
|
|
343
743
|
o.say(`ok: ${sheet}`)
|
|
344
744
|
}
|