rvlib-shipkit 2.1.1 → 2.3.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.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,72 @@
|
|
|
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 { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { basename } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const FFMPEG = Bun.which('ffmpeg') ?? '/opt/homebrew/bin/ffmpeg'
|
|
8
|
+
const FFPROBE = Bun.which('ffprobe') ?? '/opt/homebrew/bin/ffprobe'
|
|
9
|
+
|
|
10
|
+
/** ffmpeg and ffprobe on this machine, or what to install */
|
|
11
|
+
export function ffmpegMissing(): string | null {
|
|
12
|
+
for (const [name, bin] of [['ffmpeg', FFMPEG], ['ffprobe', FFPROBE]] as const) if (!Bun.which(bin)) return `${name} not found: brew install ffmpeg`
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const niced = (cmd: string[], nice?: number): string[] => (nice ? ['nice', '-n', String(nice), ...cmd] : cmd)
|
|
17
|
+
|
|
18
|
+
const failure = (args: readonly string[], err: string, code: number | null): Error =>
|
|
19
|
+
new Error(`ffmpeg → ${basename(args.at(-1) ?? '')}: ${err.trim().split('\n').slice(-6).join('\n') || `exit ${code}`}`)
|
|
20
|
+
|
|
21
|
+
/** ffmpeg with `args` after `-hide_banner -nostdin -y -loglevel error`; resolves to its stderr, rejects with it */
|
|
22
|
+
export function ffmpeg(args: readonly string[], o: { nice?: number } = {}): Promise<string> {
|
|
23
|
+
return new Promise((ok, fail) => {
|
|
24
|
+
const cmd = niced([FFMPEG, '-hide_banner', '-nostdin', '-y', '-loglevel', 'error', ...args], o.nice)
|
|
25
|
+
const p = spawn(cmd[0]!, cmd.slice(1), { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
26
|
+
let err = ''
|
|
27
|
+
p.stderr.on('data', (d: Buffer) => (err += d.toString()))
|
|
28
|
+
p.on('error', (e) => fail(new Error(`${FFMPEG}: ${e.message}; brew install ffmpeg`)))
|
|
29
|
+
p.on('close', (code) => (code === 0 ? ok(err) : fail(failure(args, err, code))))
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** one ffprobe of a media file: its length in seconds; throws when ffprobe cannot read it or finds no length */
|
|
34
|
+
export function duration(file: string): number {
|
|
35
|
+
const r = Bun.spawnSync([FFPROBE, '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { stdout: 'pipe', stderr: 'pipe' })
|
|
36
|
+
const d = Number(r.stdout.toString().trim())
|
|
37
|
+
if (r.exitCode !== 0 || !(d > 0)) throw new Error(`ffprobe ${file}: ${r.stderr.toString().trim() || 'no length'}`)
|
|
38
|
+
return d
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** the media file carries a sound stream */
|
|
42
|
+
export function hasAudio(file: string): boolean {
|
|
43
|
+
const r = Bun.spawnSync([FFPROBE, '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', file], { stdout: 'pipe', stderr: 'pipe' })
|
|
44
|
+
if (r.exitCode !== 0) throw new Error(`ffprobe ${file}: ${r.stderr.toString().trim() || `exit ${r.exitCode}`}`)
|
|
45
|
+
return r.stdout.toString().trim() !== ''
|
|
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
|
+
|
|
69
|
+
/** nine frames of a clip evenly spaced, three by three on one png: how a shot is judged */
|
|
70
|
+
export async function sheetOf(clip: string, seconds: number, png: string): Promise<void> {
|
|
71
|
+
await ffmpeg(['-i', clip, '-vf', `fps=${(9 / seconds).toFixed(4)},scale=480:-2,tile=3x3`, '-frames:v', '1', png])
|
|
72
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
// `.shipkit/cache/trailer/footage/<key>.<ext>`, a stamp beside it naming the world it was filmed on. A take of an older
|
|
3
|
+
// world is reported, and filmed again only on --refilm. Written aside then renamed, two at once
|
|
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'
|
|
8
|
+
import { duration } from './ffmpeg.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'
|
|
12
|
+
|
|
13
|
+
export const trailerCache = (root: string, part: 'footage' | 'pieces' | 'titles' | 'sound' | 'score'): string => join(root, '.shipkit', 'cache', 'trailer', part)
|
|
14
|
+
|
|
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[] {
|
|
52
|
+
const dir = trailerCache(root, 'footage')
|
|
53
|
+
return [...t.shots, ...t.footage].flatMap((shot) => {
|
|
54
|
+
const film = shot.film
|
|
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))
|
|
58
|
+
const stampFile = `${file}.json`
|
|
59
|
+
const stamp = existsSync(file) && existsSync(stampFile) ? (JSON.parse(readFileSync(stampFile, 'utf8')) as Stamp) : null
|
|
60
|
+
return [{ shot, film, file, stamp, spec, ...(argv ? { argv } : {}) }]
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
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
|
|
68
|
+
}
|
|
69
|
+
|
|
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 })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface FilmContext {
|
|
97
|
+
root: string
|
|
98
|
+
t: Pick<Trailer, 'size' | 'fps' | 'base'>
|
|
99
|
+
world: string
|
|
100
|
+
/** the project CLI's argv, null when the repo has none */
|
|
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
|
|
104
|
+
say: (line: string) => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** film one take; a failure throws with the shot, its line and what the command said */
|
|
108
|
+
export async function filmTake(tk: Take, c: FilmContext): Promise<Take> {
|
|
109
|
+
mkdirSync(trailerCache(c.root, 'footage'), { recursive: true })
|
|
110
|
+
const part = tk.file.replace(/(\.\w+)$/, '.part$1')
|
|
111
|
+
rmSync(part, { force: true })
|
|
112
|
+
const t0 = performance.now()
|
|
113
|
+
const seconds = takeSeconds(tk.shot, tk.film, tk.spec.length)
|
|
114
|
+
const f = tk.film
|
|
115
|
+
const still = takeIsStill(f)
|
|
116
|
+
let said = ''
|
|
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)
|
|
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
|
+
}
|
|
140
|
+
writeFileSync(`${tk.file}.json`, JSON.stringify(stamp, null, 2))
|
|
141
|
+
c.say(` filmed ${tk.shot.name}: ${still ? 'a still' : `${stamp.seconds.toFixed(2)} s`} in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
|
|
142
|
+
return { ...tk, stamp }
|
|
143
|
+
}
|
|
144
|
+
|
|
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> {
|
|
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
|
+
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 })
|
|
152
|
+
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
153
|
+
const said = `${out}${err}`.trim()
|
|
154
|
+
if (code !== 0 || !existsSync(part)) {
|
|
155
|
+
rmSync(part, { force: true })
|
|
156
|
+
const shown = f.kind === 'run' ? f.cmd : [...(c.cli ?? []), ...(tk.argv ?? f.argv)].join(' ')
|
|
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')}` : ''}`)
|
|
158
|
+
}
|
|
159
|
+
return said
|
|
160
|
+
}
|
|
161
|
+
|
|
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> {
|
|
164
|
+
const { filmUrl } = await import('./url.ts')
|
|
165
|
+
try {
|
|
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 })
|
|
167
|
+
if (!existsSync(part)) throw new Error('the shot service wrote no file')
|
|
168
|
+
return note
|
|
169
|
+
} catch (e) {
|
|
170
|
+
rmSync(part, { force: true })
|
|
171
|
+
throw new Error(`shot ${tk.shot.name} (line ${f.line}): film url ${f.route}: ${(e as Error).message}`)
|
|
172
|
+
}
|
|
173
|
+
}
|