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.
Files changed (107) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -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/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -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 { type FilmVerb, type Film, parseTrailer, type Shot, starts, total, type Trailer, type TrailerError, musicCues, NO_MUSIC } from './grammar.ts'
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
- export function load(file: string, root: string): Loaded {
67
- const text = readFileSync(file, 'utf8')
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 l: Loaded = { root, file, dir: dirname(file), t, errors: [...errors, ...parsed.errors], cli, verbs }
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
- /** the timeline, one line a shot, each take's state against the world */
137
- export function timeline(l: Loaded, takes: readonly Take[], world: string | null, cwd: string): string[] {
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
- l.t.shots.forEach((s, i) => {
141
- const tk = takes.find((x) => x.shot === s)
142
- const state = !isTake(s.film)
143
- ? (s.film?.kind === 'card' ? 'drawn' : `a file`)
144
- : !tk?.stamp
145
- ? 'NOT FILMED'
146
- : world === null || fresh(tk.stamp, world)
147
- ? 'filmed'
148
- : `filmed on an older world, ${ago(tk.stamp.filmedAt)} (--refilm ${s.name})`
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(` ${String(i + 1).padStart(2)} ${st[i]!.toFixed(2).padStart(6)} s ${s.name.padEnd(20)} ${s.length.toFixed(2).padStart(5)} s${fit} ${state}${sec}`)
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
- const secs = (x: number): string => `${+x.toFixed(2)} s`
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: string, refilm: Refilm, say: Say, only?: ReadonlySet<string>): Promise<Take[]> {
191
- const { filmTake, takesOf } = await import('./footage.ts')
192
- const takes = takesOf(l.t, l.root)
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 sources = shots.map((s) => {
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 = takes.find((x) => x.shot.name === s.name)!
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(musicCues(l.t).map(({ cue, at, end }) => (isFileWord(cue.song) ? fileOf(cue.song, l)! : resolveId('music', cue.song, cue.line, cue.from + end - at, cue.take))))
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
- /** the whole cut: out/<cut>.mp4, its strip and montage page; the timeline and what was rendered, said */
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 [{ worldStamp, trailerCache }, { stitch, strip, rendered }, { ffmpegMissing }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts')])
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 = worldStamp(l.root)
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 [{ worldStamp, trailerCache }, { stitch }, { sheetOf, ffmpegMissing }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts')])
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, worldStamp(l.root), o.refilm, o.say, new Set([name]))
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
  }
@@ -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])