rvlib-shipkit 2.1.0 → 2.2.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 (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +2 -1
  3. package/src/assets/kits/identity-kit.ts +7 -3
  4. package/src/assets/run-assets.ts +18 -4
  5. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  6. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  7. package/src/cli/cmd_CacheAudit.ts +3 -2
  8. package/src/cli/cmd_Calls.ts +53 -0
  9. package/src/cli/cmd_Ci.ts +4 -3
  10. package/src/cli/cmd_Commands.ts +158 -43
  11. package/src/cli/cmd_Fleet.ts +3 -1
  12. package/src/cli/cmd_GlobalProcesses.ts +7 -2
  13. package/src/cli/cmd_Shot.ts +63 -0
  14. package/src/cli/cmd_Start.ts +8 -4
  15. package/src/cli/cmd_Trailer.ts +108 -0
  16. package/src/cli/kit/builtins.ts +40 -0
  17. package/src/cli/kit/calls.ts +177 -0
  18. package/src/cli/kit/families.ts +67 -0
  19. package/src/cli/kit/index.ts +8 -0
  20. package/src/cli/kit/pages-check.ts +36 -0
  21. package/src/cli/kit/project-cli.ts +73 -0
  22. package/src/cli/kit/verb.ts +157 -0
  23. package/src/cli/list-flag.ts +3 -0
  24. package/src/cli.ts +13 -2
  25. package/src/core/layout.ts +10 -2
  26. package/src/core/migrate-layout.ts +31 -8
  27. package/src/core/one-way-in-policy.ts +12 -4
  28. package/src/mv/mv-md.ts +2 -1
  29. package/src/reflect/prompt-corpus.ts +5 -0
  30. package/src/scaffold/cli-skeleton.ts +149 -0
  31. package/src/scaffold/files/agent-index.md +2 -2
  32. package/src/scaffold/files/tsconfig.json +3 -0
  33. package/src/scaffold/files/verify-skill.md +1 -1
  34. package/src/scaffold/project-scaffold.ts +90 -12
  35. package/src/shipkit-files.ts +8 -4
  36. package/src/shot/client.ts +67 -0
  37. package/src/shot/clip-encoder.ts +146 -0
  38. package/src/shot/dom-clip.ts +124 -0
  39. package/src/shot/serve-shot.ts +54 -0
  40. package/src/shot/shooter.ts +343 -0
  41. package/src/shot/shot-ask.ts +189 -0
  42. package/src/shot/shot.shipkit.ts +14 -0
  43. package/src/smoke/run-smoke.ts +2 -2
  44. package/src/start-repo.ts +29 -14
  45. package/src/suite/judge-suites.ts +56 -10
  46. package/src/suite/select.ts +2 -5
  47. package/src/suite/suite-cli.ts +3 -2
  48. package/src/trailer/adapter.ts +23 -0
  49. package/src/trailer/build.ts +344 -0
  50. package/src/trailer/ffmpeg.ts +50 -0
  51. package/src/trailer/footage.ts +104 -0
  52. package/src/trailer/grammar.ts +915 -0
  53. package/src/trailer/graph.ts +323 -0
  54. package/src/trailer/load-unit.ts +14 -0
  55. package/src/trailer/look.ts +110 -0
  56. package/src/trailer/plan.ts +133 -0
  57. package/src/trailer/stitch.ts +150 -0
  58. package/src/trailer/titles.ts +274 -0
  59. package/src/trailer/url.ts +108 -0
  60. package/src/trailer/vertical-draw.ts +286 -0
  61. package/src/trailer/vertical.ts +148 -0
  62. package/src/trailer/vocab.ts +40 -0
  63. package/src/ts/rename.ts +9 -0
@@ -335,6 +335,18 @@ export function aloneRunsNeeded(suspects: Timed[], parallelCpu: Record<string, n
335
335
  * every (fail) line under no header of its own: it closes the last file */
336
336
  export const BUN_RECAP = /^\s*\d+ tests? failed:\s*$/
337
337
 
338
+ /** the banner bun prints under a file header for an error thrown outside
339
+ * any test (a top level throw, a throw inside a describe callback), then a
340
+ * rule, the code frame, the error, the stack and a closing rule */
341
+ export const BETWEEN_TESTS = '# Unhandled error between tests'
342
+ export const BUN_RULE = /^-{20,}$/
343
+ /** the line that carries an error's message: bun's own `error:` and a named
344
+ * error class (`ReferenceError: x is not defined`) */
345
+ const ERROR_LINE = /^\s*(?:error|[A-Z]\w*(?:Error|Exception)): /
346
+ const BETWEEN_WORDS = 'an unhandled error between tests'
347
+ /** the line that opens a file in bun's report: its path and a colon */
348
+ const HEADER = /^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/
349
+
338
350
  /** the suites with a failing test, read off bun's report: a file that failed
339
351
  * prints its path as a header line, then its `(fail)` lines under it. A
340
352
  * passing file prints nothing at all in a piped run, which is why this reads
@@ -343,39 +355,73 @@ export function failedSuites(output: string): string[] {
343
355
  const out = new Set<string>()
344
356
  let cur: string | null = null
345
357
  for (const line of output.split('\n')) {
346
- const head = line.match(/^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/)
358
+ const head = line.match(HEADER)
347
359
  if (head != null) { cur = normSuitePath(head[1]!); continue }
348
360
  if (BUN_RECAP.test(line)) { cur = null; continue }
349
361
  // a file that cannot load prints no `(fail)` line, only this banner
350
- if (cur != null && (/^\(fail\) /.test(line) || line.startsWith('# Unhandled error between tests'))) out.add(cur)
362
+ if (cur != null && (/^\(fail\) /.test(line) || line.startsWith(BETWEEN_TESTS))) out.add(cur)
351
363
  }
352
364
  return [...out]
353
365
  }
354
366
 
367
+ /** the error line inside the banner block that starts at `from`, before its closing rule */
368
+ function blockError(lines: string[], from: number): string | null {
369
+ let rules = 0
370
+ for (let j = from; j < lines.length && rules < 2; j++) {
371
+ const line = lines[j]!.trim()
372
+ if (BUN_RULE.test(line)) { rules++; continue }
373
+ if (HEADER.test(line)) return null
374
+ if (ERROR_LINE.test(line)) return line
375
+ }
376
+ return null
377
+ }
378
+
355
379
  /** the FIRST failure of each failing suite, read off the same report as
356
- * failedSuites: its `(fail)` line, timing dropped, with the first `error:`
357
- * line bun printed before it in that file. What the record keeps so a page
358
- * can say why a file is red without a rerun */
380
+ * failedSuites: its `(fail)` line, timing dropped, with the first error line
381
+ * bun printed before it in that file, or the error inside the banner of one
382
+ * that threw outside any test. What the record keeps so a page can say why a
383
+ * file is red without a rerun */
359
384
  export function failLines(output: string): Map<string, string> {
360
385
  const out = new Map<string, string>()
386
+ const lines = output.split('\n')
361
387
  let cur: string | null = null
362
388
  let err: string | null = null
363
- for (const raw of output.split('\n')) {
364
- const line = raw.trimEnd()
365
- const head = line.match(/^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/)
389
+ for (let i = 0; i < lines.length; i++) {
390
+ const line = lines[i]!.trimEnd()
391
+ const head = line.match(HEADER)
366
392
  if (head != null) { cur = normSuitePath(head[1]!); err = null; continue }
367
393
  if (BUN_RECAP.test(line)) { cur = null; continue }
368
394
  if (cur == null || out.has(cur)) continue
369
- if (err == null && /^\s*error: /.test(line)) err = line.trim()
395
+ if (err == null && ERROR_LINE.test(line)) err = line.trim()
370
396
  const fail = line.match(/^\(fail\) (.*?)(?: \[[\d.]+m?s\])?$/)
371
397
  if (fail != null) out.set(cur, clip(err == null ? fail[1]! : `${fail[1]!}: ${err}`))
372
- else if (line.startsWith('# Unhandled error between tests')) out.set(cur, clip(err == null ? 'an unhandled error between tests' : `an unhandled error between tests: ${err}`))
398
+ else if (line.startsWith(BETWEEN_TESTS)) {
399
+ const thrown = blockError(lines, i + 1) ?? err
400
+ out.set(cur, clip(thrown == null ? BETWEEN_WORDS : `${BETWEEN_WORDS}: ${thrown}`))
401
+ }
373
402
  }
374
403
  return out
375
404
  }
376
405
 
377
406
  const clip = (s: string): string => (s.length > 300 ? `${s.slice(0, 299)}…` : s)
378
407
 
408
+ /** the FAIL headline of a run bun ended red with no kill, read off bun's own
409
+ * summary (`N fail`, `N error`) and the report in `out`. bun can count an
410
+ * error thrown outside any test as `0 fail`, so the errors lead, each with
411
+ * the file that threw */
412
+ export function redWords(o: { failedTests: number; errors: number; ran: number; slowest: string; out: string }): string {
413
+ const thrown = [...failLines(o.out)].filter(([, w]) => w.startsWith(BETWEEN_WORDS))
414
+ const parts: string[] = []
415
+ if (o.errors > 0 || thrown.length > 0) {
416
+ const named = thrown.slice(0, 3).map(([file, w]) => `${file} threw ${w.slice(BETWEEN_WORDS.length + 2) || 'with no message'}`)
417
+ const more = thrown.length > 3 ? `; ${thrown.length - 3} more` : ''
418
+ parts.push(`${Math.max(o.errors, thrown.length)} error(s) outside any test ${named.length > 0 ? `(${named.join('; ')}${more})` : '(bun named no file)'}`)
419
+ }
420
+ if (o.failedTests > 0) parts.push(`${o.failedTests} test(s) failed`)
421
+ if (parts.length === 0) parts.push('bun test exited red with no failed test and no error counted')
422
+ return `${parts.join(', ')}, Ran ${o.ran} tests${o.slowest}`
423
+ }
424
+
379
425
  /** the clause the headline carries for the gate line: the command phase one
380
426
  * ran, and the flags the runner added to the script's own. Per file ignores
381
427
  * stay out, the slow and serial counts already say what they leave out */
@@ -31,7 +31,7 @@ import { blobOid, listingHash } from '../core/read-set.ts'
31
31
  import { shipkitRoot } from '../ledger/db.ts'
32
32
  import { extractSpecs, resolveSpec } from '../ts/source-graph.ts'
33
33
  import { type RvtConfig } from '../ts/config.ts'
34
- import { inDotDir } from './judge-suites.ts'
34
+ import { BETWEEN_TESTS, BUN_RULE, inDotDir } from './judge-suites.ts'
35
35
 
36
36
  export const SELECT_VERSION = 5
37
37
 
@@ -403,9 +403,6 @@ export function selectSuites(o: {
403
403
  return { all: false, run, why: reasons, changed: o.changed.size }
404
404
  }
405
405
 
406
- const BETWEEN_TESTS = '# Unhandled error between tests'
407
- const RULE = /^-{20,}$/
408
-
409
406
  /** the repo modules named in the frames of every error bun printed between
410
407
  * tests (a module whose top level threw), repo-relative to `top` */
411
408
  export function loadErrorModules(out: string, top: string): string[] {
@@ -419,7 +416,7 @@ export function loadErrorModules(out: string, top: string): string[] {
419
416
  let rules = 0
420
417
  for (let j = i + 1; j < lines.length && rules < 2; j++) {
421
418
  const line = lines[j]!
422
- if (RULE.test(line.trim())) { rules++; continue }
419
+ if (BUN_RULE.test(line.trim())) { rules++; continue }
423
420
  const m = /^\s+at .*?\(?((?:\/|[A-Za-z]:\\)[^():]+):\d+(?::\d+)?\)?\s*$/.exec(line)
424
421
  if (m == null) continue
425
422
  const base = roots.find((r) => m[1]!.startsWith(`${r}/`))
@@ -20,7 +20,7 @@ import { SUITE_READS_ENV, SUITE_ROOT_ENV, SUITE_SELECT_ENV } from './reads-env.t
20
20
  import { changedKeys, dropStaticEdges, extras, isBunSuite, envFilesOf, foldReads, loadErrorModules, loadState, preloadsOf, saveState, selectStatePath, selectSuites, snapshot, suiteFiles, taintedSuites, walkGraph, type GraphEntry, type Selection, type SelectState } from './select.ts'
21
21
  import { gitRoot } from '../core/git-root.ts'
22
22
  import { loadConfig } from '../ts/config.ts'
23
- import { aloneRunsNeeded, bunfigTest, type BunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, interruptedSuites, overWords, ranAs, retryAlone, smallerRun, stallWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
23
+ import { aloneRunsNeeded, bunfigTest, type BunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, interruptedSuites, overWords, ranAs, retryAlone, smallerRun, stallWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers, redWords } from './judge-suites.ts'
24
24
 
25
25
  type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
26
26
 
@@ -196,6 +196,7 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
196
196
  const phases = [p1, ...(p2 != null ? [p2] : [])]
197
197
  const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
198
198
  const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
199
+ const errors = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) errors?$/gm), 0)
199
200
  const timings = Object.assign({}, ...phases.map((p) => p.timings)) as Record<string, number>
200
201
  const crashed = crashedWorkers(p1.out)
201
202
  const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashed.map((c) => c.file)])
@@ -290,7 +291,7 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
290
291
  ...killed.map((k) => `FAIL: ${k}`),
291
292
  ...(vacuous ? ['FAIL: ran 0 tests: a suite with no test in it proves nothing, write a real one'] : []),
292
293
  ...(!vacuous && (failedTests > 0 || (phases.some((p) => p.code !== 0 && p.signal == null) && killed.length === 0))
293
- ? [`FAIL: ${failedTests} test(s) failed, Ran ${ran} tests${slowest}`] : []),
294
+ ? [`FAIL: ${redWords({ failedTests, errors, ran, slowest, out: phases.map((p) => p.out).join('\n') })}`] : []),
294
295
  ...(over.length > 0
295
296
  ? [`FAIL: ${over.length} suite(s) over the ${budget}ms budget: ${over.map((c) => overWords(c, load, budget)).join(', ')} (run alone, load ${load.load1.toFixed(1)} on ${load.cores} cores before the run). Make each fast, or declare it in ${declaredIn}.slow with what it catches and why it cannot be fast`]
296
297
  : []),
@@ -0,0 +1,23 @@
1
+ // what a repo adds to the trailer core, all optional. Filming is never here: a film line calls the project CLI's
2
+ // film rows (vocab.ts). A `trailer({...})` unit in any `<name>.shipkit.ts` resolves the sounds that are not files.
3
+ // Discovered like every unit file (load-unit.ts), and read only when a cut names such a sound
4
+
5
+ export interface TrailerSounds {
6
+ /** a music id to an audio file of at least `seconds`, cached by the repo */
7
+ music?(id: string, o: { take?: string; seconds: number }): Promise<string>
8
+ /** an sfx id to an audio file */
9
+ sfx?(id: string): Promise<string>
10
+ /** a voice line, found by its text, to an audio file */
11
+ voice?(text: string): Promise<string>
12
+ }
13
+
14
+ export interface TrailerDef {
15
+ sound?: TrailerSounds
16
+ }
17
+
18
+ export type TrailerUnit = TrailerDef & { kind: 'trailer' }
19
+
20
+ export function trailer(d: TrailerDef): TrailerUnit {
21
+ 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`)
22
+ return { ...d, kind: 'trailer' }
23
+ }
@@ -0,0 +1,344 @@
1
+ // a cut's .trailer file → its mp4: load (parse, resolve every file and sound), film what is missing, draw the titles,
2
+ // stitch the pieces, lay the strip, write the montage page. `sk trailer` and its verbs call these
3
+ import { 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'
6
+ import type { BrandLike, TrailerLook } from './look.ts'
7
+ import { fresh, isTake, type Refilm, type Take, takesToFilm } from './plan.ts'
8
+ import type { Stitched } from './stitch.ts'
9
+ import { projectCli, readVocab } from './vocab.ts'
10
+
11
+ export type Say = (line: string) => void
12
+
13
+ export class Refused extends Error {}
14
+ const refuse = (why: string): never => {
15
+ throw new Refused(why)
16
+ }
17
+
18
+ /** every `.trailer` under `trailer/<trailer>/` */
19
+ export function trailerFiles(root: string): string[] {
20
+ const dir = join(root, 'trailer')
21
+ if (!existsSync(dir)) return []
22
+ return readdirSync(dir)
23
+ .filter((d) => statSync(join(dir, d)).isDirectory())
24
+ .flatMap((d) => readdirSync(join(dir, d)).filter((f) => f.endsWith('.trailer')).map((f) => join(dir, d, f)))
25
+ .sort()
26
+ }
27
+
28
+ /** the .trailer a command names: a file, a folder's main.trailer (or its one .trailer), or the one under trailer/ */
29
+ export function trailerFile(arg: string | undefined, root: string, cwd: string): string {
30
+ if (arg !== undefined) {
31
+ const p = resolve(cwd, arg)
32
+ if (existsSync(p) && statSync(p).isDirectory()) {
33
+ if (existsSync(join(p, 'main.trailer'))) return join(p, 'main.trailer')
34
+ const own = readdirSync(p).filter((f) => f.endsWith('.trailer'))
35
+ return own.length === 1 ? join(p, own[0]!) : refuse(own.length === 0 ? `${arg} has no .trailer` : `${arg} has several cuts, name one: ${own.join(', ')}`)
36
+ }
37
+ return existsSync(p) ? p : refuse(`no file ${arg}`)
38
+ }
39
+ const all = trailerFiles(root)
40
+ if (all.length === 1) return all[0]!
41
+ return refuse(all.length === 0 ? 'no .trailer under trailer/<trailer>/: write trailer/<name>/main.trailer (wiki build/trailer.md)' : `several cuts, name one: ${all.map((f) => relative(cwd, f)).join(', ')}`)
42
+ }
43
+
44
+ /** a sound or a media word that names a file rather than an id */
45
+ export const isFileWord = (w: string): boolean => w.includes('/') || /\.[a-z0-9]{2,5}$/i.test(w)
46
+
47
+ export interface Loaded {
48
+ root: string
49
+ file: string
50
+ dir: string
51
+ t: Trailer
52
+ errors: TrailerError[]
53
+ /** the project CLI's argv, null when the repo has none */
54
+ cli: string[] | null
55
+ verbs: FilmVerb[] | null
56
+ }
57
+
58
+ /** a file a line names: beside the .trailer first, then from the repo root */
59
+ export function fileOf(path: string, l: Pick<Loaded, 'dir' | 'root'>): string | null {
60
+ if (isAbsolute(path)) return existsSync(path) ? path : null
61
+ for (const f of [join(l.dir, path), join(l.root, path)]) if (existsSync(f)) return f
62
+ return null
63
+ }
64
+
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')
68
+ const cli = projectCli(root)
69
+ let verbs: FilmVerb[] | null = null
70
+ const errors: TrailerError[] = []
71
+ let parsed = parseTrailer(text, { verbs: null })
72
+ // the project CLI is asked only when a film line does not read as a core film
73
+ const lines = text.split('\n')
74
+ if (cli !== null && parsed.errors.some((e) => /^\s*film\s/.test(lines[e.line - 1] ?? ''))) {
75
+ try {
76
+ verbs = readVocab(root, cli)
77
+ parsed = parseTrailer(text, { verbs })
78
+ } catch (e) {
79
+ errors.push({ line: 1, message: (e as Error).message })
80
+ }
81
+ }
82
+ const t = parsed.trailer
83
+ const l: Loaded = { root, file, dir: dirname(file), t, errors: [...errors, ...parsed.errors], cli, verbs }
84
+ if (l.errors.length === 0) l.errors.push(...checkFiles(l))
85
+ l.errors.sort((a, b) => a.line - b.line)
86
+ return l
87
+ }
88
+
89
+ /** the clips, images and sound files the lines name, each found where it should be */
90
+ function checkFiles(l: Loaded): TrailerError[] {
91
+ const out: TrailerError[] = []
92
+ const need = (line: number, what: string, path: string): void => {
93
+ if (!fileOf(path, l)) out.push({ line, message: `${what}: no file ${path} beside the .trailer or from the repo root` })
94
+ }
95
+ for (const s of l.t.shots) {
96
+ 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)
98
+ for (const v of s.voices) if (v.file) need(v.line, 'voice file', v.file)
99
+ }
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
+ const v = l.t.vertical
102
+ if (v && typeof v.outro === 'object') need(v.line, 'vertical outro', v.outro.clip)
103
+ return out
104
+ }
105
+
106
+ /** the ids that are not files: what only the repo's trailer unit resolves */
107
+ export function soundIds(t: Trailer): { line: number; kind: 'music' | 'sfx' | 'voice'; id: string }[] {
108
+ 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 }] : [])),
110
+ ...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! })),
113
+ ]),
114
+ ]
115
+ }
116
+
117
+ export function sayErrors(l: Loaded, say: Say, cwd: string): boolean {
118
+ for (const e of l.errors) say(`${relative(cwd, l.file)}:${e.line}: ${e.message}`)
119
+ return l.errors.length === 0
120
+ }
121
+
122
+ const ago = (iso: string): string => {
123
+ const m = Math.round((Date.now() - Date.parse(iso)) / 60_000)
124
+ return m < 60 ? `${m} min ago` : m < 48 * 60 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} days ago`
125
+ }
126
+
127
+ const filmText = (f: Film | null): string => {
128
+ if (!f) return 'nothing'
129
+ if (f.kind === 'clip' || f.kind === 'image') return `${f.kind} ${f.path}`
130
+ if (f.kind === 'run') return `run "${f.cmd}"${f.still ? ' still' : ''}`
131
+ if (f.kind === 'cli') return f.argv.join(' ')
132
+ if (f.kind === 'url') return `url ${f.route}${f.viewport ? ` viewport ${f.viewport.join('x')}` : ''}${f.still ? ' still' : ''}`
133
+ return 'card'
134
+ }
135
+
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[] {
138
+ const st = starts(l.t)
139
+ 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})`
149
+ const sec = s.section ? ` [section ${s.section.name} at ${+s.section.at.toFixed(3)} s]` : ''
150
+ 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
+ })
153
+ return out
154
+ }
155
+
156
+ const secs = (x: number): string => `${+x.toFixed(2)} s`
157
+ const clockText = (x: number): string => `${Math.floor(x / 60)}:${(x % 60).toFixed(2).padStart(5, '0')}`
158
+ const cell = (s: string): string => s.replaceAll('|', '\\|').replaceAll('\n', ' ')
159
+
160
+ /** the cut as a table an agent or a human reads: `out/<cut>.montage.md`, written by every build */
161
+ export function montage(t: Trailer, file: string): string {
162
+ const st = starts(t)
163
+ const m = t.music
164
+ const heard = (s: Shot): string =>
165
+ [s.music ? `music ${s.music.song}` : '', ...s.sfx.map((x) => `sfx ${x.id} at ${secs(x.at)}`), ...s.voices.map((v) => `voice ${v.text ? `"${v.text}"` : v.file} at ${secs(v.at)}`)].filter(Boolean).join('<br>')
166
+ return [
167
+ `# Montage: ${t.name}`,
168
+ '',
169
+ `Written from \`${file}\` by every \`sk trailer\` build: edit the .trailer, never this file.`,
170
+ '',
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)}` : ''}.`,
172
+ '',
173
+ '| # | at | shot | length | film | on screen | heard | cut out |',
174
+ '|---|---|---|---|---|---|---|---|',
175
+ ...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)}`} |`,
177
+ ),
178
+ '',
179
+ ].join('\n')
180
+ }
181
+
182
+ export interface BuildOptions {
183
+ refilm: Refilm
184
+ final: boolean
185
+ say: Say
186
+ cwd: string
187
+ }
188
+
189
+ /** 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)
193
+ const ask = takesToFilm(takes, world, refilm, only)
194
+ const failed: string[] = []
195
+ const lane = async (): Promise<void> => {
196
+ for (let group = ask.shift(); group; group = ask.shift()) {
197
+ try {
198
+ const got = await filmTake(group[0]!, { root: l.root, t: l.t, world, cli: l.cli, say })
199
+ for (const tk of group) {
200
+ const i = takes.indexOf(tk)
201
+ takes[i] = { ...tk, stamp: got.stamp }
202
+ }
203
+ } catch (e) {
204
+ failed.push(e instanceof Error ? e.message : String(e))
205
+ }
206
+ }
207
+ }
208
+ await Promise.all([lane(), lane()])
209
+ if (failed.length > 0) throw new Refused(`${failed.length} shot(s) did not film:\n${failed.join('\n')}`)
210
+ return takes
211
+ }
212
+
213
+ /** 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([
216
+ import('./titles.ts'),
217
+ import('./look.ts'),
218
+ import('./ffmpeg.ts'),
219
+ import('./footage.ts'),
220
+ import('../assets/run-assets.ts'),
221
+ ])
222
+ const brand = await loadBrand(l.root)
223
+ if (typeof brand === 'string') throw new Refused(brand)
224
+ const look = lookOf(brand, await identityLogo(l.root))
225
+ const base = { look, brand }
226
+ const titles = trailerCache(l.root, 'titles')
227
+ const sources = shots.map((s) => {
228
+ const f = s.film!
229
+ if (f.kind === 'card') return { image: cardPng(titles, l.t.size, look) }
230
+ if (f.kind === 'image') return { image: fileOf(f.path, l)! }
231
+ if (f.kind === 'clip') {
232
+ const file = fileOf(f.path, l)!
233
+ return { footage: file, seconds: duration(file) }
234
+ }
235
+ const tk = takes.find((x) => x.shot.name === s.name)!
236
+ 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)))))
239
+ 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))))
248
+ 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)
250
+ return { file, seconds: duration(file) }
251
+ }))))
252
+ 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)
254
+ return { file, seconds: duration(file) }
255
+ }))))
256
+ return { ...base, sources, titles: drawn, music, sfx, voices }
257
+ }
258
+
259
+ /** a vertical cut finished: what the brand's vertical look draws over the stitched cut, the outro, the end card. The
260
+ * finish is cached on the pieces and the mix it reads, so a build that changed nothing encodes nothing */
261
+ 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([
263
+ import('./vertical.ts'),
264
+ import('./vertical-draw.ts'),
265
+ import('./stitch.ts'),
266
+ import('./ffmpeg.ts'),
267
+ import('./footage.ts'),
268
+ ])
269
+ const v = l.t.vertical!
270
+ const vl = verticalOf(input.brand, input.look, basename(l.root), v.mark)
271
+ const dir = trailerCache(l.root, 'titles')
272
+ const clip = typeof v.outro === 'object' ? fileOf(v.outro.clip, l)! : null
273
+ const [wear, mark, end] = await Promise.all([
274
+ drawWear(dir, l.root, input.look, vl, l.t.size, l.t.fps),
275
+ clip ? drawMark(dir, l.root, input.look, vl, l.t.size) : null,
276
+ v.outro === 'none' ? null : drawEnd(dir, l.root, input.look, vl, l.t.size),
277
+ ])
278
+ const a = {
279
+ cut,
280
+ seconds: duration(cut),
281
+ size: l.t.size,
282
+ fps: l.t.fps,
283
+ wear,
284
+ mark,
285
+ outro: clip ? { clip, seconds: duration(clip), audio: hasAudio(clip) } : null,
286
+ end: end && { ...end, seconds: vl.end!.seconds },
287
+ encoder: final ? ('final' as const) : ('draft' as const),
288
+ }
289
+ const clipStat = clip ? [statSync(clip).size, statSync(clip).mtimeMs] : null
290
+ const key = new Bun.CryptoHasher('sha1').update(JSON.stringify([finishArgs(a, 'out.mp4'), done.pieces.map((p) => p.file), done.mix, clipStat])).digest('hex').slice(0, 16)
291
+ const { files, made } = await cachedAs(join(trailerCache(l.root, 'pieces'), 'vertical'), `${l.t.name}-vertical`, key, ['mp4'], ([o]) => finishArgs(a, o!))
292
+ copyFileSync(files[0]!, out)
293
+ return { note: made ? 'the vertical finished' : 'the vertical cached', seconds: finishSeconds(a) }
294
+ }
295
+
296
+ /** trailer/<trailer>/out/, its own `.gitignore` holding `*`: a build output is never committed by accident */
297
+ function outDir(l: Loaded): string {
298
+ const dir = join(l.dir, 'out')
299
+ mkdirSync(dir, { recursive: true })
300
+ if (!existsSync(join(dir, '.gitignore'))) writeFileSync(join(dir, '.gitignore'), '*\n')
301
+ return dir
302
+ }
303
+
304
+ /** the whole cut: out/<cut>.mp4, its strip and montage page; the timeline and what was rendered, said */
305
+ export async function build(l: Loaded, o: BuildOptions): Promise<string> {
306
+ const t0 = performance.now()
307
+ const [{ worldStamp, trailerCache }, { stitch, strip, rendered }, { ffmpegMissing }] = await Promise.all([import('./footage.ts'), import('./stitch.ts'), import('./ffmpeg.ts')])
308
+ const missing = ffmpegMissing()
309
+ if (missing) throw new Refused(missing)
310
+ const world = worldStamp(l.root)
311
+ const takes = await filmAll(l, world, o.refilm, o.say)
312
+ const tFilm = performance.now()
313
+ const input = await stitchInput(l, takes, l.t.shots, false)
314
+ const out = join(outDir(l), `${l.t.name}.mp4`)
315
+ // a vertical cut stitches into the cache, and its finish writes the mp4
316
+ const cut = l.t.vertical ? join(trailerCache(l.root, 'pieces'), `${l.t.name}-cut.mp4`) : out
317
+ const done = await stitch({ ...input, t: l.t, out: cut, cache: trailerCache(l.root, 'pieces'), encoder: o.final ? 'final' : 'draft' })
318
+ const finished = l.t.vertical ? await finishVertical(l, input, done, cut, out, o.final) : null
319
+ const stripFile = join(outDir(l), `${l.t.name}.strip.png`)
320
+ await strip(done.pieces, stripFile)
321
+ writeFileSync(join(outDir(l), `${l.t.name}.montage.md`), montage(l.t, basename(l.file)))
322
+ for (const line of timeline(l, takes, world, o.cwd)) o.say(line)
323
+ 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
+ o.say(`ok: ${stripFile}`)
325
+ return out
326
+ }
327
+
328
+ /** one shot alone with its titles, no sound: out/shot-<name>.mp4 and its nine frames on out/shot-<name>.png */
329
+ export async function previewShot(l: Loaded, name: string, o: BuildOptions): Promise<void> {
330
+ const t0 = performance.now()
331
+ 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')])
333
+ const missing = ffmpegMissing()
334
+ if (missing) throw new Refused(missing)
335
+ 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]))
337
+ const input = await stitchInput(l, takes, one.shots, true)
338
+ const out = join(outDir(l), `shot-${name}.mp4`)
339
+ await stitch({ ...input, t: one, out, cache: trailerCache(l.root, 'pieces'), silent: true })
340
+ const sheet = out.replace(/\.mp4$/, '.png')
341
+ await sheetOf(out, s.length, sheet)
342
+ o.say(`ok: ${out} (${s.length} s) in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
343
+ o.say(`ok: ${sheet}`)
344
+ }
@@ -0,0 +1,50 @@
1
+ // the one way the trailer spawns ffmpeg and ffprobe: a run that fails loud with ffmpeg's own words, a probe, the nine
2
+ // frame sheet. The binaries resolve once, with the homebrew path for processes started without it
3
+ import { spawn } from 'node:child_process'
4
+ import { basename } from 'node:path'
5
+
6
+ const FFMPEG = Bun.which('ffmpeg') ?? '/opt/homebrew/bin/ffmpeg'
7
+ const FFPROBE = Bun.which('ffprobe') ?? '/opt/homebrew/bin/ffprobe'
8
+
9
+ /** ffmpeg and ffprobe on this machine, or what to install */
10
+ export function ffmpegMissing(): string | null {
11
+ for (const [name, bin] of [['ffmpeg', FFMPEG], ['ffprobe', FFPROBE]] as const) if (!Bun.which(bin)) return `${name} not found: brew install ffmpeg`
12
+ return null
13
+ }
14
+
15
+ const niced = (cmd: string[], nice?: number): string[] => (nice ? ['nice', '-n', String(nice), ...cmd] : cmd)
16
+
17
+ const failure = (args: readonly string[], err: string, code: number | null): Error =>
18
+ new Error(`ffmpeg → ${basename(args.at(-1) ?? '')}: ${err.trim().split('\n').slice(-6).join('\n') || `exit ${code}`}`)
19
+
20
+ /** ffmpeg with `args` after `-hide_banner -nostdin -y -loglevel error`; resolves to its stderr, rejects with it */
21
+ export function ffmpeg(args: readonly string[], o: { nice?: number } = {}): Promise<string> {
22
+ return new Promise((ok, fail) => {
23
+ const cmd = niced([FFMPEG, '-hide_banner', '-nostdin', '-y', '-loglevel', 'error', ...args], o.nice)
24
+ const p = spawn(cmd[0]!, cmd.slice(1), { stdio: ['ignore', 'ignore', 'pipe'] })
25
+ let err = ''
26
+ p.stderr.on('data', (d: Buffer) => (err += d.toString()))
27
+ p.on('error', (e) => fail(new Error(`${FFMPEG}: ${e.message}; brew install ffmpeg`)))
28
+ p.on('close', (code) => (code === 0 ? ok(err) : fail(failure(args, err, code))))
29
+ })
30
+ }
31
+
32
+ /** one ffprobe of a media file: its length in seconds; throws when ffprobe cannot read it or finds no length */
33
+ export function duration(file: string): number {
34
+ const r = Bun.spawnSync([FFPROBE, '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { stdout: 'pipe', stderr: 'pipe' })
35
+ const d = Number(r.stdout.toString().trim())
36
+ if (r.exitCode !== 0 || !(d > 0)) throw new Error(`ffprobe ${file}: ${r.stderr.toString().trim() || 'no length'}`)
37
+ return d
38
+ }
39
+
40
+ /** the media file carries a sound stream */
41
+ export function hasAudio(file: string): boolean {
42
+ const r = Bun.spawnSync([FFPROBE, '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', file], { stdout: 'pipe', stderr: 'pipe' })
43
+ if (r.exitCode !== 0) throw new Error(`ffprobe ${file}: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`)
44
+ return r.stdout.toString().trim() !== ''
45
+ }
46
+
47
+ /** nine frames of a clip evenly spaced, three by three on one png: how a shot is judged */
48
+ export async function sheetOf(clip: string, seconds: number, png: string): Promise<void> {
49
+ await ffmpeg(['-i', clip, '-vf', `fps=${(9 / seconds).toFixed(4)},scale=480:-2,tile=3x3`, '-frames:v', '1', png])
50
+ }