rvlib-shipkit 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- package/src/trailer/words.ts +254 -0
package/src/trailer/build.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
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 { 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'
|
|
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 } from './graph.ts'
|
|
8
13
|
import type { Stitched } from './stitch.ts'
|
|
9
14
|
import { projectCli, readVocab } from './vocab.ts'
|
|
10
15
|
|
|
@@ -15,6 +20,14 @@ const refuse = (why: string): never => {
|
|
|
15
20
|
throw new Refused(why)
|
|
16
21
|
}
|
|
17
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
|
+
|
|
18
31
|
/** every `.trailer` under `trailer/<trailer>/` */
|
|
19
32
|
export function trailerFiles(root: string): string[] {
|
|
20
33
|
const dir = join(root, 'trailer')
|
|
@@ -50,9 +63,17 @@ export interface Loaded {
|
|
|
50
63
|
dir: string
|
|
51
64
|
t: Trailer
|
|
52
65
|
errors: TrailerError[]
|
|
66
|
+
/** what the trailer unit's check said and never blocks */
|
|
67
|
+
warnings: TrailerError[]
|
|
53
68
|
/** the project CLI's argv, null when the repo has none */
|
|
54
69
|
cli: string[] | null
|
|
55
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
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
/** a file a line names: beside the .trailer first, then from the repo root */
|
|
@@ -62,9 +83,15 @@ export function fileOf(path: string, l: Pick<Loaded, 'dir' | 'root'>): string |
|
|
|
62
83
|
return null
|
|
63
84
|
}
|
|
64
85
|
|
|
65
|
-
/** the file parsed and checked against the repo: every error at its line, all at once
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|
|
68
95
|
const cli = projectCli(root)
|
|
69
96
|
let verbs: FilmVerb[] | null = null
|
|
70
97
|
const errors: TrailerError[] = []
|
|
@@ -80,12 +107,87 @@ export function load(file: string, root: string): Loaded {
|
|
|
80
107
|
}
|
|
81
108
|
}
|
|
82
109
|
const t = parsed.trailer
|
|
83
|
-
const
|
|
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))
|
|
84
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
|
+
}
|
|
85
123
|
l.errors.sort((a, b) => a.line - b.line)
|
|
86
124
|
return l
|
|
87
125
|
}
|
|
88
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
|
+
|
|
89
191
|
/** the clips, images and sound files the lines name, each found where it should be */
|
|
90
192
|
function checkFiles(l: Loaded): TrailerError[] {
|
|
91
193
|
const out: TrailerError[] = []
|
|
@@ -114,8 +216,10 @@ export function soundIds(t: Trailer): { line: number; kind: 'music' | 'sfx' | 'v
|
|
|
114
216
|
]
|
|
115
217
|
}
|
|
116
218
|
|
|
219
|
+
/** the errors, which stop the build, then the warnings, which do not */
|
|
117
220
|
export function sayErrors(l: Loaded, say: Say, cwd: string): boolean {
|
|
118
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}`)
|
|
119
223
|
return l.errors.length === 0
|
|
120
224
|
}
|
|
121
225
|
|
|
@@ -130,30 +234,56 @@ const filmText = (f: Film | null): string => {
|
|
|
130
234
|
if (f.kind === 'run') return `run "${f.cmd}"${f.still ? ' still' : ''}`
|
|
131
235
|
if (f.kind === 'cli') return f.argv.join(' ')
|
|
132
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)}` : ''}`
|
|
133
239
|
return 'card'
|
|
134
240
|
}
|
|
135
241
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
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[] {
|
|
138
254
|
const st = starts(l.t)
|
|
139
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`]
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
?
|
|
148
|
-
:
|
|
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) : ''
|
|
149
273
|
const sec = s.section ? ` [section ${s.section.name} at ${+s.section.at.toFixed(3)} s]` : ''
|
|
150
274
|
const fit = s.written !== undefined ? ` (fitted, written ${s.written} s)` : ''
|
|
151
|
-
out.push(` ${
|
|
152
|
-
}
|
|
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` : ''}`)
|
|
153
281
|
return out
|
|
154
282
|
}
|
|
155
283
|
|
|
156
|
-
|
|
284
|
+
function secs(x: number): string {
|
|
285
|
+
return `${+x.toFixed(2)} s`
|
|
286
|
+
}
|
|
157
287
|
const clockText = (x: number): string => `${Math.floor(x / 60)}:${(x % 60).toFixed(2).padStart(5, '0')}`
|
|
158
288
|
const cell = (s: string): string => s.replaceAll('|', '\\|').replaceAll('\n', ' ')
|
|
159
289
|
|
|
@@ -168,12 +298,12 @@ export function montage(t: Trailer, file: string): string {
|
|
|
168
298
|
'',
|
|
169
299
|
`Written from \`${file}\` by every \`sk trailer\` build: edit the .trailer, never this file.`,
|
|
170
300
|
'',
|
|
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)}` : ''}.`,
|
|
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)}` : ''}.`,
|
|
172
302
|
'',
|
|
173
303
|
'| # | at | shot | length | film | on screen | heard | cut out |',
|
|
174
304
|
'|---|---|---|---|---|---|---|---|',
|
|
175
305
|
...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)}`} |`,
|
|
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)}`} |`,
|
|
177
307
|
),
|
|
178
308
|
'',
|
|
179
309
|
].join('\n')
|
|
@@ -186,16 +316,23 @@ export interface BuildOptions {
|
|
|
186
316
|
cwd: string
|
|
187
317
|
}
|
|
188
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
|
+
|
|
189
326
|
/** 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 =
|
|
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)
|
|
193
330
|
const ask = takesToFilm(takes, world, refilm, only)
|
|
194
331
|
const failed: string[] = []
|
|
195
332
|
const lane = async (): Promise<void> => {
|
|
196
333
|
for (let group = ask.shift(); group; group = ask.shift()) {
|
|
197
334
|
try {
|
|
198
|
-
const got = await filmTake(group[0]!, { root: l.root, t: l.t, world, cli: l.cli, say })
|
|
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 })
|
|
199
336
|
for (const tk of group) {
|
|
200
337
|
const i = takes.indexOf(tk)
|
|
201
338
|
takes[i] = { ...tk, stamp: got.stamp }
|
|
@@ -210,8 +347,28 @@ async function filmAll(l: Loaded, world: string, refilm: Refilm, say: Say, only?
|
|
|
210
347
|
return takes
|
|
211
348
|
}
|
|
212
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
|
+
|
|
213
369
|
/** 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) {
|
|
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')
|
|
215
372
|
const [{ cardPng, placeOf, titlePng }, { lookOf }, { duration }, { trailerCache }, { loadBrand, identityLogo }] = await Promise.all([
|
|
216
373
|
import('./titles.ts'),
|
|
217
374
|
import('./look.ts'),
|
|
@@ -224,18 +381,30 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
|
|
|
224
381
|
const look = lookOf(brand, await identityLogo(l.root))
|
|
225
382
|
const base = { look, brand }
|
|
226
383
|
const titles = trailerCache(l.root, 'titles')
|
|
227
|
-
const
|
|
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> => {
|
|
228
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
|
+
}
|
|
229
398
|
if (f.kind === 'card') return { image: cardPng(titles, l.t.size, look) }
|
|
230
399
|
if (f.kind === 'image') return { image: fileOf(f.path, l)! }
|
|
231
400
|
if (f.kind === 'clip') {
|
|
232
401
|
const file = fileOf(f.path, l)!
|
|
233
402
|
return { footage: file, seconds: duration(file) }
|
|
234
403
|
}
|
|
235
|
-
const tk =
|
|
404
|
+
const tk = takeOf(s)!
|
|
236
405
|
return tk.stamp!.seconds === 0 ? { image: tk.file } : { footage: tk.file, seconds: tk.stamp!.seconds }
|
|
237
|
-
})
|
|
238
|
-
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)))))
|
|
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)))))
|
|
239
408
|
if (silent) return { ...base, sources, titles: drawn, music: [], sfx: shots.map(() => []), voices: shots.map(() => []) }
|
|
240
409
|
const ids = soundIds(l.t)
|
|
241
410
|
const unit = ids.length > 0 ? await (await import('./load-unit.ts')).loadTrailerUnit(l.root) : null
|
|
@@ -244,7 +413,11 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
|
|
|
244
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 }))`)
|
|
245
414
|
return f(id)
|
|
246
415
|
}
|
|
247
|
-
const music = await Promise.all(
|
|
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
|
+
)
|
|
248
421
|
const sfx = await Promise.all(shots.map((s) => Promise.all(s.sfx.map(async (x) => {
|
|
249
422
|
const file = isFileWord(x.id) ? fileOf(x.id, l)! : await resolveId('sfx', x.id, x.line)
|
|
250
423
|
return { file, seconds: duration(file) }
|
|
@@ -259,14 +432,17 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
|
|
|
259
432
|
/** a vertical cut finished: what the brand's vertical look draws over the stitched cut, the outro, the end card. The
|
|
260
433
|
* finish is cached on the pieces and the mix it reads, so a build that changed nothing encodes nothing */
|
|
261
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 }> {
|
|
262
|
-
const [{ finishArgs, finishSeconds, verticalOf }, { drawEnd, drawMark, drawWear }, { cachedAs }, { duration, hasAudio }, { trailerCache }] = await Promise.all([
|
|
435
|
+
const [{ finishArgs, finishSeconds, verticalOf }, { drawEnd, drawMark, drawWear }, { cachedAs }, { duration, hasAudio }, { trailerCache }, { shortLook }] = await Promise.all([
|
|
263
436
|
import('./vertical.ts'),
|
|
264
437
|
import('./vertical-draw.ts'),
|
|
265
438
|
import('./stitch.ts'),
|
|
266
439
|
import('./ffmpeg.ts'),
|
|
267
440
|
import('./footage.ts'),
|
|
441
|
+
import('./look.ts'),
|
|
268
442
|
])
|
|
269
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) }
|
|
270
446
|
const vl = verticalOf(input.brand, input.look, basename(l.root), v.mark)
|
|
271
447
|
const dir = trailerCache(l.root, 'titles')
|
|
272
448
|
const clip = typeof v.outro === 'object' ? fileOf(v.outro.clip, l)! : null
|
|
@@ -301,16 +477,42 @@ function outDir(l: Loaded): string {
|
|
|
301
477
|
return dir
|
|
302
478
|
}
|
|
303
479
|
|
|
304
|
-
/**
|
|
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 */
|
|
305
506
|
export async function build(l: Loaded, o: BuildOptions): Promise<string> {
|
|
306
507
|
const t0 = performance.now()
|
|
307
|
-
const [{
|
|
508
|
+
const [{ trailerCache }, { stitch, strip, rendered }, { ffmpegMissing }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts')])
|
|
308
509
|
const missing = ffmpegMissing()
|
|
309
510
|
if (missing) throw new Refused(missing)
|
|
310
|
-
const world =
|
|
511
|
+
const world = await worldOf(l)
|
|
311
512
|
const takes = await filmAll(l, world, o.refilm, o.say)
|
|
513
|
+
shelve(l, takes)
|
|
312
514
|
const tFilm = performance.now()
|
|
313
|
-
const input = await stitchInput(l, takes, l.t.shots, false)
|
|
515
|
+
const input = await stitchInput(l, takes, l.t.shots, false, o.say)
|
|
314
516
|
const out = join(outDir(l), `${l.t.name}.mp4`)
|
|
315
517
|
// a vertical cut stitches into the cache, and its finish writes the mp4
|
|
316
518
|
const cut = l.t.vertical ? join(trailerCache(l.root, 'pieces'), `${l.t.name}-cut.mp4`) : out
|
|
@@ -322,23 +524,80 @@ export async function build(l: Loaded, o: BuildOptions): Promise<string> {
|
|
|
322
524
|
for (const line of timeline(l, takes, world, o.cwd)) o.say(line)
|
|
323
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}` : ''}`)
|
|
324
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)
|
|
325
537
|
return out
|
|
326
538
|
}
|
|
327
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
|
+
|
|
328
586
|
/** one shot alone with its titles, no sound: out/shot-<name>.mp4 and its nine frames on out/shot-<name>.png */
|
|
329
587
|
export async function previewShot(l: Loaded, name: string, o: BuildOptions): Promise<void> {
|
|
330
588
|
const t0 = performance.now()
|
|
331
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(', ')}`)
|
|
332
|
-
const [{
|
|
590
|
+
const [{ trailerCache }, { stitch }, { sheetOf, ffmpegMissing }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts')])
|
|
333
591
|
const missing = ffmpegMissing()
|
|
334
592
|
if (missing) throw new Refused(missing)
|
|
335
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 }] }
|
|
336
|
-
const takes = await filmAll(l,
|
|
594
|
+
const takes = await filmAll(l, await worldOf(l), o.refilm, o.say, withCells(l, [name]))
|
|
337
595
|
const input = await stitchInput(l, takes, one.shots, true)
|
|
338
596
|
const out = join(outDir(l), `shot-${name}.mp4`)
|
|
339
597
|
await stitch({ ...input, t: one, out, cache: trailerCache(l.root, 'pieces'), silent: true })
|
|
340
598
|
const sheet = out.replace(/\.mp4$/, '.png')
|
|
341
599
|
await sheetOf(out, s.length, sheet)
|
|
600
|
+
await pruneAfter(l.root, o.say)
|
|
342
601
|
o.say(`ok: ${out} (${s.length} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
|
|
343
602
|
o.say(`ok: ${sheet}`)
|
|
344
603
|
}
|
package/src/trailer/ffmpeg.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// the one way the trailer spawns ffmpeg and ffprobe: a run that fails loud with ffmpeg's own words, a probe, the nine
|
|
2
2
|
// frame sheet. The binaries resolve once, with the homebrew path for processes started without it
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
5
|
import { basename } from 'node:path'
|
|
5
6
|
|
|
6
7
|
const FFMPEG = Bun.which('ffmpeg') ?? '/opt/homebrew/bin/ffmpeg'
|
|
@@ -44,6 +45,27 @@ export function hasAudio(file: string): boolean {
|
|
|
44
45
|
return r.stdout.toString().trim() !== ''
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/** a sound's integrated loudness (LUFS) and true peak (dBTP), as ffmpeg's ebur128 measures them */
|
|
49
|
+
export async function loudness(file: string): Promise<{ lufs: number; truePeak: number }> {
|
|
50
|
+
const log = await ffmpeg(['-nostats', '-loglevel', 'info', '-i', file, '-map', '0:a', '-af', 'ebur128=peak=true', '-f', 'null', '-'])
|
|
51
|
+
const summary = log.split('Summary:').at(-1) ?? ''
|
|
52
|
+
// silence measures -inf: floored, so the value stays a number in json
|
|
53
|
+
const num = (v: string | undefined): number => (v === '-inf' ? -120 : Number(v ?? Number.NaN))
|
|
54
|
+
const lufs = num(/I:\s+(-?[\d.]+|-inf) LUFS/.exec(summary)?.[1])
|
|
55
|
+
const truePeak = num(/Peak:\s+(-?[\d.]+|-inf) dBFS/.exec(summary)?.[1])
|
|
56
|
+
if (Number.isNaN(lufs) || Number.isNaN(truePeak)) throw new Error(`ebur128 ${basename(file)}: no loudness summary in ffmpeg's log`)
|
|
57
|
+
return { lufs, truePeak }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** loudness() of a file that never changes under its name (a cached render), kept beside it */
|
|
61
|
+
export async function loudnessKept(file: string): Promise<{ lufs: number; truePeak: number }> {
|
|
62
|
+
const json = `${file}.loudness.json`
|
|
63
|
+
if (existsSync(json)) return JSON.parse(readFileSync(json, 'utf8')) as { lufs: number; truePeak: number }
|
|
64
|
+
const m = await loudness(file)
|
|
65
|
+
writeFileSync(json, JSON.stringify(m))
|
|
66
|
+
return m
|
|
67
|
+
}
|
|
68
|
+
|
|
47
69
|
/** nine frames of a clip evenly spaced, three by three on one png: how a shot is judged */
|
|
48
70
|
export async function sheetOf(clip: string, seconds: number, png: string): Promise<void> {
|
|
49
71
|
await ffmpeg(['-i', clip, '-vf', `fps=${(9 / seconds).toFixed(4)},scale=480:-2,tile=3x3`, '-frames:v', '1', png])
|