rvlib-shipkit 2.2.0 → 2.4.0

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