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,603 @@
|
|
|
1
|
+
// a cut's .trailer file → its mp4: load (parse, resolve every file and sound), film what is missing, draw the titles,
|
|
2
|
+
// stitch the pieces, lay the strip, write the montage page. `sk trailer` and its verbs call these
|
|
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 { TrailerCheckInput, TrailerUnit } from './adapter.ts'
|
|
6
|
+
import { type FilmVerb, type Film, gridCells, parseTrailer, 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'
|
|
8
|
+
import type { BrandLike, TrailerLook } from './look.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 } from './graph.ts'
|
|
13
|
+
import type { Stitched } from './stitch.ts'
|
|
14
|
+
import { projectCli, readVocab } from './vocab.ts'
|
|
15
|
+
|
|
16
|
+
export type Say = (line: string) => void
|
|
17
|
+
|
|
18
|
+
export class Refused extends Error {}
|
|
19
|
+
const refuse = (why: string): never => {
|
|
20
|
+
throw new Refused(why)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** every cut a build can name: each `.trailer` under `trailer/`, at any depth, a kept version's and `out/` left out */
|
|
24
|
+
export function cutFiles(root: string, dir = join(root, 'trailer')): string[] {
|
|
25
|
+
if (!existsSync(dir)) return []
|
|
26
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
27
|
+
.flatMap((e) => (e.isDirectory() ? (e.name === 'versions' || e.name === 'out' ? [] : cutFiles(root, join(dir, e.name))) : e.name.endsWith('.trailer') ? [join(dir, e.name)] : []))
|
|
28
|
+
.sort()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** every `.trailer` under `trailer/<trailer>/` */
|
|
32
|
+
export function trailerFiles(root: string): string[] {
|
|
33
|
+
const dir = join(root, 'trailer')
|
|
34
|
+
if (!existsSync(dir)) return []
|
|
35
|
+
return readdirSync(dir)
|
|
36
|
+
.filter((d) => statSync(join(dir, d)).isDirectory())
|
|
37
|
+
.flatMap((d) => readdirSync(join(dir, d)).filter((f) => f.endsWith('.trailer')).map((f) => join(dir, d, f)))
|
|
38
|
+
.sort()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** the .trailer a command names: a file, a folder's main.trailer (or its one .trailer), or the one under trailer/ */
|
|
42
|
+
export function trailerFile(arg: string | undefined, root: string, cwd: string): string {
|
|
43
|
+
if (arg !== undefined) {
|
|
44
|
+
const p = resolve(cwd, arg)
|
|
45
|
+
if (existsSync(p) && statSync(p).isDirectory()) {
|
|
46
|
+
if (existsSync(join(p, 'main.trailer'))) return join(p, 'main.trailer')
|
|
47
|
+
const own = readdirSync(p).filter((f) => f.endsWith('.trailer'))
|
|
48
|
+
return own.length === 1 ? join(p, own[0]!) : refuse(own.length === 0 ? `${arg} has no .trailer` : `${arg} has several cuts, name one: ${own.join(', ')}`)
|
|
49
|
+
}
|
|
50
|
+
return existsSync(p) ? p : refuse(`no file ${arg}`)
|
|
51
|
+
}
|
|
52
|
+
const all = trailerFiles(root)
|
|
53
|
+
if (all.length === 1) return all[0]!
|
|
54
|
+
return refuse(all.length === 0 ? 'no .trailer under trailer/<trailer>/: write trailer/<name>/main.trailer (wiki build/trailer.md)' : `several cuts, name one: ${all.map((f) => relative(cwd, f)).join(', ')}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** a sound or a media word that names a file rather than an id */
|
|
58
|
+
export const isFileWord = (w: string): boolean => w.includes('/') || /\.[a-z0-9]{2,5}$/i.test(w)
|
|
59
|
+
|
|
60
|
+
export interface Loaded {
|
|
61
|
+
root: string
|
|
62
|
+
file: string
|
|
63
|
+
dir: string
|
|
64
|
+
t: Trailer
|
|
65
|
+
errors: TrailerError[]
|
|
66
|
+
/** what the trailer unit's check said and never blocks */
|
|
67
|
+
warnings: TrailerError[]
|
|
68
|
+
/** the project CLI's argv, null when the repo has none */
|
|
69
|
+
cli: string[] | null
|
|
70
|
+
verbs: FilmVerb[] | null
|
|
71
|
+
/** the score's notes, null when the cut has none or it failed */
|
|
72
|
+
score: CompiledScore | null
|
|
73
|
+
/** each `@<file>` word of a film line, found */
|
|
74
|
+
recipes: Map<string, Recipe>
|
|
75
|
+
/** the repo's trailer unit, null when it has none */
|
|
76
|
+
unit: TrailerUnit | null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** a file a line names: beside the .trailer first, then from the repo root */
|
|
80
|
+
export function fileOf(path: string, l: Pick<Loaded, 'dir' | 'root'>): string | null {
|
|
81
|
+
if (isAbsolute(path)) return existsSync(path) ? path : null
|
|
82
|
+
for (const f of [join(l.dir, path), join(l.root, path)]) if (existsSync(f)) return f
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** the file parsed and checked against the repo: every error at its line, all at once; the trailer unit's check with
|
|
87
|
+
* it unless `o.check` is false (a cut read only for its keys); `o.fresh` reads the rows and the unit again */
|
|
88
|
+
export async function load(file: string, root: string, o: { text?: string; dir?: string; check?: boolean; fresh?: boolean } = {}): Promise<Loaded> {
|
|
89
|
+
const text = o.text ?? readFileSync(file, 'utf8')
|
|
90
|
+
// a watch's build reads the rows and the unit again: a bumped version or an edited unit keys as a fresh run would
|
|
91
|
+
if (o.fresh) {
|
|
92
|
+
;(await import('./vocab.ts')).forgetVocab()
|
|
93
|
+
;(await import('./load-unit.ts')).forgetTrailerUnit(root)
|
|
94
|
+
}
|
|
95
|
+
const cli = projectCli(root)
|
|
96
|
+
let verbs: FilmVerb[] | null = null
|
|
97
|
+
const errors: TrailerError[] = []
|
|
98
|
+
let parsed = parseTrailer(text, { verbs: null })
|
|
99
|
+
// the project CLI is asked only when a film line does not read as a core film
|
|
100
|
+
const lines = text.split('\n')
|
|
101
|
+
if (cli !== null && parsed.errors.some((e) => /^\s*film\s/.test(lines[e.line - 1] ?? ''))) {
|
|
102
|
+
try {
|
|
103
|
+
verbs = readVocab(root, cli)
|
|
104
|
+
parsed = parseTrailer(text, { verbs })
|
|
105
|
+
} catch (e) {
|
|
106
|
+
errors.push({ line: 1, message: (e as Error).message })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const t = parsed.trailer
|
|
110
|
+
const { loadTrailerUnit } = await import('./load-unit.ts')
|
|
111
|
+
const l: Loaded = { root, file, dir: o.dir ?? dirname(file), t, errors: [...errors, ...parsed.errors], warnings: [], cli, verbs, score: null, recipes: new Map(), unit: await loadTrailerUnit(root) }
|
|
112
|
+
l.errors.push(...findRecipes(l))
|
|
113
|
+
if (l.errors.length === 0) l.errors.push(...checkFiles(l))
|
|
114
|
+
if (l.errors.length === 0 && t.score) {
|
|
115
|
+
const c = compileScore(t.score, t.sections, trailerCache(root, 'score'))
|
|
116
|
+
l.score = c.score
|
|
117
|
+
l.errors.push(...c.errors)
|
|
118
|
+
}
|
|
119
|
+
if (l.errors.length === 0 && l.unit?.check && o.check !== false) {
|
|
120
|
+
for (const f of await l.unit.check(checkInput(l))) (f.warn ? l.warnings : l.errors).push({ line: f.line, message: f.message })
|
|
121
|
+
l.warnings.sort((a, b) => a.line - b.line)
|
|
122
|
+
}
|
|
123
|
+
l.errors.sort((a, b) => a.line - b.line)
|
|
124
|
+
return l
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** each `@<file>` word of a film line, and each file a `film run` reads: beside the .trailer first, then from the repo
|
|
128
|
+
* root; its repo relative path */
|
|
129
|
+
function findRecipes(l: Loaded): TrailerError[] {
|
|
130
|
+
const out: TrailerError[] = []
|
|
131
|
+
for (const s of [...l.t.shots, ...l.t.footage]) {
|
|
132
|
+
const f = s.film
|
|
133
|
+
if (f?.kind === 'run')
|
|
134
|
+
for (const p of f.reads ?? []) {
|
|
135
|
+
const abs = fileOf(p, l)
|
|
136
|
+
if (!abs) out.push({ line: f.line, message: `film run: no file ${p} beside the .trailer or from the repo root (reads)` })
|
|
137
|
+
// its bytes by their digest: an image read as text would lose them
|
|
138
|
+
else l.recipes.set(`reads:${p}`, { path: relative(l.root, abs), text: new Bun.CryptoHasher('sha1').update(readFileSync(abs)).digest('hex') })
|
|
139
|
+
}
|
|
140
|
+
if (f?.kind !== 'cli') continue
|
|
141
|
+
for (const i of f.files ?? []) {
|
|
142
|
+
const w = f.argv[i]!
|
|
143
|
+
if (l.recipes.has(`file:${w}`)) continue
|
|
144
|
+
const abs = fileOf(w, l)
|
|
145
|
+
if (!abs) out.push({ line: f.line, message: `film ${f.argv[0]}: no file ${w} beside the .trailer or from the repo root` })
|
|
146
|
+
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') })
|
|
147
|
+
}
|
|
148
|
+
for (const w of recipeWords(f.argv)) {
|
|
149
|
+
if (l.recipes.has(w)) continue
|
|
150
|
+
const abs = fileOf(w.slice(1), l)
|
|
151
|
+
if (!abs) out.push({ line: f.line, message: `film: no recipe ${w.slice(1)} beside the .trailer or from the repo root` })
|
|
152
|
+
else l.recipes.set(w, { path: abs.startsWith(`${l.root}/`) ? relative(l.root, abs) : abs, text: readFileSync(abs, 'utf8') })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** what the trailer unit's check reads: the film lines of the project CLI, the sounds named by id */
|
|
159
|
+
export function checkInput(l: Loaded): TrailerCheckInput {
|
|
160
|
+
const ids = soundIds(l.t)
|
|
161
|
+
return {
|
|
162
|
+
file: l.file,
|
|
163
|
+
dir: l.dir,
|
|
164
|
+
size: l.t.size,
|
|
165
|
+
fps: l.t.fps,
|
|
166
|
+
films: [...l.t.shots, ...l.t.footage].flatMap((s) => (s.film?.kind === 'cli' ? [filmLineOf(s, s.film, l)] : [])),
|
|
167
|
+
music: [l.t.music, ...l.t.shots.map((s) => s.music)].flatMap((m) =>
|
|
168
|
+
m && m.song !== NO_MUSIC && !isFileWord(m.song) ? [{ line: m.line, id: m.song, ...(m.take ? { take: m.take } : {}), from: m.from, opening: m === l.t.music }] : [],
|
|
169
|
+
),
|
|
170
|
+
sfx: ids.filter((x) => x.kind === 'sfx').map((x) => ({ line: x.line, id: x.id })),
|
|
171
|
+
voices: ids.filter((x) => x.kind === 'voice').map((x) => ({ line: x.line, text: x.id })),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** every take of the cut, its spec read through the repo's unit */
|
|
176
|
+
export const takesOfLoaded = (l: Loaded): Take[] => takesOf(l.t, l.root, l)
|
|
177
|
+
|
|
178
|
+
/** the world each take shows: its row's `stamp` paths, else the unit's `stamp`, else the whole repo; each measured once */
|
|
179
|
+
export async function worldOf(l: Pick<Loaded, 'root' | 'unit'>): Promise<(tk: Take) => string> {
|
|
180
|
+
const { worldStamp } = await import('./footage.ts')
|
|
181
|
+
const seen = new Map<string, string>()
|
|
182
|
+
return (tk) => {
|
|
183
|
+
const paths = tk.spec.stamp ?? l.unit?.stamp
|
|
184
|
+
const k = JSON.stringify(paths ?? null)
|
|
185
|
+
let w = seen.get(k)
|
|
186
|
+
if (w === undefined) seen.set(k, (w = worldStamp(l.root, paths)))
|
|
187
|
+
return w
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** the clips, images and sound files the lines name, each found where it should be */
|
|
192
|
+
function checkFiles(l: Loaded): TrailerError[] {
|
|
193
|
+
const out: TrailerError[] = []
|
|
194
|
+
const need = (line: number, what: string, path: string): void => {
|
|
195
|
+
if (!fileOf(path, l)) out.push({ line, message: `${what}: no file ${path} beside the .trailer or from the repo root` })
|
|
196
|
+
}
|
|
197
|
+
for (const s of l.t.shots) {
|
|
198
|
+
if (s.film?.kind === 'clip' || s.film?.kind === 'image') need(s.film.line, `film ${s.film.kind}`, s.film.path)
|
|
199
|
+
for (const x of s.sfx) if (isFileWord(x.id)) need(x.line, 'sfx', x.id)
|
|
200
|
+
for (const v of s.voices) if (v.file) need(v.line, 'voice file', v.file)
|
|
201
|
+
}
|
|
202
|
+
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)
|
|
203
|
+
const v = l.t.vertical
|
|
204
|
+
if (v && typeof v.outro === 'object') need(v.line, 'vertical outro', v.outro.clip)
|
|
205
|
+
return out
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** the ids that are not files: what only the repo's trailer unit resolves */
|
|
209
|
+
export function soundIds(t: Trailer): { line: number; kind: 'music' | 'sfx' | 'voice'; id: string }[] {
|
|
210
|
+
return [
|
|
211
|
+
...[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 }] : [])),
|
|
212
|
+
...t.shots.flatMap((s) => [
|
|
213
|
+
...s.sfx.filter((x) => !isFileWord(x.id)).map((x) => ({ line: x.line, kind: 'sfx' as const, id: x.id })),
|
|
214
|
+
...s.voices.filter((v) => v.text !== undefined).map((v) => ({ line: v.line, kind: 'voice' as const, id: v.text! })),
|
|
215
|
+
]),
|
|
216
|
+
]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** the errors, which stop the build, then the warnings, which do not */
|
|
220
|
+
export function sayErrors(l: Loaded, say: Say, cwd: string): boolean {
|
|
221
|
+
for (const e of l.errors) say(`${relative(cwd, l.file)}:${e.line}: ${e.message}`)
|
|
222
|
+
for (const w of l.warnings) say(`WARN ${relative(cwd, l.file)}:${w.line}: ${w.message}`)
|
|
223
|
+
return l.errors.length === 0
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const ago = (iso: string): string => {
|
|
227
|
+
const m = Math.round((Date.now() - Date.parse(iso)) / 60_000)
|
|
228
|
+
return m < 60 ? `${m} min ago` : m < 48 * 60 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} days ago`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const filmText = (f: Film | null): string => {
|
|
232
|
+
if (!f) return 'nothing'
|
|
233
|
+
if (f.kind === 'clip' || f.kind === 'image') return `${f.kind} ${f.path}`
|
|
234
|
+
if (f.kind === 'run') return `run "${f.cmd}"${f.still ? ' still' : ''}`
|
|
235
|
+
if (f.kind === 'cli') return f.argv.join(' ')
|
|
236
|
+
if (f.kind === 'url') return `url ${f.route}${f.viewport ? ` viewport ${f.viewport.join('x')}` : ''}${f.still ? ' still' : ''}`
|
|
237
|
+
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 ')}` : ''}`
|
|
238
|
+
if (f.kind === 'split') return `split of ${f.cells.join(', ')}, seams at ${f.angle}°${f.slide ? `, sliding in over ${secs(f.slide)}` : ''}`
|
|
239
|
+
return 'card'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** what changes at a shot's edges in its take (states.ts): a box rising or a change playing on a cut */
|
|
243
|
+
function edgesOf(s: Shot, tk: Take, fps: number, rule: TrailerUnit['states']): string {
|
|
244
|
+
const states = tk.stamp?.states
|
|
245
|
+
if (!states) return ''
|
|
246
|
+
const first = Math.ceil(s.from * fps - 1e-6)
|
|
247
|
+
const found = edgeChanges(decodeStates(states), first, first + Math.round(s.length * fps) - 1, rule ?? {})
|
|
248
|
+
return found.length > 0 ? `, AT ITS EDGES: ${found.join('; ')}` : ''
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** the timeline, one line a shot then one a footage block, each take's state against the world: a take's last frame
|
|
252
|
+
* held, a change at its edges */
|
|
253
|
+
export function timeline(l: Loaded, takes: readonly Take[], world: World | null, cwd: string): string[] {
|
|
254
|
+
const st = starts(l.t)
|
|
255
|
+
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`]
|
|
256
|
+
const takeOf = (s: Shot): Take | undefined => takes.find((x) => x.shot === s)
|
|
257
|
+
const line = (s: Shot, head: string): void => {
|
|
258
|
+
const tk = takeOf(s)
|
|
259
|
+
const f = s.film
|
|
260
|
+
const cells = f?.kind === 'grid' || f?.kind === 'split' ? gridCells(l.t, f.cells).map((c) => (c ? takeOf(c) : undefined)) : []
|
|
261
|
+
const state =
|
|
262
|
+
f?.kind === 'grid' || f?.kind === 'split'
|
|
263
|
+
? `a ${f.kind} of ${cells.length}, ${cells.filter((c) => c?.stamp).length} filmed`
|
|
264
|
+
: !isTake(f)
|
|
265
|
+
? f?.kind === 'card' ? 'drawn' : 'a file'
|
|
266
|
+
: !tk?.stamp
|
|
267
|
+
? 'NOT FILMED'
|
|
268
|
+
: world === null || fresh(tk.stamp, worldFor(world, tk))
|
|
269
|
+
? 'filmed'
|
|
270
|
+
: `filmed on an older world, ${ago(tk.stamp.filmedAt)} (--refilm ${s.name})`
|
|
271
|
+
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` : ''
|
|
272
|
+
const edges = tk ? edgesOf(s, tk, l.t.fps, l.unit?.states) : ''
|
|
273
|
+
const sec = s.section ? ` [section ${s.section.name} at ${+s.section.at.toFixed(3)} s]` : ''
|
|
274
|
+
const fit = s.written !== undefined ? ` (fitted, written ${s.written} s)` : ''
|
|
275
|
+
out.push(` ${head} ${s.name.padEnd(20)} ${s.length.toFixed(2).padStart(5)} s${fit} ${state}${held}${edges}${sec}`)
|
|
276
|
+
}
|
|
277
|
+
l.t.shots.forEach((s, i) => line(s, `${String(i + 1).padStart(2)} ${st[i]!.toFixed(2).padStart(6)} s `))
|
|
278
|
+
for (const s of l.t.footage) line(s, ' footage ')
|
|
279
|
+
const sc = l.score
|
|
280
|
+
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` : ''}`)
|
|
281
|
+
return out
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function secs(x: number): string {
|
|
285
|
+
return `${+x.toFixed(2)} s`
|
|
286
|
+
}
|
|
287
|
+
const clockText = (x: number): string => `${Math.floor(x / 60)}:${(x % 60).toFixed(2).padStart(5, '0')}`
|
|
288
|
+
const cell = (s: string): string => s.replaceAll('|', '\\|').replaceAll('\n', ' ')
|
|
289
|
+
|
|
290
|
+
/** the cut as a table an agent or a human reads: `out/<cut>.montage.md`, written by every build */
|
|
291
|
+
export function montage(t: Trailer, file: string): string {
|
|
292
|
+
const st = starts(t)
|
|
293
|
+
const m = t.music
|
|
294
|
+
const heard = (s: Shot): string =>
|
|
295
|
+
[s.music ? `music ${s.music.song}` : '', ...s.sfx.map((x) => `sfx ${x.id} at ${secs(x.at)}`), ...s.voices.map((v) => `voice ${v.text ? `"${v.text}"` : v.file} at ${secs(v.at)}`)].filter(Boolean).join('<br>')
|
|
296
|
+
return [
|
|
297
|
+
`# Montage: ${t.name}`,
|
|
298
|
+
'',
|
|
299
|
+
`Written from \`${file}\` by every \`sk trailer\` build: edit the .trailer, never this file.`,
|
|
300
|
+
'',
|
|
301
|
+
`${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)}` : ''}.`,
|
|
302
|
+
'',
|
|
303
|
+
'| # | at | shot | length | film | on screen | heard | cut out |',
|
|
304
|
+
'|---|---|---|---|---|---|---|---|',
|
|
305
|
+
...t.shots.map((s, i) =>
|
|
306
|
+
`| ${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)}`} |`,
|
|
307
|
+
),
|
|
308
|
+
'',
|
|
309
|
+
].join('\n')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface BuildOptions {
|
|
313
|
+
refilm: Refilm
|
|
314
|
+
final: boolean
|
|
315
|
+
say: Say
|
|
316
|
+
cwd: string
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** the shots `names` names and the cells their grids and splits read: what a preview films */
|
|
320
|
+
function withCells(l: Loaded, names: Iterable<string>): Set<string> {
|
|
321
|
+
const out = new Set(names)
|
|
322
|
+
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)
|
|
323
|
+
return out
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** film the takes missing (and what --refilm names), two at once; every shot is tried, the failures said together */
|
|
327
|
+
async function filmAll(l: Loaded, world: World, refilm: Refilm, say: Say, only?: ReadonlySet<string>): Promise<Take[]> {
|
|
328
|
+
const { filmTake } = await import('./footage.ts')
|
|
329
|
+
const takes = takesOfLoaded(l)
|
|
330
|
+
const ask = takesToFilm(takes, world, refilm, only)
|
|
331
|
+
const failed: string[] = []
|
|
332
|
+
const lane = async (): Promise<void> => {
|
|
333
|
+
for (let group = ask.shift(); group; group = ask.shift()) {
|
|
334
|
+
try {
|
|
335
|
+
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 })
|
|
336
|
+
for (const tk of group) {
|
|
337
|
+
const i = takes.indexOf(tk)
|
|
338
|
+
takes[i] = { ...tk, stamp: got.stamp }
|
|
339
|
+
}
|
|
340
|
+
} catch (e) {
|
|
341
|
+
failed.push(e instanceof Error ? e.message : String(e))
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
await Promise.all([lane(), lane()])
|
|
346
|
+
if (failed.length > 0) throw new Refused(`${failed.length} shot(s) did not film:\n${failed.join('\n')}`)
|
|
347
|
+
return takes
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** the score's wav of `seconds`, rendered by the synth when the cache misses it; each section's level said, and a
|
|
351
|
+
* section whose notes are silent refuses the build */
|
|
352
|
+
export async function scoreFile(l: Loaded, seconds: number, say: Say): Promise<string> {
|
|
353
|
+
const { levelLine, scoreWav } = await import('./score/sound.ts')
|
|
354
|
+
const sc = l.score ?? refuse(`${relative(l.root, l.file)}: the score did not compile`)
|
|
355
|
+
const w = scoreWav(sc, seconds, trailerCache(l.root, 'sound'))
|
|
356
|
+
say(w.made ? ` score: ${sc.notes.length} notes, ${Math.ceil(seconds)} s rendered in ${(w.ms / 1000).toFixed(1)} s` : ' score: cached')
|
|
357
|
+
const silent: string[] = []
|
|
358
|
+
for (const x of w.levels) {
|
|
359
|
+
const { line, bad } = levelLine(x)
|
|
360
|
+
say(` ${bad ? 'WARN ' : ''}${line}`)
|
|
361
|
+
if (bad === 'silent') silent.push(x.name)
|
|
362
|
+
}
|
|
363
|
+
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`)
|
|
364
|
+
const m = await (await import('./ffmpeg.ts')).loudnessKept(w.file)
|
|
365
|
+
say(` score: ${m.lufs.toFixed(1)} LUFS integrated, true peak ${m.truePeak.toFixed(1)} dBTP`)
|
|
366
|
+
return w.file
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** everything the stitch reads but its output: sources, titles, sounds, the look */
|
|
370
|
+
async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Shot[], silent: boolean, say: Say = () => undefined) {
|
|
371
|
+
const { gridMask, splitMasks } = await import('./stitch.ts')
|
|
372
|
+
const [{ cardPng, placeOf, titlePng }, { lookOf }, { duration }, { trailerCache }, { loadBrand, identityLogo }] = await Promise.all([
|
|
373
|
+
import('./titles.ts'),
|
|
374
|
+
import('./look.ts'),
|
|
375
|
+
import('./ffmpeg.ts'),
|
|
376
|
+
import('./footage.ts'),
|
|
377
|
+
import('../assets/run-assets.ts'),
|
|
378
|
+
])
|
|
379
|
+
const brand = await loadBrand(l.root)
|
|
380
|
+
if (typeof brand === 'string') throw new Refused(brand)
|
|
381
|
+
const look = lookOf(brand, await identityLogo(l.root))
|
|
382
|
+
const base = { look, brand }
|
|
383
|
+
const titles = trailerCache(l.root, 'titles')
|
|
384
|
+
const pieces = trailerCache(l.root, 'pieces')
|
|
385
|
+
const takeOf = (s: Shot): Take | undefined => takes.find((x) => x.shot === s) ?? takes.find((x) => x.shot.name === s.name)
|
|
386
|
+
const sources = await Promise.all(shots.map(async (s): Promise<Source> => {
|
|
387
|
+
const f = s.film!
|
|
388
|
+
if (f.kind === 'grid' || f.kind === 'split') {
|
|
389
|
+
const cells = gridCells(l.t, f.cells).map((c, j) => {
|
|
390
|
+
const tk = c && takeOf(c)
|
|
391
|
+
if (!c || !tk?.stamp) throw new Refused(`shot ${s.name}: its cell ${f.cells[j]} is not filmed`)
|
|
392
|
+
return { footage: tk.file, seconds: tk.stamp.seconds, from: c.from, frame: c.frame, ...(f.kind === 'grid' && c.frameAt ? { at: c.frameAt } : {}) }
|
|
393
|
+
})
|
|
394
|
+
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) }
|
|
395
|
+
const k = f.grow ? GROW_RES : 1
|
|
396
|
+
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) }
|
|
397
|
+
}
|
|
398
|
+
if (f.kind === 'card') return { image: cardPng(titles, l.t.size, look) }
|
|
399
|
+
if (f.kind === 'image') return { image: fileOf(f.path, l)! }
|
|
400
|
+
if (f.kind === 'clip') {
|
|
401
|
+
const file = fileOf(f.path, l)!
|
|
402
|
+
return { footage: file, seconds: duration(file) }
|
|
403
|
+
}
|
|
404
|
+
const tk = takeOf(s)!
|
|
405
|
+
return tk.stamp!.seconds === 0 ? { image: tk.file } : { footage: tk.file, seconds: tk.stamp!.seconds }
|
|
406
|
+
}))
|
|
407
|
+
const drawn = await Promise.all(shots.map((s) => Promise.all(s.titles.map((ti) => titlePng(titles, l.root, ti.text, ti.style, l.t.size, placeOf(ti), look, ti.step)))))
|
|
408
|
+
if (silent) return { ...base, sources, titles: drawn, music: [], sfx: shots.map(() => []), voices: shots.map(() => []) }
|
|
409
|
+
const ids = soundIds(l.t)
|
|
410
|
+
const unit = ids.length > 0 ? await (await import('./load-unit.ts')).loadTrailerUnit(l.root) : null
|
|
411
|
+
const resolveId = async (kind: 'music' | 'sfx' | 'voice', id: string, line: number, seconds = 0, take?: string): Promise<string> => {
|
|
412
|
+
const f = kind === 'music' ? unit?.sound?.music && ((x: string) => unit.sound!.music!(x, { take, seconds })) : kind === 'sfx' ? unit?.sound?.sfx : unit?.sound?.voice
|
|
413
|
+
if (!f) throw new Refused(`${relative(l.root, l.file)}:${line}: ${kind} ${id} is no file, and no trailer unit resolves ${kind} ids (a <name>.shipkit.ts default-exporting trailer({ sound }))`)
|
|
414
|
+
return f(id)
|
|
415
|
+
}
|
|
416
|
+
const music = await Promise.all(
|
|
417
|
+
musicCues(l.t).map(({ cue, at, end }) =>
|
|
418
|
+
cue.song === SCORE ? scoreFile(l, cue.from + end - at, say) : isFileWord(cue.song) ? fileOf(cue.song, l)! : resolveId('music', cue.song, cue.line, cue.from + end - at, cue.take),
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
const sfx = await Promise.all(shots.map((s) => Promise.all(s.sfx.map(async (x) => {
|
|
422
|
+
const file = isFileWord(x.id) ? fileOf(x.id, l)! : await resolveId('sfx', x.id, x.line)
|
|
423
|
+
return { file, seconds: duration(file) }
|
|
424
|
+
}))))
|
|
425
|
+
const voices = await Promise.all(shots.map((s) => Promise.all(s.voices.map(async (v) => {
|
|
426
|
+
const file = v.file ? fileOf(v.file, l)! : await resolveId('voice', v.text!, v.line)
|
|
427
|
+
return { file, seconds: duration(file) }
|
|
428
|
+
}))))
|
|
429
|
+
return { ...base, sources, titles: drawn, music, sfx, voices }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** a vertical cut finished: what the brand's vertical look draws over the stitched cut, the outro, the end card. The
|
|
433
|
+
* finish is cached on the pieces and the mix it reads, so a build that changed nothing encodes nothing */
|
|
434
|
+
async function finishVertical(l: Loaded, input: { look: TrailerLook; brand: BrandLike | null }, done: Stitched, cut: string, out: string, final: boolean): Promise<{ note: string; seconds: number }> {
|
|
435
|
+
const [{ finishArgs, finishSeconds, verticalOf }, { drawEnd, drawMark, drawWear }, { cachedAs }, { duration, hasAudio }, { trailerCache }, { shortLook }] = await Promise.all([
|
|
436
|
+
import('./vertical.ts'),
|
|
437
|
+
import('./vertical-draw.ts'),
|
|
438
|
+
import('./stitch.ts'),
|
|
439
|
+
import('./ffmpeg.ts'),
|
|
440
|
+
import('./footage.ts'),
|
|
441
|
+
import('./look.ts'),
|
|
442
|
+
])
|
|
443
|
+
const v = l.t.vertical!
|
|
444
|
+
// the vertical's words wear the short's face and colours
|
|
445
|
+
input = { ...input, look: shortLook(input.look) }
|
|
446
|
+
const vl = verticalOf(input.brand, input.look, basename(l.root), v.mark)
|
|
447
|
+
const dir = trailerCache(l.root, 'titles')
|
|
448
|
+
const clip = typeof v.outro === 'object' ? fileOf(v.outro.clip, l)! : null
|
|
449
|
+
const [wear, mark, end] = await Promise.all([
|
|
450
|
+
drawWear(dir, l.root, input.look, vl, l.t.size, l.t.fps),
|
|
451
|
+
clip ? drawMark(dir, l.root, input.look, vl, l.t.size) : null,
|
|
452
|
+
v.outro === 'none' ? null : drawEnd(dir, l.root, input.look, vl, l.t.size),
|
|
453
|
+
])
|
|
454
|
+
const a = {
|
|
455
|
+
cut,
|
|
456
|
+
seconds: duration(cut),
|
|
457
|
+
size: l.t.size,
|
|
458
|
+
fps: l.t.fps,
|
|
459
|
+
wear,
|
|
460
|
+
mark,
|
|
461
|
+
outro: clip ? { clip, seconds: duration(clip), audio: hasAudio(clip) } : null,
|
|
462
|
+
end: end && { ...end, seconds: vl.end!.seconds },
|
|
463
|
+
encoder: final ? ('final' as const) : ('draft' as const),
|
|
464
|
+
}
|
|
465
|
+
const clipStat = clip ? [statSync(clip).size, statSync(clip).mtimeMs] : null
|
|
466
|
+
const key = new Bun.CryptoHasher('sha1').update(JSON.stringify([finishArgs(a, 'out.mp4'), done.pieces.map((p) => p.file), done.mix, clipStat])).digest('hex').slice(0, 16)
|
|
467
|
+
const { files, made } = await cachedAs(join(trailerCache(l.root, 'pieces'), 'vertical'), `${l.t.name}-vertical`, key, ['mp4'], ([o]) => finishArgs(a, o!))
|
|
468
|
+
copyFileSync(files[0]!, out)
|
|
469
|
+
return { note: made ? 'the vertical finished' : 'the vertical cached', seconds: finishSeconds(a) }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** trailer/<trailer>/out/, its own `.gitignore` holding `*`: a build output is never committed by accident */
|
|
473
|
+
function outDir(l: Loaded): string {
|
|
474
|
+
const dir = join(l.dir, 'out')
|
|
475
|
+
mkdirSync(dir, { recursive: true })
|
|
476
|
+
if (!existsSync(join(dir, '.gitignore'))) writeFileSync(join(dir, '.gitignore'), '*\n')
|
|
477
|
+
return dir
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** each shot's take beside the cut, in cut order and by name (`out/<cut>-shots/07-town.mp4`): clones, free on APFS;
|
|
481
|
+
* never a hard link, which a take filmed again in place would turn into the new picture */
|
|
482
|
+
function shelve(l: Loaded, takes: readonly Take[]): void {
|
|
483
|
+
const dir = join(outDir(l), `${l.t.name}-shots`)
|
|
484
|
+
rmSync(dir, { recursive: true, force: true })
|
|
485
|
+
mkdirSync(dir, { recursive: true })
|
|
486
|
+
l.t.shots.forEach((s, i) => {
|
|
487
|
+
const f = s.film
|
|
488
|
+
const from = f?.kind === 'clip' || f?.kind === 'image' ? fileOf(f.path, l) : takes.find((x) => x.shot === s)?.file
|
|
489
|
+
if (!from || !existsSync(from)) return
|
|
490
|
+
copyFileSync(from, join(dir, `${String(i + 1).padStart(2, '0')}-${s.name}${extname(from)}`), constants.COPYFILE_FICLONE)
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** the cache trimmed after a stitch, said in one line */
|
|
495
|
+
async function pruneAfter(root: string, say: Say): Promise<void> {
|
|
496
|
+
const { prune, sayPruned } = await import('./prune.ts')
|
|
497
|
+
// the cut is written: a prune that fails is said, never the build's failure
|
|
498
|
+
try {
|
|
499
|
+
for (const line of sayPruned(await prune(root, { dry: false }), false, true)) say(line)
|
|
500
|
+
} catch (e) {
|
|
501
|
+
say(`WARN the cache prune failed, the cut is written: ${(e as Error).message}`)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** the whole cut: out/<cut>.mp4, its strip, montage page and shelf of takes; the timeline and what was rendered, said */
|
|
506
|
+
export async function build(l: Loaded, o: BuildOptions): Promise<string> {
|
|
507
|
+
const t0 = performance.now()
|
|
508
|
+
const [{ trailerCache }, { stitch, strip, rendered }, { ffmpegMissing }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts')])
|
|
509
|
+
const missing = ffmpegMissing()
|
|
510
|
+
if (missing) throw new Refused(missing)
|
|
511
|
+
const world = await worldOf(l)
|
|
512
|
+
const takes = await filmAll(l, world, o.refilm, o.say)
|
|
513
|
+
shelve(l, takes)
|
|
514
|
+
const tFilm = performance.now()
|
|
515
|
+
const input = await stitchInput(l, takes, l.t.shots, false, o.say)
|
|
516
|
+
const out = join(outDir(l), `${l.t.name}.mp4`)
|
|
517
|
+
// a vertical cut stitches into the cache, and its finish writes the mp4
|
|
518
|
+
const cut = l.t.vertical ? join(trailerCache(l.root, 'pieces'), `${l.t.name}-cut.mp4`) : out
|
|
519
|
+
const done = await stitch({ ...input, t: l.t, out: cut, cache: trailerCache(l.root, 'pieces'), encoder: o.final ? 'final' : 'draft' })
|
|
520
|
+
const finished = l.t.vertical ? await finishVertical(l, input, done, cut, out, o.final) : null
|
|
521
|
+
const stripFile = join(outDir(l), `${l.t.name}.strip.png`)
|
|
522
|
+
await strip(done.pieces, stripFile)
|
|
523
|
+
writeFileSync(join(outDir(l), `${l.t.name}.montage.md`), montage(l.t, basename(l.file)))
|
|
524
|
+
for (const line of timeline(l, takes, world, o.cwd)) o.say(line)
|
|
525
|
+
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}` : ''}`)
|
|
526
|
+
o.say(`ok: ${stripFile}`)
|
|
527
|
+
// the vertical's finish can lay an outro's own sound after the mix: its mp4 is what is measured then
|
|
528
|
+
const heard = finished ? out : done.mix
|
|
529
|
+
if (heard) {
|
|
530
|
+
const [{ loudness, loudnessKept }, { MIX_TRUE_PEAK }] = await Promise.all([import('./ffmpeg.ts'), import('./graph.ts')])
|
|
531
|
+
const { lufs, truePeak } = await (finished ? loudness(heard) : loudnessKept(heard))
|
|
532
|
+
const line = `the mix: ${lufs.toFixed(1)} LUFS integrated, true peak ${truePeak.toFixed(1)} dBTP`
|
|
533
|
+
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)`)
|
|
534
|
+
o.say(truePeak > MIX_TRUE_PEAK ? `WARN ${line}, past the limiter's ${MIX_TRUE_PEAK} dBTP` : `ok: ${line}`)
|
|
535
|
+
}
|
|
536
|
+
await pruneAfter(l.root, o.say)
|
|
537
|
+
return out
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** one section with its window of the sound: out/<cut>-<section>.mp4; `start` the shots before the first section */
|
|
541
|
+
export async function previewSection(l: Loaded, name: string, o: BuildOptions): Promise<void> {
|
|
542
|
+
const t0 = performance.now()
|
|
543
|
+
const [{ trailerCache }, { stitchPiece, rendered }, { ffmpegMissing }, { pieces }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts'), import('./plan.ts')])
|
|
544
|
+
const missing = ffmpegMissing()
|
|
545
|
+
if (missing) throw new Refused(missing)
|
|
546
|
+
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(', ')}`)
|
|
547
|
+
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)))
|
|
548
|
+
const input = await stitchInput(l, takes, l.t.shots, false, o.say)
|
|
549
|
+
const out = join(outDir(l), `${l.t.name}-${name}.mp4`)
|
|
550
|
+
const done = await stitchPiece({ ...input, t: l.t, out, cache: trailerCache(l.root, 'pieces'), encoder: o.final ? 'final' : 'draft' }, name)
|
|
551
|
+
await pruneAfter(l.root, o.say)
|
|
552
|
+
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)})`)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** the score alone, no picture: out/<cut>.score.wav, its waveform with a line at each section's start on
|
|
556
|
+
* out/<cut>.score.png, each section's level and the whole's loudness said */
|
|
557
|
+
export async function auditionScore(l: Loaded, o: BuildOptions): Promise<void> {
|
|
558
|
+
const t0 = performance.now()
|
|
559
|
+
const sc = l.score ?? refuse(`${relative(o.cwd, l.file)} has no score: score tempo <bpm> in the header, sections with their plays`)
|
|
560
|
+
const { ffmpeg, ffmpegMissing } = await import('./ffmpeg.ts')
|
|
561
|
+
const missing = ffmpegMissing()
|
|
562
|
+
if (missing) throw new Refused(missing)
|
|
563
|
+
const src = await scoreFile(l, sc.seconds, o.say)
|
|
564
|
+
const wav = join(outDir(l), `${l.t.name}.score.wav`)
|
|
565
|
+
copyFileSync(src, wav)
|
|
566
|
+
const png = join(outDir(l), `${l.t.name}.score.png`)
|
|
567
|
+
const [w, h] = [1600, 320]
|
|
568
|
+
const { TAIL_S } = await import('./score/synth.ts')
|
|
569
|
+
const span = Math.ceil(sc.seconds) + TAIL_S
|
|
570
|
+
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`
|
|
571
|
+
const marks = [...sc.sections.map((x) => line(x.at, '0xff5050')), ...(sc.stop !== null ? [line(sc.stop, '0xffd040')] : [])]
|
|
572
|
+
// the waveform over a dark ground, the spectrum under it (low notes at the bottom), a line at each section's start
|
|
573
|
+
const graph = [
|
|
574
|
+
`color=c=0x14171c:s=${w}x${h}[bg]`,
|
|
575
|
+
`[0:a]asplit[a][b]`,
|
|
576
|
+
`[a]showwavespic=s=${w}x${h}:split_channels=1:colors=0x9ad0ff|0x9ad0ff[wv]`,
|
|
577
|
+
`[bg][wv]overlay=format=auto[top]`,
|
|
578
|
+
`[b]showspectrumpic=s=${w}x${h}:legend=0:scale=log:fscale=log[sp]`,
|
|
579
|
+
`[top][sp]vstack,${marks.join(',')}`,
|
|
580
|
+
].join(';')
|
|
581
|
+
await ffmpeg(['-i', wav, '-filter_complex', graph, '-frames:v', '1', png])
|
|
582
|
+
o.say(`ok: ${wav} (${span.toFixed(1)} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
|
|
583
|
+
o.say(`ok: ${png} (the waveform over the spectrum; red: each section's start${sc.stop !== null ? ', yellow: the stop' : ''})`)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** one shot alone with its titles, no sound: out/shot-<name>.mp4 and its nine frames on out/shot-<name>.png */
|
|
587
|
+
export async function previewShot(l: Loaded, name: string, o: BuildOptions): Promise<void> {
|
|
588
|
+
const t0 = performance.now()
|
|
589
|
+
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(', ')}`)
|
|
590
|
+
const [{ trailerCache }, { stitch }, { sheetOf, ffmpegMissing }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts')])
|
|
591
|
+
const missing = ffmpegMissing()
|
|
592
|
+
if (missing) throw new Refused(missing)
|
|
593
|
+
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 }] }
|
|
594
|
+
const takes = await filmAll(l, await worldOf(l), o.refilm, o.say, withCells(l, [name]))
|
|
595
|
+
const input = await stitchInput(l, takes, one.shots, true)
|
|
596
|
+
const out = join(outDir(l), `shot-${name}.mp4`)
|
|
597
|
+
await stitch({ ...input, t: one, out, cache: trailerCache(l.root, 'pieces'), silent: true })
|
|
598
|
+
const sheet = out.replace(/\.mp4$/, '.png')
|
|
599
|
+
await sheetOf(out, s.length, sheet)
|
|
600
|
+
await pruneAfter(l.root, o.say)
|
|
601
|
+
o.say(`ok: ${out} (${s.length} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
|
|
602
|
+
o.say(`ok: ${sheet}`)
|
|
603
|
+
}
|