rvlib-shipkit 2.3.0 → 2.5.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 (104) hide show
  1. package/CHANGELOG.md +19 -2
  2. package/package.json +2 -2
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +30 -5
  5. package/src/cli/cmd_FleetPush.ts +47 -0
  6. package/src/cli/cmd_GlobalProcesses.ts +5 -5
  7. package/src/cli/cmd_Review.ts +52 -11
  8. package/src/cli/cmd_Sound.ts +209 -0
  9. package/src/cli/cmd_Trailer.ts +11 -3
  10. package/src/cli/family-pages.ts +49 -0
  11. package/src/cli/kit/builtins.ts +1 -0
  12. package/src/cli/kit/verb.ts +11 -3
  13. package/src/cli/registry.ts +137 -0
  14. package/src/cli.ts +47 -133
  15. package/src/core/app-icon.ts +10 -6
  16. package/src/core/last-good.ts +217 -0
  17. package/src/core/load-probe.ts +71 -0
  18. package/src/core/reexec.ts +5 -3
  19. package/src/fleet/push-fleet.ts +80 -0
  20. package/src/home/home-routes.ts +12 -2
  21. package/src/home/lib-watch.ts +110 -20
  22. package/src/home/process-rows.ts +4 -2
  23. package/src/home/run-daemon.ts +1 -1
  24. package/src/repo-config.ts +3 -0
  25. package/src/review/format.ts +11 -3
  26. package/src/review/model.ts +66 -5
  27. package/src/review/paste.ts +30 -0
  28. package/src/review/project.ts +20 -4
  29. package/src/review/routes.ts +22 -4
  30. package/src/review/store.ts +15 -6
  31. package/src/review/unit.ts +19 -1
  32. package/src/scaffold/files/agent-index.md +2 -2
  33. package/src/scaffold/project-scaffold.ts +2 -2
  34. package/src/serve/hub-app/activity-st.ts +31 -0
  35. package/src/serve/hub-app/activity-view.tsx +9 -0
  36. package/src/serve/hub-app/activity.css +6 -0
  37. package/src/serve/hub-app/admin-svc.ts +1 -1
  38. package/src/serve/hub-app/admin-view.tsx +1 -1
  39. package/src/serve/hub-app/hash-route.ts +12 -4
  40. package/src/serve/hub-app/home-st.ts +7 -1
  41. package/src/serve/hub-app/home-strip.tsx +3 -1
  42. package/src/serve/hub-app/hub.css +38 -1
  43. package/src/serve/hub-app/icons.tsx +6 -0
  44. package/src/serve/hub-app/layout-st.ts +2 -2
  45. package/src/serve/hub-app/machine-st.ts +1 -1
  46. package/src/serve/hub-app/md.ts +32 -0
  47. package/src/serve/hub-app/project-list.css +9 -7
  48. package/src/serve/hub-app/project-list.tsx +5 -2
  49. package/src/serve/hub-app/projects-svc.ts +3 -2
  50. package/src/serve/hub-app/repo-st.ts +22 -7
  51. package/src/serve/hub-app/review/clip-st.ts +57 -7
  52. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  53. package/src/serve/hub-app/review/review-answer.tsx +67 -58
  54. package/src/serve/hub-app/review/review-page.tsx +17 -15
  55. package/src/serve/hub-app/review/review-st.ts +191 -20
  56. package/src/serve/hub-app/review/review-views.tsx +154 -9
  57. package/src/serve/hub-app/review.css +73 -13
  58. package/src/serve/hub-app/wiki-st.ts +190 -0
  59. package/src/serve/hub-app/wikis-view.tsx +134 -5
  60. package/src/serve/hub-runtime.ts +4 -4
  61. package/src/serve/project-gutter-route.ts +25 -1
  62. package/src/serve/project-gutter.ts +1 -1
  63. package/src/serve/run-serve.ts +25 -0
  64. package/src/serve/serve-hub.ts +15 -1
  65. package/src/serve/stale-restart.ts +19 -2
  66. package/src/shot/client.ts +17 -5
  67. package/src/shot/shooter.ts +19 -3
  68. package/src/shot/shot-ask.ts +9 -1
  69. package/src/shot/stepped-clip.ts +53 -0
  70. package/src/trailer/adapter.ts +33 -4
  71. package/src/trailer/build.ts +182 -33
  72. package/src/trailer/ffmpeg.ts +21 -1
  73. package/src/trailer/fonts.ts +55 -0
  74. package/src/trailer/footage.ts +4 -4
  75. package/src/trailer/grammar.ts +429 -67
  76. package/src/trailer/graph.ts +130 -56
  77. package/src/trailer/look.ts +3 -0
  78. package/src/trailer/motion.ts +207 -0
  79. package/src/trailer/plan.ts +2 -2
  80. package/src/trailer/prune.ts +13 -2
  81. package/src/trailer/score/compose.ts +18 -2
  82. package/src/trailer/score/gm-render.ts +43 -0
  83. package/src/trailer/score/gm.ts +90 -0
  84. package/src/trailer/score/midi.ts +143 -0
  85. package/src/trailer/score/pattern.ts +13 -6
  86. package/src/trailer/score/score.ts +28 -14
  87. package/src/trailer/score/sound.ts +12 -1
  88. package/src/trailer/score/synth.ts +24 -2
  89. package/src/trailer/sound/audio.ts +148 -0
  90. package/src/trailer/sound/brand.ts +42 -0
  91. package/src/trailer/sound/comfy.ts +38 -0
  92. package/src/trailer/sound/eleven.ts +72 -0
  93. package/src/trailer/sound/fluid.ts +71 -0
  94. package/src/trailer/sound/gen.ts +139 -0
  95. package/src/trailer/sound/index.ts +60 -0
  96. package/src/trailer/sound/presets.ts +69 -0
  97. package/src/trailer/sound/run.ts +189 -0
  98. package/src/trailer/titles.ts +451 -235
  99. package/src/trailer/url.ts +18 -3
  100. package/src/trailer/vocab.ts +12 -1
  101. package/src/trailer/words.ts +29 -8
  102. package/src/wiki/graph-mount.ts +3 -0
  103. package/src/wiki/graph-page.ts +45 -9
  104. package/src/wiki/graph-pages.ts +128 -0
@@ -7,6 +7,7 @@
7
7
  import { existsSync } from 'fs'
8
8
  import { join } from 'path'
9
9
  import { positionsFile } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
10
+ import { pageOf, searchWiki } from 'rvlib-shipkit/src/wiki/graph-pages.ts'
10
11
  import { graphRoutes } from 'rvlib-shipkit/src/wiki/serve-graph.ts'
11
12
  import type { StudioRoutes } from '../assets/server/studio-server.ts'
12
13
  import { comfyBoxUp, type ComfyBox, type ComfyPanelUp } from '../comfy/comfy-panel.ts'
@@ -30,6 +31,7 @@ import { RUN_EXIT } from '../home/home-words.ts'
30
31
  import { projectGutter } from './project-gutter-route.ts'
31
32
  import { sameOrigin } from './same-origin.ts'
32
33
  import { LAYOUT } from '../core/layout.ts'
34
+ import { heldNow, heldText } from '../core/last-good.ts'
33
35
  import { WarmCell } from './warm-cell.ts'
34
36
  import { readmeRoutes } from './readme-route.ts'
35
37
  import { reviewRoutes } from '../review/routes.ts'
@@ -245,7 +247,7 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
245
247
  collections: r.collections,
246
248
  resources: r.resources,
247
249
  planned: d.planned?.() ?? null,
248
- hub: d.hubProblems?.list() ?? [],
250
+ hub: [...(d.hubProblems?.list() ?? []), ...heldProblem()],
249
251
  })
250
252
  }
251
253
  // the previous address, answered while pages opened before the
@@ -411,6 +413,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
411
413
  if (path === `/graph/${g.id}`) return page(routes.page())
412
414
  if (path === `/graph/${g.id}/graph.json`) return Response.json(held)
413
415
  if (path === `/graph/${g.id}/positions`) return routes.save(req)
416
+ // the wikis page's search bar and its preview panel: only a node of the graph is read
417
+ if (path === `/graph/${g.id}/search`) return g.dir == null ? new Response('this wiki names no folder to read', { status: 404 }) : Response.json(searchWiki(held, g.dir, new URL(req.url).searchParams.get('q') ?? ''))
418
+ if (path === `/graph/${g.id}/page`) {
419
+ const p = g.dir == null ? null : pageOf(held, g.dir, new URL(req.url).searchParams.get('node') ?? '')
420
+ return p == null ? new Response('not a page of this wiki', { status: 404 }) : Response.json(p)
421
+ }
414
422
  }
415
423
  // the media API namespace — the studio route table, ONE prefix strip
416
424
  if (d.studio != null && path.startsWith('/media/')) return d.studio.handle(req, path.slice('/media'.length))
@@ -420,6 +428,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
420
428
  }
421
429
  }
422
430
 
431
+ /** the machine runs older code than its tree: every hub's header says so */
432
+ const heldProblem = (): { id: string; text: string; severity: 'yellow' }[] => {
433
+ const h = heldNow()
434
+ return h == null ? [] : [{ id: 'last-good', text: heldText(h), severity: 'yellow' }]
435
+ }
436
+
423
437
  export function serveHub(d: HubDeps, h: HubHandler = hubHandler(d)): Hub {
424
438
  const server: Server = Bun.serve({
425
439
  hostname: '127.0.0.1',
@@ -7,10 +7,14 @@
7
7
  // watch fds, and a full table fails every spawn with EBADF. Either way it
8
8
  // re-executes in place (core/reexec.ts) once no run is in flight, which is a
9
9
  // clean table too. Only a restart that throws reaches a human, once, through
10
- // `say`.
10
+ // `say`. With `loads`, it restarts only onto code that loads: while the tree
11
+ // fails, it keeps the running image and tells `hold` why.
11
12
  import { assertLeakHealable, FD_HEALED_ENV, fdTableLeak } from '../core/fd-table.ts'
12
13
 
13
- export type StaleTick = 'fresh' | 'busy' | 'restarted' | 'impossible'
14
+ export type StaleTick = 'fresh' | 'busy' | 'probing' | 'held' | 'restarted' | 'impossible'
15
+
16
+ /** does the code a restart would run load: `pending` while a probe runs */
17
+ export type LoadVerdict = 'ok' | 'pending' | { file: string; error: string }
14
18
 
15
19
  export type StaleRestartOpts = {
16
20
  /** the version this process booted with */
@@ -30,6 +34,10 @@ export type StaleRestartOpts = {
30
34
  /** the hub's status line: a text when the restart is impossible, null
31
35
  * once there is nothing to say */
32
36
  say: (text: string | null) => void
37
+ /** asked once a restart is due, never before (core/load-probe.ts) */
38
+ loads?: () => LoadVerdict
39
+ /** why the due restart waits: a file of the tree fails to load. Null once it loads */
40
+ hold?: (why: { file: string; error: string } | null) => void
33
41
  everyMs?: number
34
42
  }
35
43
 
@@ -47,6 +55,15 @@ export class StaleRestart {
47
55
  }
48
56
  if (this.failed != null) return 'impossible'
49
57
  if (this.o.busy()) return 'busy'
58
+ if (this.o.loads != null) {
59
+ const v = this.o.loads()
60
+ if (v === 'pending') return 'probing'
61
+ if (v !== 'ok') {
62
+ this.o.hold?.(v)
63
+ return 'held'
64
+ }
65
+ this.o.hold?.(null)
66
+ }
50
67
  try {
51
68
  if (stale.leak) {
52
69
  assertLeakHealable()
@@ -8,7 +8,7 @@ import type { ShotDone } from './shooter.ts'
8
8
  /** the global process id pm knows the service by (shot.shipkit.ts) */
9
9
  export const SHOT_PROCESS = 'shot'
10
10
 
11
- export type ShotInput = { url: string; out: string; viewport?: Viewport; dpr?: number; reload?: boolean; waitMs?: number }
11
+ export type ShotInput = { url: string; out: string; viewport?: Viewport; dpr?: number; reload?: boolean; waitMs?: number; step?: string; at?: number }
12
12
  export type ClipInput = ShotInput & { seconds: number; fps?: number; dom?: boolean }
13
13
  export type ShotResult = ShotDone & { via: 'warm' | 'started' | 'local' }
14
14
 
@@ -21,13 +21,25 @@ const shotAsk = (i: ShotInput): ShotAsk => ({
21
21
  waitMs: i.waitMs ?? DEFAULT_WAIT_MS,
22
22
  })
23
23
 
24
- /** a png of `url` at `out` (absolute), through the warm browser */
25
- export const shotUrl = (i: ShotInput, log: (l: string) => void = () => {}): Promise<ShotResult> => ask('/shot', shotAsk(i), log)
24
+ /** a service of an older shipkit takes a stepped ask, ignores the step and shoots the page as it loads: never kept */
25
+ function stepped(i: { step?: string }, r: ShotResult): void {
26
+ if (i.step && r.stepped !== true) throw new Error(`the shot service on ${shotUrlBase()} did not step the page (it says: ${r.note}); it runs an older shipkit: sk gp restart ${SHOT_PROCESS}`)
27
+ }
28
+
29
+ /** a png of `url` at `out` (absolute), through the warm browser; with `step`, the page drawn at `at` first */
30
+ export async function shotUrl(i: ShotInput, log: (l: string) => void = () => {}): Promise<ShotResult> {
31
+ const r = await ask('/shot', { ...shotAsk(i), ...(i.step ? { step: i.step, at: i.at ?? 0 } : {}) }, log)
32
+ stepped(i, r)
33
+ return r
34
+ }
26
35
 
27
36
  /** an mp4 of `seconds` of `url` at `out` (absolute): a page that is a canvas through its WebCodecs, any other page (or
28
37
  * `dom`) as chromium paints it */
29
- export const clipUrl = (i: ClipInput, log: (l: string) => void = () => {}): Promise<ShotResult> =>
30
- ask('/clip', { ...shotAsk(i), seconds: i.seconds, fps: i.fps ?? DEFAULT_FPS, dom: i.dom ?? false } satisfies ClipAsk, log)
38
+ export async function clipUrl(i: ClipInput, log: (l: string) => void = () => {}): Promise<ShotResult> {
39
+ const r = await ask('/clip', { ...shotAsk(i), seconds: i.seconds, fps: i.fps ?? DEFAULT_FPS, dom: i.dom ?? false, ...(i.step ? { step: i.step } : {}) } satisfies ClipAsk, log)
40
+ stepped(i, r)
41
+ return r
42
+ }
31
43
 
32
44
  /** null when nothing listens (start it), else the service's answer; a refusal throws its reason */
33
45
  async function post(route: string, a: ShotAsk): Promise<ShotDone | null> {
@@ -59,7 +59,7 @@ export const SHOT_NICE = 10
59
59
  /** a page that paints nothing once the screencast starts is filmed from one still taken after this */
60
60
  const FIRST_FRAME_MS = 1000
61
61
 
62
- export type ShotDone = { out: string; ms: number; phases: Record<string, number>; errors: string[]; note: string }
62
+ export type ShotDone = { out: string; ms: number; phases: Record<string, number>; errors: string[]; note: string; stepped?: true }
63
63
 
64
64
  type Slot = { key: string; url: string; ctx: Context; page: Page; cdp: Cdp; errors: string[]; frozen: boolean }
65
65
 
@@ -169,6 +169,8 @@ export class Shooter {
169
169
  const phases: Record<string, number> = {}
170
170
  const t0 = performance.now()
171
171
  let move = pageMove(this.#slot == null || this.#slot.page.isClosed() ? null : { url: this.#slot.url, key: this.#slot.key }, a)
172
+ // a stepped still draws its moment on a fresh load, never on a page some other shot left at another time
173
+ if (a.step && move === 'none') move = 'reload'
172
174
  if (move === 'boot') {
173
175
  const old = this.#slot
174
176
  this.#slot = null
@@ -200,13 +202,22 @@ export class Shooter {
200
202
  let t = performance.now()
201
203
  const how = await waitReady(() => slot.page.evaluate(readMark), move === 'none' ? 0 : a.waitMs, { now: () => performance.now(), sleep: (n) => slot.page.waitForTimeout(n) })
202
204
  phases['ready'] = ms(t)
205
+ if (a.step) {
206
+ t = performance.now()
207
+ const { stepTo } = await import('./stepped-clip.ts')
208
+ if (!(await slot.page.evaluate(stepTo, [a.step, a.at ?? 0] as [string, number]))) throw new Error(`the page has no window.${a.step}(t): a stepped page draws the time it is given there`)
209
+ await waitReady(() => slot.page.evaluate(readMark), 0, { now: () => performance.now(), sleep: (n) => slot.page.waitForTimeout(n) })
210
+ // the warm page now shows that moment: the next plain shot of this url loads it again
211
+ slot.url = ''
212
+ phases['step'] = ms(t)
213
+ }
203
214
  t = performance.now()
204
215
  const r = await slot.cdp.send('Page.captureScreenshot', { format: 'png', optimizeForSpeed: true })
205
216
  phases['capture'] = ms(t)
206
217
  mkdirSync(dirname(a.out), { recursive: true })
207
218
  await Bun.write(a.out, Buffer.from(String(r['data']), 'base64'))
208
- const note = `${move === 'none' ? 'warm page' : thawed ? 'frozen page reloaded' : move}, ${how === 'ready' ? 'ready mark' : move === 'none' ? 'no wait' : `${a.waitMs} ms after load`}`
209
- return { out: a.out, ms: ms(t0), phases, errors: [...slot.errors], note }
219
+ const note = `${move === 'none' ? 'warm page' : thawed ? 'frozen page reloaded' : move}, ${how === 'ready' ? 'ready mark' : move === 'none' ? 'no wait' : `${a.waitMs} ms after load`}${a.step ? `, drawn at ${a.at ?? 0}s through window.${a.step}(t)` : ''}`
220
+ return { out: a.out, ms: ms(t0), phases, errors: [...slot.errors], note, ...(a.step ? { stepped: true as const } : {}) }
210
221
  }
211
222
 
212
223
  async #clip(a: ClipAsk): Promise<ShotDone> {
@@ -223,6 +234,11 @@ export class Shooter {
223
234
  await waitReady(() => page.evaluate(readMark), a.waitMs, { now: () => performance.now(), sleep: (n) => page.waitForTimeout(n) })
224
235
  phases['load'] = ms(t)
225
236
  t = performance.now()
237
+ if (a.step) {
238
+ const note = await (await import('./stepped-clip.ts')).stepPage(await ctx.newCDPSession(page), page, a as ClipAsk & { step: string })
239
+ phases['film'] = ms(t)
240
+ return { out: a.out, ms: ms(t0), phases, errors, note, stepped: true }
241
+ }
226
242
  const path: ClipPath = clipPathOf({ dom: a.dom === true, canvas: await page.evaluate(largestCanvas), viewport: a.viewport })
227
243
  const why = path === 'canvas' ? await page.evaluate(installClip, { seconds: a.seconds, fps: a.fps, quantizer: CLIP_QUANTIZER, keyEvery: KEY_EVERY } satisfies PageClipAsk) : null
228
244
  if (path === 'dom' || why != null) {
@@ -17,6 +17,9 @@ export type ShotAsk = {
17
17
  reload: boolean
18
18
  /** a page with no ready mark is shot this long after its load (a warm page at once) */
19
19
  waitMs: number
20
+ /** a still of a stepped page: `window[step](at)` on a fresh load, then its ready mark, then the shot */
21
+ step?: string
22
+ at?: number
20
23
  }
21
24
 
22
25
  export type ClipAsk = ShotAsk & {
@@ -24,6 +27,9 @@ export type ClipAsk = ShotAsk & {
24
27
  fps: number
25
28
  /** film the page itself even when a canvas fills it (dom-clip.ts); a page with no big canvas is filmed so anyway */
26
29
  dom?: boolean
30
+ /** step the page frame by frame (stepped-clip.ts): before each frame `window[step](t)`, t its time in seconds, then
31
+ * its ready mark; a slow page films as smooth as a fast one */
32
+ step?: string
27
33
  }
28
34
 
29
35
  export const DEFAULT_SHOT_PORT = 4796
@@ -113,7 +119,9 @@ export const isClip = (a: ShotAsk | ClipAsk): a is ClipAsk => 'seconds' in a
113
119
 
114
120
  /** a request held past this (a clip: past its own seconds) fails loud and frees the turn */
115
121
  export const REQUEST_MAX_MS = 120_000
116
- export const capMsOf = (a: ShotAsk | ClipAsk): number => REQUEST_MAX_MS + (isClip(a) ? a.seconds * 1000 : 0)
122
+ export const capMsOf = (a: ShotAsk | ClipAsk): number => REQUEST_MAX_MS + (isClip(a) ? (a.step ? a.seconds * a.fps * STEP_FRAME_MS : a.seconds * 1000) : 0)
123
+ /** a stepped frame's budget in the request's cap: a page that draws a frame slower than this is no page to step */
124
+ export const STEP_FRAME_MS = 1000
117
125
 
118
126
  // ── the ready mark ──────────────────────────────────────────────────────────
119
127
  // A page that knows when it is drawn says so: `<meta name="shot-ready"
@@ -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
  }