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.
Files changed (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. 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
+ }