rvlib-shipkit 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -1,6 +1,7 @@
1
1
  // the one way the trailer spawns ffmpeg and ffprobe: a run that fails loud with ffmpeg's own words, a probe, the nine
2
2
  // frame sheet. The binaries resolve once, with the homebrew path for processes started without it
3
3
  import { spawn } from 'node:child_process'
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
4
5
  import { basename } from 'node:path'
5
6
 
6
7
  const FFMPEG = Bun.which('ffmpeg') ?? '/opt/homebrew/bin/ffmpeg'
@@ -44,6 +45,27 @@ export function hasAudio(file: string): boolean {
44
45
  return r.stdout.toString().trim() !== ''
45
46
  }
46
47
 
48
+ /** a sound's integrated loudness (LUFS) and true peak (dBTP), as ffmpeg's ebur128 measures them */
49
+ export async function loudness(file: string): Promise<{ lufs: number; truePeak: number }> {
50
+ const log = await ffmpeg(['-nostats', '-loglevel', 'info', '-i', file, '-map', '0:a', '-af', 'ebur128=peak=true', '-f', 'null', '-'])
51
+ const summary = log.split('Summary:').at(-1) ?? ''
52
+ // silence measures -inf: floored, so the value stays a number in json
53
+ const num = (v: string | undefined): number => (v === '-inf' ? -120 : Number(v ?? Number.NaN))
54
+ const lufs = num(/I:\s+(-?[\d.]+|-inf) LUFS/.exec(summary)?.[1])
55
+ const truePeak = num(/Peak:\s+(-?[\d.]+|-inf) dBFS/.exec(summary)?.[1])
56
+ if (Number.isNaN(lufs) || Number.isNaN(truePeak)) throw new Error(`ebur128 ${basename(file)}: no loudness summary in ffmpeg's log`)
57
+ return { lufs, truePeak }
58
+ }
59
+
60
+ /** loudness() of a file that never changes under its name (a cached render), kept beside it */
61
+ export async function loudnessKept(file: string): Promise<{ lufs: number; truePeak: number }> {
62
+ const json = `${file}.loudness.json`
63
+ if (existsSync(json)) return JSON.parse(readFileSync(json, 'utf8')) as { lufs: number; truePeak: number }
64
+ const m = await loudness(file)
65
+ writeFileSync(json, JSON.stringify(m))
66
+ return m
67
+ }
68
+
47
69
  /** nine frames of a clip evenly spaced, three by three on one png: how a shot is judged */
48
70
  export async function sheetOf(clip: string, seconds: number, png: string): Promise<void> {
49
71
  await ffmpeg(['-i', clip, '-vf', `fps=${(9 / seconds).toFixed(4)},scale=480:-2,tile=3x3`, '-frames:v', '1', png])
@@ -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
+ }
@@ -1,52 +1,96 @@
1
1
  // the takes on disk: each film line that writes its own take (a project CLI film row, a `film run`, a `film url`) filmed once into
2
2
  // `.shipkit/cache/trailer/footage/<key>.<ext>`, a stamp beside it naming the world it was filmed on. A take of an older
3
3
  // world is reported, and filmed again only on --refilm. Written aside then renamed, two at once
4
- import { spawnSync } from 'node:child_process'
5
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
6
- import { join } from 'node:path'
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { isAbsolute, join } from 'node:path'
7
+ import type { TrailerFilmLine, TrailerUnit } from './adapter.ts'
7
8
  import { duration } from './ffmpeg.ts'
8
- import type { Trailer } from './grammar.ts'
9
- import { cliArgv, isTake, runCommand, type Stamp, type Take, type TakeFilm, takeIsStill, takeName, takeSeconds } from './plan.ts'
9
+ import type { Shot, Trailer } from './grammar.ts'
10
+ import { cliArgv, isTake, recipeText, recipeWords, runCommand, type Stamp, specOf, type Take, type TakeFilm, type TakeSpec, takeIsStill, takeName, takeSeconds } from './plan.ts'
11
+ import { statesOf } from './states.ts'
10
12
 
11
- export const trailerCache = (root: string, part: 'footage' | 'pieces' | 'titles' | 'sound'): string => join(root, '.shipkit', 'cache', 'trailer', part)
13
+ export const trailerCache = (root: string, part: 'footage' | 'pieces' | 'titles' | 'sound' | 'score'): string => join(root, '.shipkit', 'cache', 'trailer', part)
12
14
 
13
- /** every shot's take, with its stamp when it was filmed */
14
- export function takesOf(t: Trailer, root: string): Take[] {
15
+ /** a recipe file a `@<file>` word names: its repo relative path, its text */
16
+ export interface Recipe {
17
+ path: string
18
+ text: string
19
+ }
20
+
21
+ /** what a take reads past its line: the recipe files, the repo's trailer unit */
22
+ export interface TakeContext {
23
+ recipes: ReadonlyMap<string, Recipe>
24
+ unit: TrailerUnit | null
25
+ }
26
+
27
+ /** a film line as the unit sees it */
28
+ export function filmLineOf(shot: Shot, f: Extract<TakeFilm, { kind: 'cli' }>, c: Pick<TakeContext, 'recipes'>): TrailerFilmLine {
29
+ const files = Object.fromEntries(recipeWords(f.argv).flatMap((w) => (c.recipes.has(w) ? [[w, c.recipes.get(w)!.text]] : [])))
30
+ return { line: f.line, shot: shot.name, argv: f.argv, output: f.output, files }
31
+ }
32
+
33
+ /** a take's spec and the words it runs: its row's defaults, the unit's `take` over them, each `@<file>` resolved */
34
+ export function specFor(shot: Shot, f: TakeFilm, c: TakeContext): { spec: TakeSpec; argv?: string[] } {
35
+ // a run keys the files it says it reads, by their content
36
+ if (f.kind === 'run') return { spec: specOf(f, Object.fromEntries((f.reads ?? []).map((p) => [`reads:${p}`, c.recipes.get(`reads:${p}`)?.text ?? '']))) }
37
+ if (f.kind !== 'cli') return { spec: specOf(f) }
38
+ const line = filmLineOf(shot, f, c)
39
+ // a declared file word keys its content, and reaches the row as its repo relative path
40
+ const declared = Object.fromEntries((f.files ?? []).flatMap((i) => (c.recipes.has(`file:${f.argv[i]}`) ? [[`file:${f.argv[i]}`, c.recipes.get(`file:${f.argv[i]}`)!.text]] : [])))
41
+ const base = specOf(f, { ...Object.fromEntries(Object.entries(line.files).map(([w, text]) => [w, recipeText(text)])), ...declared })
42
+ const got = c.unit?.take?.({ ...line, version: base.version, length: base.length })
43
+ const spec: TakeSpec = got
44
+ ? { ...base, version: got.version ?? base.version, length: got.length ?? base.length, world: got.world ?? null, recipe: got.recipe === undefined ? base.recipe : { recipe: got.recipe, files: (base.recipe as { files?: unknown }).files ?? null } }
45
+ : base
46
+ const files = new Set(f.files ?? [])
47
+ return { spec, argv: f.argv.map((w, i) => (files.has(i) ? (c.recipes.get(`file:${w}`)?.path ?? w) : c.recipes.has(w) ? `@${c.recipes.get(w)!.path}` : w)) }
48
+ }
49
+
50
+ /** every shot's and footage block's take, with its stamp when it was filmed */
51
+ export function takesOf(t: Trailer, root: string, c: TakeContext = { recipes: new Map(), unit: null }): Take[] {
15
52
  const dir = trailerCache(root, 'footage')
16
- return t.shots.flatMap((shot) => {
53
+ return [...t.shots, ...t.footage].flatMap((shot) => {
17
54
  const film = shot.film
18
- if (!isTake(film)) return []
19
- const file = join(dir, takeName(shot, film, t))
55
+ if (!isTake(film) || shot.broken) return []
56
+ const { spec, argv } = specFor(shot, film, c)
57
+ const file = join(dir, takeName(shot, film, t, spec))
20
58
  const stampFile = `${file}.json`
21
59
  const stamp = existsSync(file) && existsSync(stampFile) ? (JSON.parse(readFileSync(stampFile, 'utf8')) as Stamp) : null
22
- return [{ shot, film, file, stamp }]
60
+ return [{ shot, film, file, stamp, spec, ...(argv ? { argv } : {}) }]
23
61
  })
24
62
  }
25
63
 
26
- const git = (root: string, ...args: string[]): string | null => {
27
- const r = spawnSync('git', args, { cwd: root, encoding: 'utf8' })
28
- return r.status === 0 ? r.stdout : null
64
+ /** a git run's output, null when it fails; no size limit: a big repo lists many files */
65
+ const git = (root: string, args: string[], env?: Record<string, string>): string | null => {
66
+ const r = Bun.spawnSync(['git', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe', ...(env ? { env: { ...process.env, ...env } } : {}) })
67
+ return r.exitCode === 0 ? r.stdout.toString() : null
29
68
  }
30
69
 
31
- /** the world the takes show: the repo's committed tree and what the working tree changes, trailer folders and the
32
- * shipkit cache left out; a repo with no git: `no-git` */
33
- export function worldStamp(root: string): string {
34
- const tree = git(root, 'rev-parse', 'HEAD^{tree}')
35
- if (tree === null) return 'no-git'
36
- const h = new Bun.CryptoHasher('sha1').update(tree)
37
- const dirty = (git(root, 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.', ':(exclude)trailer', ':(exclude).shipkit') ?? '')
38
- .split('\0')
39
- .filter(Boolean)
40
- .map((l) => l.slice(3))
41
- .sort()
42
- for (const f of dirty) {
43
- h.update(f)
44
- const abs = join(root, f)
45
- try {
46
- h.update(readFileSync(abs))
47
- } catch {}
70
+ /**
71
+ * the world the takes show: every file of the repo as it stands, by its content (`trailer/` and `.shipkit/` left out),
72
+ * or only the `paths` the trailer unit's `stamp` names; a repo with no git: `no-git`. Content addressed: the working
73
+ * tree is staged into a throwaway copy of the index and the staged entries hashed, so the same content dirty or
74
+ * committed is one world, and an edit of the cut alone moves nothing
75
+ */
76
+ export function worldStamp(root: string, paths?: readonly string[]): string {
77
+ const real = git(root, ['rev-parse', '--git-path', 'index'])
78
+ if (real === null) return 'no-git'
79
+ const spec = paths ? ['--', ...paths] : ['--', '.', ':(exclude)trailer', ':(exclude).shipkit']
80
+ if (paths) for (const p of paths) if (!existsSync(join(root, p))) throw new Error(`the trailer unit's stamp names a path the repo does not hold: ${p}`)
81
+ const index = join(tmpdir(), `sk-world-${process.pid}-${Math.random().toString(36).slice(2)}`)
82
+ try {
83
+ // a copy of the real index keeps git's stat cache: only the files that changed are read again
84
+ const from = isAbsolute(real.trim()) ? real.trim() : join(root, real.trim())
85
+ if (existsSync(from)) copyFileSync(from, index)
86
+ const env = { GIT_INDEX_FILE: index }
87
+ if (git(root, ['add', '-A', ...spec], env) === null) throw new Error('git add into the world\'s throwaway index failed')
88
+ const staged = git(root, ['ls-files', '-s', ...spec], env)
89
+ if (staged === null) throw new Error('git ls-files of the world\'s throwaway index failed')
90
+ return new Bun.CryptoHasher('sha1').update(staged).digest('hex').slice(0, 12)
91
+ } finally {
92
+ rmSync(index, { force: true })
48
93
  }
49
- return h.digest('hex').slice(0, 12)
50
94
  }
51
95
 
52
96
  export interface FilmContext {
@@ -55,6 +99,8 @@ export interface FilmContext {
55
99
  world: string
56
100
  /** the project CLI's argv, null when the repo has none */
57
101
  cli: readonly string[] | null
102
+ /** a clip take that drops its picture is filmed again once, then refused (the unit's `holes`) */
103
+ holes?: boolean
58
104
  say: (line: string) => void
59
105
  }
60
106
 
@@ -64,34 +110,57 @@ export async function filmTake(tk: Take, c: FilmContext): Promise<Take> {
64
110
  const part = tk.file.replace(/(\.\w+)$/, '.part$1')
65
111
  rmSync(part, { force: true })
66
112
  const t0 = performance.now()
67
- const seconds = takeSeconds(tk.shot, tk.film)
113
+ const seconds = takeSeconds(tk.shot, tk.film, tk.spec.length)
68
114
  const f = tk.film
69
- const said = f.kind === 'url' ? await urlTake(tk, f, part, seconds, c) : await commandTake(tk, f, part, seconds, c)
70
- renameSync(part, tk.file)
71
115
  const still = takeIsStill(f)
72
- const stamp: Stamp = { world: c.world, filmedAt: new Date().toISOString(), seconds: still ? 0 : duration(tk.file), note: said.split('\n')[0]?.slice(0, 200) ?? '' }
116
+ let said = ''
117
+ for (let attempt = 1; ; attempt++) {
118
+ said = f.kind === 'url' || f.kind === 'page' ? await urlTake(tk, f, part, seconds, c) : await commandTake(tk, f, part, seconds, c)
119
+ if (still || !c.holes) break
120
+ const holes = await (await import('./holes.ts')).takeHoles(part)
121
+ if (holes.length === 0) break
122
+ const where = `the picture drops out on frames ${holes[0]} to ${holes.at(-1)}`
123
+ if (attempt >= 2) {
124
+ rmSync(part, { force: true })
125
+ throw new Error(`shot ${tk.shot.name} (line ${f.line}): ${where}, twice`)
126
+ }
127
+ c.say(` ${tk.shot.name}: ${where}, filmed again`)
128
+ }
129
+ renameSync(part, tk.file)
130
+ const states = statesOf(said)
131
+ const first = (said.split('\n')[0] ?? '').replace(/\s·\sstates \S+/, '').replace(part, '').trim()
132
+ const stamp: Stamp = {
133
+ world: tk.spec.world ?? c.world,
134
+ filmedAt: new Date().toISOString(),
135
+ seconds: still ? 0 : duration(tk.file),
136
+ note: first.slice(0, 200),
137
+ ...(states ? { states } : {}),
138
+ ...(tk.spec.world ? { fixed: true as const } : {}),
139
+ }
73
140
  writeFileSync(`${tk.file}.json`, JSON.stringify(stamp, null, 2))
74
141
  c.say(` filmed ${tk.shot.name}: ${still ? 'a still' : `${stamp.seconds.toFixed(2)} s`} in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
75
142
  return { ...tk, stamp }
76
143
  }
77
144
 
78
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 */
79
- 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> {
80
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)`)
81
- 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)
82
- const p = Bun.spawn(argv, { cwd: c.root, stdout: 'pipe', stderr: 'pipe' })
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
+ // the cut's frame and rate: a row that films at the cut's size reads them
150
+ const env = { ...process.env, SK_TRAILER_SIZE: `${c.t.size[0]}x${c.t.size[1]}`, SK_TRAILER_FPS: String(c.t.fps) }
151
+ const p = Bun.spawn(argv, { cwd: c.root, stdout: 'pipe', stderr: 'pipe', env })
83
152
  const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
84
153
  const said = `${out}${err}`.trim()
85
154
  if (code !== 0 || !existsSync(part)) {
86
155
  rmSync(part, { force: true })
87
- const shown = f.kind === 'run' ? f.cmd : [...(c.cli ?? []), ...f.argv].join(' ')
156
+ const shown = f.kind === 'run' ? f.cmd : [...(c.cli ?? []), ...(tk.argv ?? f.argv)].join(' ')
88
157
  throw new Error(`shot ${tk.shot.name} (line ${f.line}): ${shown} ${code !== 0 ? `exited ${code}` : `wrote no file at its --out`}${said ? `\n${said.split('\n').slice(-8).join('\n')}` : ''}`)
89
158
  }
90
159
  return said
91
160
  }
92
161
 
93
162
  /** a `film url` take through the house shot service; a throw names the shot, its line and the service's reason */
94
- 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> {
95
164
  const { filmUrl } = await import('./url.ts')
96
165
  try {
97
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 })
@@ -99,6 +168,6 @@ async function urlTake(tk: Take, f: Extract<TakeFilm, { kind: 'url' }>, part: st
99
168
  return note
100
169
  } catch (e) {
101
170
  rmSync(part, { force: true })
102
- 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}`)
103
172
  }
104
173
  }