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.
- package/CHANGELOG.md +18 -0
- package/package.json +2 -1
- 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_CacheAudit.ts +3 -2
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Ci.ts +4 -3
- package/src/cli/cmd_Commands.ts +158 -43
- package/src/cli/cmd_Fleet.ts +3 -1
- package/src/cli/cmd_GlobalProcesses.ts +7 -2
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Trailer.ts +108 -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 +157 -0
- package/src/cli/list-flag.ts +3 -0
- package/src/cli.ts +13 -2
- package/src/core/layout.ts +10 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/mv/mv-md.ts +2 -1
- package/src/reflect/prompt-corpus.ts +5 -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/shipkit-files.ts +8 -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 +23 -0
- package/src/trailer/build.ts +344 -0
- package/src/trailer/ffmpeg.ts +50 -0
- package/src/trailer/footage.ts +104 -0
- package/src/trailer/grammar.ts +915 -0
- package/src/trailer/graph.ts +323 -0
- package/src/trailer/load-unit.ts +14 -0
- package/src/trailer/look.ts +110 -0
- package/src/trailer/plan.ts +133 -0
- package/src/trailer/stitch.ts +150 -0
- package/src/trailer/titles.ts +274 -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 +40 -0
- 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(
|
|
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(
|
|
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
|
|
357
|
-
*
|
|
358
|
-
*
|
|
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 (
|
|
364
|
-
const line =
|
|
365
|
-
const head = line.match(
|
|
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 &&
|
|
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(
|
|
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 */
|
package/src/suite/select.ts
CHANGED
|
@@ -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 (
|
|
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}/`))
|
package/src/suite/suite-cli.ts
CHANGED
|
@@ -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: ${
|
|
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
|
+
}
|