rvlib-shipkit 2.3.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 (80) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. package/src/wiki/graph-pages.ts +128 -0
@@ -0,0 +1,53 @@
1
+ // a clip filmed frame by frame (wiki build/trailer.md, film page): before each frame the page is asked to draw the
2
+ // frame's time, `window[step](t)`, then the shot service waits for its ready mark and two painted frames, takes one
3
+ // screenshot, and ffmpeg encodes the frames in order. The page's own speed never shows: a frame that takes a second to
4
+ // draw sits beside one that took a millisecond. The page owns its clock while it is stepped
5
+ import { mkdirSync } from 'fs'
6
+ import { dirname } from 'path'
7
+ import { domEncodeArgs } from './dom-clip.ts'
8
+ import { type ClipAsk, readMark, waitReady } from './shot-ask.ts'
9
+
10
+ type Cdp = { send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> }
11
+ type Page = { evaluate<R, A>(fn: (a: A) => R | Promise<R>, arg: A): Promise<R>; waitForTimeout(ms: number): Promise<void> }
12
+
13
+ /** the frames a stepped clip holds, and each one's time */
14
+ export const stepTimes = (seconds: number, fps: number): number[] => Array.from({ length: Math.max(1, Math.round(seconds * fps)) }, (_, i) => +(i / fps).toFixed(6))
15
+
16
+ /** IN THE PAGE: the hook asked to draw time t; false when the page has no such hook */
17
+ export const stepTo = async ([hook, t]: [string, number]): Promise<boolean> => {
18
+ const f = (window as unknown as Record<string, unknown>)[hook]
19
+ if (typeof f !== 'function') return false
20
+ await (f as (t: number) => unknown)(t)
21
+ await new Promise<void>((ok) => requestAnimationFrame(() => requestAnimationFrame(() => ok())))
22
+ return true
23
+ }
24
+
25
+ export async function stepPage(cdp: Cdp, page: Page, a: ClipAsk & { step: string }): Promise<string> {
26
+ const ffmpeg = Bun.which('ffmpeg') ?? (process.platform === 'darwin' ? '/opt/homebrew/bin/ffmpeg' : null)
27
+ if (ffmpeg == null) throw new Error('ffmpeg is not installed: brew install ffmpeg')
28
+ mkdirSync(dirname(a.out), { recursive: true })
29
+ const times = stepTimes(a.seconds, a.fps)
30
+ const p = Bun.spawn([ffmpeg, ...domEncodeArgs(a.fps, times.length, a.out)], { stdin: 'pipe', stdout: 'ignore', stderr: 'pipe' })
31
+ const stderr = new Response(p.stderr).text()
32
+ const t0 = performance.now()
33
+ let px = ''
34
+ try {
35
+ for (const t of times) {
36
+ if (!(await page.evaluate(stepTo, [a.step, t] as [string, number]))) throw new Error(`the page has no window.${a.step}(t): a stepped page draws the time it is given there`)
37
+ await waitReady(() => page.evaluate(readMark, undefined), 0, { now: () => performance.now(), sleep: (n) => page.waitForTimeout(n) })
38
+ const shot = await cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 92 })
39
+ const jpeg = Buffer.from(String(shot['data']), 'base64')
40
+ // the frame's pixels, from the jpeg's start of frame
41
+ const sof = jpeg.indexOf(Buffer.from([0xff, 0xc0]))
42
+ if (px === '' && sof >= 0) px = `${jpeg.readUInt16BE(sof + 7)}x${jpeg.readUInt16BE(sof + 5)}`
43
+ p.stdin.write(jpeg)
44
+ await p.stdin.flush()
45
+ }
46
+ } finally {
47
+ await Promise.resolve(p.stdin.end()).catch(() => undefined)
48
+ }
49
+ const err = await stderr
50
+ if ((await p.exited) !== 0) throw new Error(`ffmpeg encode → ${a.out}: ${err.trim().split('\n').slice(-6).join('\n') || `exit ${p.exitCode}`}`)
51
+ const s = (performance.now() - t0) / 1000
52
+ return `${times.length} frames ${px} at ${a.fps} fps, each stepped through window.${a.step}(t) and its ready mark (${(s / times.length * 1000).toFixed(0)} ms a frame), H.264 by ffmpeg`
53
+ }
@@ -3,15 +3,38 @@
3
3
  // resolves the sounds that are not files, names the paths a take's world is, checks the content the language cannot
4
4
  // know, refines one film line's take, and turns on the guards that read the repo's own words. Discovered like every
5
5
  // unit file (load-unit.ts)
6
- import type { TakeLength } from './grammar.ts'
6
+ import type { SoundRun, TakeLength } from './grammar.ts'
7
+ import { type Words, wordsProblem } from './words.ts'
7
8
 
9
+ /** the repo's sounds by id; null: not the repo's, the house makes it (src/trailer/sound/, design call 9) */
8
10
  export interface TrailerSounds {
9
11
  /** a music id to an audio file of at least `seconds`, cached by the repo */
10
- music?(id: string, o: { take?: string; seconds: number }): Promise<string>
12
+ music?(id: string, o: { take?: string; seconds: number }): Promise<string | null>
11
13
  /** an sfx id to an audio file */
12
- sfx?(id: string): Promise<string>
14
+ sfx?(id: string): Promise<string | null>
13
15
  /** a voice line, found by its text, to an audio file */
14
- voice?(text: string): Promise<string>
16
+ voice?(text: string): Promise<string | null>
17
+ }
18
+
19
+ /** a sound line the house makes (`resolveSound` and `soundStates` of src/trailer/sound/index.ts, the shape it reads):
20
+ * the line read, the prompt, or a `.mid` file's path (`text`), how it is made (`seed`, `by`, `for`, `hit`), what a music
21
+ * cue covers (`seconds`), or the line's own `run` command */
22
+ export interface SoundRequest {
23
+ kind: 'music' | 'sfx' | 'voice'
24
+ text: string
25
+ seed?: number
26
+ by?: string
27
+ for?: number
28
+ hit?: boolean
29
+ seconds?: number
30
+ line: number
31
+ run?: SoundRun
32
+ /** the cut's folder: a `reads` file is found beside it first, then from the repo root */
33
+ dir: string
34
+ /** the text is a quoted prompt, never an id */
35
+ prompt?: boolean
36
+ /** music: the take of a song id */
37
+ take?: string
15
38
  }
16
39
 
17
40
  /** a film line of the project CLI as the unit sees it: its words as written, the text of each `@<file>` word */
@@ -66,6 +89,8 @@ export interface TrailerDef {
66
89
  states?: { ignore?: string[]; underWay?: string[] }
67
90
  /** a clip take that drops its picture for a few frames is filmed again once, then refused (holes.ts) */
68
91
  holes?: boolean
92
+ /** the words a `film page` of this repo takes (words.ts): each checked at its line, its query made plain */
93
+ page?: { words: Words }
69
94
  }
70
95
 
71
96
  export type TrailerUnit = TrailerDef & { kind: 'trailer' }
@@ -73,6 +98,10 @@ export type TrailerUnit = TrailerDef & { kind: 'trailer' }
73
98
  export function trailer(d: TrailerDef): TrailerUnit {
74
99
  for (const [k, f] of Object.entries(d.sound ?? {})) if (typeof f !== 'function') throw new Error(`trailer: sound.${k} is a function returning an audio file`)
75
100
  for (const k of ['check', 'take'] as const) if (d[k] !== undefined && typeof d[k] !== 'function') throw new Error(`trailer: ${k} is a function`)
101
+ if (d.page !== undefined) {
102
+ const why = wordsProblem(d.page.words) ?? Object.entries(d.page.words).flatMap(([k, w]) => (w.arg !== undefined ? [`page word ${k} declares arg: a page word has a key (it is the url's query)`] : []))[0] ?? null
103
+ if (why !== null) throw new Error(`trailer: ${why}`)
104
+ }
76
105
  if (d.stamp !== undefined && (!Array.isArray(d.stamp) || d.stamp.some((p) => typeof p !== 'string' || p === '' || p.startsWith('/')))) throw new Error('trailer: stamp is a list of repo relative paths')
77
106
  return { ...d, kind: 'trailer' }
78
107
  }
@@ -2,14 +2,16 @@
2
2
  // stitch the pieces, lay the strip, write the montage page. `sk trailer` and its verbs call these
3
3
  import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
4
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'
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
7
  import { filmLineOf, type Recipe, takesOf, trailerCache } from './footage.ts'
8
8
  import type { BrandLike, TrailerLook } from './look.ts'
9
9
  import { fresh, isTake, type Refilm, recipeWords, type Take, takeIsStill, takesToFilm, type World, worldFor } from './plan.ts'
10
10
  import { decodeStates, edgeChanges } from './states.ts'
11
11
  import { type CompiledScore, compileScore } from './score/score.ts'
12
- import { GROW_RES, type Source } from './graph.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'
13
15
  import type { Stitched } from './stitch.ts'
14
16
  import { projectCli, readVocab } from './vocab.ts'
15
17
 
@@ -85,7 +87,7 @@ export function fileOf(path: string, l: Pick<Loaded, 'dir' | 'root'>): string |
85
87
 
86
88
  /** the file parsed and checked against the repo: every error at its line, all at once; the trailer unit's check with
87
89
  * 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> {
90
+ export async function load(file: string, root: string, o: { text?: string; dir?: string; check?: boolean; fresh?: boolean; variant?: string } = {}): Promise<Loaded> {
89
91
  const text = o.text ?? readFileSync(file, 'utf8')
90
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
91
93
  if (o.fresh) {
@@ -95,21 +97,25 @@ export async function load(file: string, root: string, o: { text?: string; dir?:
95
97
  const cli = projectCli(root)
96
98
  let verbs: FilmVerb[] | null = null
97
99
  const errors: TrailerError[] = []
98
- 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 })
99
104
  // the project CLI is asked only when a film line does not read as a core film
100
105
  const lines = text.split('\n')
101
106
  if (cli !== null && parsed.errors.some((e) => /^\s*film\s/.test(lines[e.line - 1] ?? ''))) {
102
107
  try {
103
108
  verbs = readVocab(root, cli)
104
- parsed = parseTrailer(text, { verbs })
109
+ parsed = parseTrailer(text, { verbs, ...variant })
105
110
  } catch (e) {
106
111
  errors.push({ line: 1, message: (e as Error).message })
107
112
  }
108
113
  }
109
114
  const t = parsed.trailer
110
- const { loadTrailerUnit } = await import('./load-unit.ts')
111
- const l: Loaded = { root, file, dir: o.dir ?? dirname(file), t, errors: [...errors, ...parsed.errors], warnings: [], cli, verbs, score: null, recipes: new Map(), unit: await loadTrailerUnit(root) }
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 }
112
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)) ?? {}))
113
119
  if (l.errors.length === 0) l.errors.push(...checkFiles(l))
114
120
  if (l.errors.length === 0 && t.score) {
115
121
  const c = compileScore(t.score, t.sections, trailerCache(root, 'score'))
@@ -165,7 +171,7 @@ export function checkInput(l: Loaded): TrailerCheckInput {
165
171
  fps: l.t.fps,
166
172
  films: [...l.t.shots, ...l.t.footage].flatMap((s) => (s.film?.kind === 'cli' ? [filmLineOf(s, s.film, l)] : [])),
167
173
  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 }] : [],
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 }] : [],
169
175
  ),
170
176
  sfx: ids.filter((x) => x.kind === 'sfx').map((x) => ({ line: x.line, id: x.id })),
171
177
  voices: ids.filter((x) => x.kind === 'voice').map((x) => ({ line: x.line, text: x.id })),
@@ -188,6 +194,56 @@ export async function worldOf(l: Pick<Loaded, 'root' | 'unit'>): Promise<(tk: Ta
188
194
  }
189
195
  }
190
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
+
191
247
  /** the clips, images and sound files the lines name, each found where it should be */
192
248
  function checkFiles(l: Loaded): TrailerError[] {
193
249
  const out: TrailerError[] = []
@@ -196,26 +252,104 @@ function checkFiles(l: Loaded): TrailerError[] {
196
252
  }
197
253
  for (const s of l.t.shots) {
198
254
  if (s.film?.kind === 'clip' || s.film?.kind === 'image') need(s.film.line, `film ${s.film.kind}`, s.film.path)
199
- for (const x of s.sfx) if (isFileWord(x.id)) need(x.line, 'sfx', x.id)
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)
200
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)
201
263
  }
202
- for (const m of [l.t.music, ...l.t.shots.map((s) => s.music)]) if (m && m.song !== NO_MUSIC && isFileWord(m.song)) need(m.line, 'music', m.song)
203
264
  const v = l.t.vertical
204
265
  if (v && typeof v.outro === 'object') need(v.line, 'vertical outro', v.outro.clip)
205
266
  return out
206
267
  }
207
268
 
208
- /** 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) */
209
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
210
275
  return [
211
- ...[t.music, ...t.shots.map((s) => s.music)].flatMap((m) => (m && m.song !== NO_MUSIC && !isFileWord(m.song) ? [{ line: m.line, kind: 'music' as const, id: m.song }] : [])),
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 }] : [])),
212
277
  ...t.shots.flatMap((s) => [
213
- ...s.sfx.filter((x) => !isFileWord(x.id)).map((x) => ({ line: x.line, kind: 'sfx' as const, id: x.id })),
214
- ...s.voices.filter((v) => v.text !== undefined).map((v) => ({ line: v.line, kind: 'voice' as const, id: v.text! })),
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! })),
215
280
  ]),
216
281
  ]
217
282
  }
218
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
+
219
353
  /** the errors, which stop the build, then the warnings, which do not */
220
354
  export function sayErrors(l: Loaded, say: Say, cwd: string): boolean {
221
355
  for (const e of l.errors) say(`${relative(cwd, l.file)}:${e.line}: ${e.message}`)
@@ -234,6 +368,7 @@ const filmText = (f: Film | null): string => {
234
368
  if (f.kind === 'run') return `run "${f.cmd}"${f.still ? ' still' : ''}`
235
369
  if (f.kind === 'cli') return f.argv.join(' ')
236
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' : ''}`
237
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 ')}` : ''}`
238
373
  if (f.kind === 'split') return `split of ${f.cells.join(', ')}, seams at ${f.angle}°${f.slide ? `, sliding in over ${secs(f.slide)}` : ''}`
239
374
  return 'card'
@@ -369,7 +504,7 @@ export async function scoreFile(l: Loaded, seconds: number, say: Say): Promise<s
369
504
  /** everything the stitch reads but its output: sources, titles, sounds, the look */
370
505
  async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Shot[], silent: boolean, say: Say = () => undefined) {
371
506
  const { gridMask, splitMasks } = await import('./stitch.ts')
372
- const [{ cardPng, placeOf, titlePng }, { lookOf }, { duration }, { trailerCache }, { loadBrand, identityLogo }] = await Promise.all([
507
+ const [{ cardPng, titlePng }, { lookOf }, { duration }, { trailerCache }, { loadBrand, identityLogo }] = await Promise.all([
373
508
  import('./titles.ts'),
374
509
  import('./look.ts'),
375
510
  import('./ffmpeg.ts'),
@@ -404,26 +539,27 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
404
539
  const tk = takeOf(s)!
405
540
  return tk.stamp!.seconds === 0 ? { image: tk.file } : { footage: tk.file, seconds: tk.stamp!.seconds }
406
541
  }))
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)))))
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) })))))
408
546
  if (silent) return { ...base, sources, titles: drawn, music: [], sfx: shots.map(() => []), voices: shots.map(() => []) }
409
- const ids = soundIds(l.t)
410
- const unit = ids.length > 0 ? await (await import('./load-unit.ts')).loadTrailerUnit(l.root) : null
411
- const resolveId = async (kind: 'music' | 'sfx' | 'voice', id: string, line: number, seconds = 0, take?: string): Promise<string> => {
412
- const f = kind === 'music' ? unit?.sound?.music && ((x: string) => unit.sound!.music!(x, { take, seconds })) : kind === 'sfx' ? unit?.sound?.sfx : unit?.sound?.voice
413
- if (!f) throw new Refused(`${relative(l.root, l.file)}:${line}: ${kind} ${id} is no file, and no trailer unit resolves ${kind} ids (a <name>.shipkit.ts default-exporting trailer({ sound }))`)
414
- return f(id)
415
- }
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)
416
549
  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
- ),
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
+ }),
420
556
  )
421
557
  const sfx = await Promise.all(shots.map((s) => Promise.all(s.sfx.map(async (x) => {
422
- const file = isFileWord(x.id) ? fileOf(x.id, l)! : await resolveId('sfx', x.id, x.line)
558
+ const file = isFileSound(x.id, x) ? fileOf(x.id, l)! : await resolve(ask('sfx', x.id, x))
423
559
  return { file, seconds: duration(file) }
424
560
  }))))
425
561
  const voices = await Promise.all(shots.map((s) => Promise.all(s.voices.map(async (v) => {
426
- const file = v.file ? fileOf(v.file, l)! : await resolveId('voice', v.text!, v.line)
562
+ const file = v.file ? fileOf(v.file, l)! : await resolve(ask('voice', v.text ?? 'run', v))
427
563
  return { file, seconds: duration(file) }
428
564
  }))))
429
565
  return { ...base, sources, titles: drawn, music, sfx, voices }
@@ -579,8 +715,13 @@ export async function auditionScore(l: Loaded, o: BuildOptions): Promise<void> {
579
715
  `[top][sp]vstack,${marks.join(',')}`,
580
716
  ].join(';')
581
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))
582
722
  o.say(`ok: ${wav} (${span.toFixed(1)} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
583
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)`)
584
725
  }
585
726
 
586
727
  /** one shot alone with its titles, no sound: out/shot-<name>.mp4 and its nine frames on out/shot-<name>.png */
@@ -0,0 +1,55 @@
1
+ // a title's face: a font file of the repo, or a Google Fonts family fetched once per weight into the machine's cache
2
+ // (every repo shares it). Each face is drawn under a name of its own, and one that does not register fails loud: a
3
+ // title never falls back to another face in silence
4
+ import { GlobalFonts } from '@napi-rs/canvas'
5
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+
9
+ /** a `font` word that names a file; any other names a Google Fonts family */
10
+ export const isFontFile = (w: string): boolean => /\.(ttf|otf|woff2?)$/i.test(w)
11
+
12
+ export const fontCache = (): string => process.env.SHIPKIT_FONT_CACHE || join(homedir(), '.cache', 'shipkit', 'fonts')
13
+
14
+ const slug = (family: string): string => family.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
15
+
16
+ /** where a Google Fonts family at a weight is kept */
17
+ export const googleFile = (family: string, weight: number): string => join(fontCache(), `${slug(family)}-${weight}.ttf`)
18
+
19
+ /** a Google Fonts family at a weight, fetched when the cache lacks it: the css answer names a truetype file to a plain
20
+ * agent */
21
+ export async function googleFont(family: string, weight: number): Promise<string> {
22
+ const file = googleFile(family, weight)
23
+ if (existsSync(file)) return file
24
+ const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replaceAll('%20', '+')}:wght@${weight}`
25
+ const css = await fetch(url)
26
+ if (!css.ok) throw new Error(`font "${family}" weight ${weight}: Google Fonts has no such family at that weight (HTTP ${css.status}); check its name and weights on fonts.google.com, or name a font file of the repo (.ttf .otf .woff .woff2)`)
27
+ const src = /url\((https:[^)]+)\)/.exec(await css.text())?.[1]
28
+ if (!src) throw new Error(`font "${family}" weight ${weight}: Google Fonts answered with no font file (${url})`)
29
+ const got = await fetch(src)
30
+ if (!got.ok) throw new Error(`font "${family}" weight ${weight}: its file did not download (HTTP ${got.status}, ${src})`)
31
+ mkdirSync(fontCache(), { recursive: true })
32
+ const part = `${file}.${process.pid}.part`
33
+ writeFileSync(part, Buffer.from(await got.arrayBuffer()))
34
+ renameSync(part, file)
35
+ return file
36
+ }
37
+
38
+ const registered = new Map<string, string>()
39
+
40
+ /** the name a face file is drawn under, its own: two files never share a family, and a file that does not load fails */
41
+ export function registerFont(abs: string): string {
42
+ const had = registered.get(abs)
43
+ if (had) return had
44
+ if (!existsSync(abs)) throw new Error(`the font file ${abs} is missing`)
45
+ const name = `F${new Bun.CryptoHasher('sha1').update(abs).digest('hex').slice(0, 12)}`
46
+ GlobalFonts.registerFromPath(abs, name)
47
+ if (!GlobalFonts.has(name)) throw new Error(`the font file ${abs} does not load: a .ttf, .otf, .woff or .woff2 face`)
48
+ registered.set(abs, name)
49
+ return name
50
+ }
51
+
52
+ /** a family the machine draws, never a stand in: a name it lacks fails loud */
53
+ export function hasFamily(family: string): boolean {
54
+ return family.split(',').some((f) => GlobalFonts.has(f.trim().replace(/^["']|["']$/g, '')))
55
+ }
@@ -115,7 +115,7 @@ export async function filmTake(tk: Take, c: FilmContext): Promise<Take> {
115
115
  const still = takeIsStill(f)
116
116
  let said = ''
117
117
  for (let attempt = 1; ; attempt++) {
118
- said = f.kind === 'url' ? await urlTake(tk, f, part, seconds, c) : await commandTake(tk, f, part, seconds, c)
118
+ said = f.kind === 'url' || f.kind === 'page' ? await urlTake(tk, f, part, seconds, c) : await commandTake(tk, f, part, seconds, c)
119
119
  if (still || !c.holes) break
120
120
  const holes = await (await import('./holes.ts')).takeHoles(part)
121
121
  if (holes.length === 0) break
@@ -143,7 +143,7 @@ export async function filmTake(tk: Take, c: FilmContext): Promise<Take> {
143
143
  }
144
144
 
145
145
  /** a take a command writes at `part` (a `film run`, a project CLI row); what it said, or a throw with its last lines */
146
- async function commandTake(tk: Take, f: Exclude<TakeFilm, { kind: 'url' }>, part: string, seconds: number | null, c: FilmContext): Promise<string> {
146
+ async function commandTake(tk: Take, f: Exclude<TakeFilm, { kind: 'url' | 'page' }>, part: string, seconds: number | null, c: FilmContext): Promise<string> {
147
147
  if (f.kind === 'cli' && c.cli === null) throw new Error(`shot ${tk.shot.name} (line ${f.line}): film ${f.argv.join(' ')} needs the project CLI (the cli script of package.json)`)
148
148
  const argv = f.kind === 'run' ? ['sh', '-c', runCommand(f, { out: part, seconds, size: c.t.size, fps: c.t.fps })] : cliArgv(c.cli!, f, part, seconds, tk.argv)
149
149
  // the cut's frame and rate: a row that films at the cut's size reads them
@@ -160,7 +160,7 @@ async function commandTake(tk: Take, f: Exclude<TakeFilm, { kind: 'url' }>, part
160
160
  }
161
161
 
162
162
  /** a `film url` take through the house shot service; a throw names the shot, its line and the service's reason */
163
- async function urlTake(tk: Take, f: Extract<TakeFilm, { kind: 'url' }>, part: string, seconds: number | null, c: FilmContext): Promise<string> {
163
+ async function urlTake(tk: Take, f: Extract<TakeFilm, { kind: 'url' | 'page' }>, part: string, seconds: number | null, c: FilmContext): Promise<string> {
164
164
  const { filmUrl } = await import('./url.ts')
165
165
  try {
166
166
  const note = await filmUrl(f, { root: c.root, base: c.t.base, size: c.t.size, fps: c.t.fps, seconds, out: part, say: c.say })
@@ -168,6 +168,6 @@ async function urlTake(tk: Take, f: Extract<TakeFilm, { kind: 'url' }>, part: st
168
168
  return note
169
169
  } catch (e) {
170
170
  rmSync(part, { force: true })
171
- throw new Error(`shot ${tk.shot.name} (line ${f.line}): film url ${f.route}: ${(e as Error).message}`)
171
+ throw new Error(`shot ${tk.shot.name} (line ${f.line}): film ${f.kind} ${f.route}: ${(e as Error).message}`)
172
172
  }
173
173
  }