rvlib-shipkit 2.4.0 → 2.6.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 +12 -0
- package/package.json +2 -2
- package/src/cli/cmd_Commands.ts +26 -5
- package/src/cli/cmd_FleetPush.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +5 -5
- package/src/cli/cmd_Review.ts +16 -1
- package/src/cli/cmd_Trailer.ts +4 -1
- package/src/cli/family-pages.ts +49 -0
- package/src/cli/kit/builtins.ts +1 -0
- package/src/cli/registry.ts +137 -0
- package/src/cli.ts +47 -135
- package/src/core/last-good.ts +217 -0
- package/src/core/load-probe.ts +71 -0
- package/src/core/reexec.ts +5 -3
- package/src/fleet/push-fleet.ts +80 -0
- package/src/home/home-routes.ts +12 -2
- package/src/home/lib-watch.ts +110 -20
- package/src/home/process-rows.ts +4 -2
- package/src/home/run-daemon.ts +1 -1
- package/src/review/model.ts +19 -0
- package/src/review/paste.ts +30 -0
- package/src/review/routes.ts +10 -3
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/project-scaffold.ts +2 -2
- package/src/serve/hub-app/activity-st.ts +31 -0
- package/src/serve/hub-app/activity-view.tsx +9 -0
- package/src/serve/hub-app/activity.css +6 -0
- package/src/serve/hub-app/home-strip.tsx +3 -1
- package/src/serve/hub-app/machine-st.ts +1 -1
- package/src/serve/hub-app/review/review-answer.tsx +16 -37
- package/src/serve/hub-app/review/review-page.tsx +8 -1
- package/src/serve/hub-app/review/review-st.ts +51 -3
- package/src/serve/hub-app/review/review-views.tsx +1 -1
- package/src/serve/hub-app/review.css +4 -4
- package/src/serve/run-serve.ts +25 -0
- package/src/serve/serve-hub.ts +8 -1
- package/src/serve/stale-restart.ts +19 -2
- package/src/trailer/build.ts +16 -7
- package/src/trailer/ffmpeg.ts +21 -1
- package/src/trailer/grammar.ts +56 -11
- package/src/trailer/look.ts +31 -16
- package/src/trailer/prune.ts +59 -12
- package/src/trailer/score/dsp.ts +87 -27
- package/src/trailer/score/pattern.ts +1 -1
- package/src/trailer/score/synth.ts +26 -5
- package/src/trailer/titles.ts +63 -34
- package/src/trailer/vocab.ts +65 -6
package/src/serve/run-serve.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
import { basename } from 'path'
|
|
6
6
|
import { reclaimPreviousImage } from '../core/instance.ts'
|
|
7
7
|
import { isLinkedWorktree } from '../core/git-root.ts'
|
|
8
|
+
import { heldNow, heldText, OWN_LIB, setHeld } from '../core/last-good.ts'
|
|
8
9
|
import { diskLibVersion, LIB_VERSION } from '../core/lib-version.ts'
|
|
10
|
+
import { bootEntries, probeLoad, TreeProbe } from '../core/load-probe.ts'
|
|
11
|
+
import { LibWatch } from '../home/lib-watch.ts'
|
|
12
|
+
import type { LoadVerdict } from './stale-restart.ts'
|
|
9
13
|
import { reexecBlocker, reexecSelf } from '../core/reexec.ts'
|
|
10
14
|
import { attachToHome } from '../home/attach.ts'
|
|
11
15
|
import { daemonUp, WORKER_ENV } from '../home/home-paths.ts'
|
|
@@ -21,6 +25,14 @@ export type { ServeIo }
|
|
|
21
25
|
/** how often a hub of its own looks at its plan */
|
|
22
26
|
export const PLAN_TICK_MS = 2_000
|
|
23
27
|
|
|
28
|
+
/** does the lib this hub would restart onto load: probed again after every
|
|
29
|
+
* write to its tree, not only when the version moves */
|
|
30
|
+
export function serveProbe(lib: string): { loads: () => LoadVerdict; stop: () => void } {
|
|
31
|
+
const w = new LibWatch(lib)
|
|
32
|
+
const p = new TreeProbe(() => probeLoad(lib, bootEntries(lib, 'serve')), () => `${w.seq}|${diskLibVersion()}`)
|
|
33
|
+
return { loads: () => p.loads(), stop: () => w.close() }
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
/** what a `shipkit serve` does with its hub. On a machine with a home, no
|
|
25
37
|
* hub runs outside it: a home that answers takes it, and an installed one
|
|
26
38
|
* that does not is started first. `serve` only inside the home, a linked
|
|
@@ -104,6 +116,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
104
116
|
// a file table the watch restarts leaked: it re-executes itself in
|
|
105
117
|
// place (same pid, argv, port) once no run is in flight. Only a
|
|
106
118
|
// restart that throws is said, in the hub's own status
|
|
119
|
+
const probe = serveProbe(OWN_LIB)
|
|
107
120
|
const stale = new StaleRestart({
|
|
108
121
|
boot: LIB_VERSION,
|
|
109
122
|
disk: diskLibVersion,
|
|
@@ -114,6 +127,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
114
127
|
if (blocked != null) throw new Error(blocked)
|
|
115
128
|
io.print(`ok: ${why}: restarting in place`)
|
|
116
129
|
stale.stop()
|
|
130
|
+
probe.stop()
|
|
117
131
|
clearInterval(planClock)
|
|
118
132
|
rt.stop()
|
|
119
133
|
hub.stop()
|
|
@@ -126,6 +140,16 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
126
140
|
}
|
|
127
141
|
},
|
|
128
142
|
say: (text) => deps.hubProblems?.set('stale', text == null ? null : { text, severity: 'red' }),
|
|
143
|
+
// a version move restarts only onto code that loads; the held line is the hub's (serve-hub.ts)
|
|
144
|
+
loads: () => probe.loads(),
|
|
145
|
+
hold: (v) => {
|
|
146
|
+
if (v == null) return setHeld(null)
|
|
147
|
+
const was = heldNow()
|
|
148
|
+
if (was?.file === v.file && was.error === v.error) return
|
|
149
|
+
const h = { since: was?.since ?? Date.now(), file: v.file, error: v.error, from: OWN_LIB, copyAt: null }
|
|
150
|
+
io.printErr(`WARN ${heldText(h)}`)
|
|
151
|
+
setHeld(h)
|
|
152
|
+
},
|
|
129
153
|
})
|
|
130
154
|
stale.start()
|
|
131
155
|
// a watch restart that leaked sheds it before the first run spawns anything
|
|
@@ -140,6 +164,7 @@ export async function runServe(root: string, io: ServeIo, o?: { port?: number })
|
|
|
140
164
|
for (const sig of ['SIGINT', 'SIGTERM'] as const)
|
|
141
165
|
process.on(sig, () => {
|
|
142
166
|
stale.stop()
|
|
167
|
+
probe.stop()
|
|
143
168
|
clearInterval(planClock)
|
|
144
169
|
plan.stop()
|
|
145
170
|
rt.stop()
|
package/src/serve/serve-hub.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { RUN_EXIT } from '../home/home-words.ts'
|
|
|
31
31
|
import { projectGutter } from './project-gutter-route.ts'
|
|
32
32
|
import { sameOrigin } from './same-origin.ts'
|
|
33
33
|
import { LAYOUT } from '../core/layout.ts'
|
|
34
|
+
import { heldNow, heldText } from '../core/last-good.ts'
|
|
34
35
|
import { WarmCell } from './warm-cell.ts'
|
|
35
36
|
import { readmeRoutes } from './readme-route.ts'
|
|
36
37
|
import { reviewRoutes } from '../review/routes.ts'
|
|
@@ -246,7 +247,7 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
246
247
|
collections: r.collections,
|
|
247
248
|
resources: r.resources,
|
|
248
249
|
planned: d.planned?.() ?? null,
|
|
249
|
-
hub: d.hubProblems?.list() ?? [],
|
|
250
|
+
hub: [...(d.hubProblems?.list() ?? []), ...heldProblem()],
|
|
250
251
|
})
|
|
251
252
|
}
|
|
252
253
|
// the previous address, answered while pages opened before the
|
|
@@ -427,6 +428,12 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
|
|
|
427
428
|
}
|
|
428
429
|
}
|
|
429
430
|
|
|
431
|
+
/** the machine runs older code than its tree: every hub's header says so */
|
|
432
|
+
const heldProblem = (): { id: string; text: string; severity: 'yellow' }[] => {
|
|
433
|
+
const h = heldNow()
|
|
434
|
+
return h == null ? [] : [{ id: 'last-good', text: heldText(h), severity: 'yellow' }]
|
|
435
|
+
}
|
|
436
|
+
|
|
430
437
|
export function serveHub(d: HubDeps, h: HubHandler = hubHandler(d)): Hub {
|
|
431
438
|
const server: Server = Bun.serve({
|
|
432
439
|
hostname: '127.0.0.1',
|
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
// watch fds, and a full table fails every spawn with EBADF. Either way it
|
|
8
8
|
// re-executes in place (core/reexec.ts) once no run is in flight, which is a
|
|
9
9
|
// clean table too. Only a restart that throws reaches a human, once, through
|
|
10
|
-
// `say`.
|
|
10
|
+
// `say`. With `loads`, it restarts only onto code that loads: while the tree
|
|
11
|
+
// fails, it keeps the running image and tells `hold` why.
|
|
11
12
|
import { assertLeakHealable, FD_HEALED_ENV, fdTableLeak } from '../core/fd-table.ts'
|
|
12
13
|
|
|
13
|
-
export type StaleTick = 'fresh' | 'busy' | 'restarted' | 'impossible'
|
|
14
|
+
export type StaleTick = 'fresh' | 'busy' | 'probing' | 'held' | 'restarted' | 'impossible'
|
|
15
|
+
|
|
16
|
+
/** does the code a restart would run load: `pending` while a probe runs */
|
|
17
|
+
export type LoadVerdict = 'ok' | 'pending' | { file: string; error: string }
|
|
14
18
|
|
|
15
19
|
export type StaleRestartOpts = {
|
|
16
20
|
/** the version this process booted with */
|
|
@@ -30,6 +34,10 @@ export type StaleRestartOpts = {
|
|
|
30
34
|
/** the hub's status line: a text when the restart is impossible, null
|
|
31
35
|
* once there is nothing to say */
|
|
32
36
|
say: (text: string | null) => void
|
|
37
|
+
/** asked once a restart is due, never before (core/load-probe.ts) */
|
|
38
|
+
loads?: () => LoadVerdict
|
|
39
|
+
/** why the due restart waits: a file of the tree fails to load. Null once it loads */
|
|
40
|
+
hold?: (why: { file: string; error: string } | null) => void
|
|
33
41
|
everyMs?: number
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -47,6 +55,15 @@ export class StaleRestart {
|
|
|
47
55
|
}
|
|
48
56
|
if (this.failed != null) return 'impossible'
|
|
49
57
|
if (this.o.busy()) return 'busy'
|
|
58
|
+
if (this.o.loads != null) {
|
|
59
|
+
const v = this.o.loads()
|
|
60
|
+
if (v === 'pending') return 'probing'
|
|
61
|
+
if (v !== 'ok') {
|
|
62
|
+
this.o.hold?.(v)
|
|
63
|
+
return 'held'
|
|
64
|
+
}
|
|
65
|
+
this.o.hold?.(null)
|
|
66
|
+
}
|
|
50
67
|
try {
|
|
51
68
|
if (stale.leak) {
|
|
52
69
|
assertLeakHealable()
|
package/src/trailer/build.ts
CHANGED
|
@@ -504,7 +504,7 @@ export async function scoreFile(l: Loaded, seconds: number, say: Say): Promise<s
|
|
|
504
504
|
/** everything the stitch reads but its output: sources, titles, sounds, the look */
|
|
505
505
|
async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Shot[], silent: boolean, say: Say = () => undefined) {
|
|
506
506
|
const { gridMask, splitMasks } = await import('./stitch.ts')
|
|
507
|
-
const [{ cardPng, titlePng }, { lookOf }, {
|
|
507
|
+
const [{ cardPng, titlePng }, { lookOf }, { durationOf }, { trailerCache }, { loadBrand, identityLogo }] = await Promise.all([
|
|
508
508
|
import('./titles.ts'),
|
|
509
509
|
import('./look.ts'),
|
|
510
510
|
import('./ffmpeg.ts'),
|
|
@@ -534,7 +534,7 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
|
|
|
534
534
|
if (f.kind === 'image') return { image: fileOf(f.path, l)! }
|
|
535
535
|
if (f.kind === 'clip') {
|
|
536
536
|
const file = fileOf(f.path, l)!
|
|
537
|
-
return { footage: file, seconds:
|
|
537
|
+
return { footage: file, seconds: await durationOf(file) }
|
|
538
538
|
}
|
|
539
539
|
const tk = takeOf(s)!
|
|
540
540
|
return tk.stamp!.seconds === 0 ? { image: tk.file } : { footage: tk.file, seconds: tk.stamp!.seconds }
|
|
@@ -556,11 +556,11 @@ async function stitchInput(l: Loaded, takes: readonly Take[], shots: readonly Sh
|
|
|
556
556
|
)
|
|
557
557
|
const sfx = await Promise.all(shots.map((s) => Promise.all(s.sfx.map(async (x) => {
|
|
558
558
|
const file = isFileSound(x.id, x) ? fileOf(x.id, l)! : await resolve(ask('sfx', x.id, x))
|
|
559
|
-
return { file, seconds:
|
|
559
|
+
return { file, seconds: await durationOf(file) }
|
|
560
560
|
}))))
|
|
561
561
|
const voices = await Promise.all(shots.map((s) => Promise.all(s.voices.map(async (v) => {
|
|
562
562
|
const file = v.file ? fileOf(v.file, l)! : await resolve(ask('voice', v.text ?? 'run', v))
|
|
563
|
-
return { file, seconds:
|
|
563
|
+
return { file, seconds: await durationOf(file) }
|
|
564
564
|
}))))
|
|
565
565
|
return { ...base, sources, titles: drawn, music, sfx, voices }
|
|
566
566
|
}
|
|
@@ -629,10 +629,11 @@ function shelve(l: Loaded, takes: readonly Take[]): void {
|
|
|
629
629
|
|
|
630
630
|
/** the cache trimmed after a stitch, said in one line */
|
|
631
631
|
async function pruneAfter(root: string, say: Say): Promise<void> {
|
|
632
|
-
const {
|
|
632
|
+
const { pruneIfMoved, sayPruned } = await import('./prune.ts')
|
|
633
633
|
// the cut is written: a prune that fails is said, never the build's failure
|
|
634
634
|
try {
|
|
635
|
-
|
|
635
|
+
const p = await pruneIfMoved(root)
|
|
636
|
+
for (const line of p === null ? ['cache prune skipped: nothing it reads moved since the last one'] : sayPruned(p, false, true)) say(line)
|
|
636
637
|
} catch (e) {
|
|
637
638
|
say(`WARN the cache prune failed, the cut is written: ${(e as Error).message}`)
|
|
638
639
|
}
|
|
@@ -655,7 +656,15 @@ export async function build(l: Loaded, o: BuildOptions): Promise<string> {
|
|
|
655
656
|
const done = await stitch({ ...input, t: l.t, out: cut, cache: trailerCache(l.root, 'pieces'), encoder: o.final ? 'final' : 'draft' })
|
|
656
657
|
const finished = l.t.vertical ? await finishVertical(l, input, done, cut, out, o.final) : null
|
|
657
658
|
const stripFile = join(outDir(l), `${l.t.name}.strip.png`)
|
|
658
|
-
|
|
659
|
+
// the strip is drawn from the pieces' thumbs: the same pieces draw the same strip, so a build that rendered no
|
|
660
|
+
// section keeps it
|
|
661
|
+
const stripKey = join(trailerCache(l.root, 'pieces'), `${l.t.name}.strip.key`)
|
|
662
|
+
const key = done.pieces.map((p) => p.thumbs).join('\n')
|
|
663
|
+
const drawn = (): string => `${key}\n${statSync(stripFile).mtimeMs}`
|
|
664
|
+
if (!existsSync(stripFile) || !existsSync(stripKey) || readFileSync(stripKey, 'utf8') !== drawn()) {
|
|
665
|
+
await strip(done.pieces, stripFile)
|
|
666
|
+
writeFileSync(stripKey, drawn())
|
|
667
|
+
}
|
|
659
668
|
writeFileSync(join(outDir(l), `${l.t.name}.montage.md`), montage(l.t, basename(l.file)))
|
|
660
669
|
for (const line of timeline(l, takes, world, o.cwd)) o.say(line)
|
|
661
670
|
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}` : ''}`)
|
package/src/trailer/ffmpeg.ts
CHANGED
|
@@ -1,7 +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
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { basename } from 'node:path'
|
|
6
6
|
|
|
7
7
|
const FFMPEG = Bun.which('ffmpeg') ?? '/opt/homebrew/bin/ffmpeg'
|
|
@@ -38,6 +38,26 @@ export function duration(file: string): number {
|
|
|
38
38
|
return d
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const probed = new Map<string, Promise<number>>()
|
|
42
|
+
|
|
43
|
+
/** a media file's length like `duration`, probed once per file and state in this process, the probes side by side: a
|
|
44
|
+
* cut that plays one sfx on many lines asks once */
|
|
45
|
+
export function durationOf(file: string): Promise<number> {
|
|
46
|
+
const st = statSync(file)
|
|
47
|
+
const k = `${file}\0${st.size}\0${st.mtimeMs}`
|
|
48
|
+
const had = probed.get(k)
|
|
49
|
+
if (had) return had
|
|
50
|
+
const p = (async () => {
|
|
51
|
+
const r = Bun.spawn([FFPROBE, '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { stdout: 'pipe', stderr: 'pipe' })
|
|
52
|
+
const [out, err, code] = await Promise.all([new Response(r.stdout).text(), new Response(r.stderr).text(), r.exited])
|
|
53
|
+
const d = Number(out.trim())
|
|
54
|
+
if (code !== 0 || !(d > 0)) throw new Error(`ffprobe ${file}: ${err.trim() || 'no length'}`)
|
|
55
|
+
return d
|
|
56
|
+
})()
|
|
57
|
+
probed.set(k, p)
|
|
58
|
+
return p
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
/** the media file carries a sound stream */
|
|
42
62
|
export function hasAudio(file: string): boolean {
|
|
43
63
|
const r = Bun.spawnSync([FFPROBE, '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=index', '-of', 'csv=p=0', file], { stdout: 'pipe', stderr: 'pipe' })
|
package/src/trailer/grammar.ts
CHANGED
|
@@ -167,11 +167,12 @@ export interface Title {
|
|
|
167
167
|
export type TitlePlace = 'top' | 'middle' | 'bottom'
|
|
168
168
|
export const placeOf = (ti: Pick<Title, 'place' | 'style'>): TitlePlace => ti.place ?? (ti.style === 'short' ? 'middle' : ti.style === 'progress' ? 'top' : 'bottom')
|
|
169
169
|
|
|
170
|
-
/** a colour as a line writes it: `#rgb`, `#rrggbb`, or one of the look's (titles.ts resolves it) */
|
|
170
|
+
/** a colour as a line writes it: `#rgb`, `#rrggbb`, `#rrggbbaa`, or one of the look's (titles.ts resolves it) */
|
|
171
171
|
export const LOOK_COLOURS = ['light', 'edge', 'accent'] as const
|
|
172
172
|
|
|
173
173
|
/** a title's look over its style's: its face (a font file or a Google Fonts family), weight, size in pixels of a frame
|
|
174
|
-
* 1080 tall, fill
|
|
174
|
+
* 1080 tall, fill (a gradient's colours each `colour[@share]`, top to bottom), and its outline, inner stroke, drop,
|
|
175
|
+
* shadow (and its fall) and glow, each a size in those pixels and a colour; a logo's layout (LOGO_WORDS) */
|
|
175
176
|
export interface TitleFx {
|
|
176
177
|
font?: string
|
|
177
178
|
weight?: number
|
|
@@ -179,10 +180,25 @@ export interface TitleFx {
|
|
|
179
180
|
color?: string
|
|
180
181
|
gradient?: string[]
|
|
181
182
|
outline?: { px: number; color?: string }
|
|
182
|
-
|
|
183
|
+
inner?: { px: number; color?: string }
|
|
184
|
+
drop?: { px: number; color?: string }
|
|
185
|
+
shadow?: { px: number; color?: string; dy?: number }
|
|
183
186
|
glow?: { px: number; color?: string }
|
|
187
|
+
/** the widest share of the frame's width the word takes */
|
|
188
|
+
fit?: number
|
|
189
|
+
/** the subtitle's height to the word's */
|
|
190
|
+
sub?: number
|
|
191
|
+
/** the space between the word and its subtitle, a share of the word's height */
|
|
192
|
+
gap?: number
|
|
193
|
+
/** the word's middle at this share of the frame's height (default: the whole block's middle at 0.48) */
|
|
194
|
+
middle?: number
|
|
195
|
+
/** the soft shade round the logo, its radius a share of the frame's longer side; 0 for none */
|
|
196
|
+
shade?: number
|
|
184
197
|
}
|
|
185
198
|
|
|
199
|
+
/** the look words only a logo title reads */
|
|
200
|
+
export const LOGO_WORDS = ['fit', 'sub', 'gap', 'middle', 'shade'] as const
|
|
201
|
+
|
|
186
202
|
/** how a made sound is made (design call 9): its seed, the voice or engine it is made `by`, its length (`for`), an sfx
|
|
187
203
|
* trimmed to its first `hit` */
|
|
188
204
|
export interface SoundGen {
|
|
@@ -403,8 +419,8 @@ interface Word {
|
|
|
403
419
|
|
|
404
420
|
/** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line. Only double quotes quote, as in
|
|
405
421
|
* the full language, so a word opening on a single quote is refused: it would reach a command with the quotes inside */
|
|
406
|
-
/** a hex colour at `k` (`#rgb`, `#rrggbb`, then the word's end or a
|
|
407
|
-
const colourAt = (line: string, k: number): boolean => /^#([0-9a-f]{3}|[0-9a-f]{6})(?=[\s,"]|$)/i.test(line.slice(k))
|
|
422
|
+
/** a hex colour at `k` (`#rgb`, `#rrggbb`, `#rrggbbaa`, then the word's end, a comma or a gradient place `@`): a word, never a comment */
|
|
423
|
+
const colourAt = (line: string, k: number): boolean => /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})(?=[\s,"@]|$)/i.test(line.slice(k))
|
|
408
424
|
|
|
409
425
|
export function tokens(line: string): { words: Word[] } | { error: string } {
|
|
410
426
|
const words: Word[] = []
|
|
@@ -511,9 +527,9 @@ const sizeWord = (what: string, w: Word | undefined, lo: number, hi: number): [n
|
|
|
511
527
|
return [x, y]
|
|
512
528
|
}
|
|
513
529
|
|
|
514
|
-
/** a colour word: `#rgb`, `#rrggbb`, or the look's light, edge or accent */
|
|
530
|
+
/** a colour word: `#rgb`, `#rrggbb`, `#rrggbbaa` (its last two digits the alpha), or the look's light, edge or accent */
|
|
515
531
|
const colour = (k: string, text: string | undefined): string => {
|
|
516
|
-
if (!text || !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|light|edge|accent)$/i.test(text)) return bad(`${k} wants a colour: #rgb, #rrggbb, ${LOOK_COLOURS.join(', ')}${text ? `, got ${text}` : ''}`)
|
|
532
|
+
if (!text || !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|light|edge|accent)$/i.test(text)) return bad(`${k} wants a colour: #rgb, #rrggbb, #rrggbbaa, ${LOOK_COLOURS.join(', ')}${text ? `, got ${text}` : ''}`)
|
|
517
533
|
return text
|
|
518
534
|
}
|
|
519
535
|
/** `<px>[,<colour>]`: an outline, a shadow or a glow, its size in pixels of a frame 1080 tall */
|
|
@@ -524,8 +540,19 @@ const sized = (w: Word | undefined, k: string): { px: number; color?: string } =
|
|
|
524
540
|
if (!Number.isFinite(px) || px < 0 || px > 300 || more !== undefined) return bad(`${k} wants <px>[,<colour>], its size 0 to 300, got ${w.text}`)
|
|
525
541
|
return { px, ...(c !== undefined ? { color: colour(k, c) } : {}) }
|
|
526
542
|
}
|
|
543
|
+
/** `<px>[,<colour>[,<fall>]]`: a shadow's blur, its colour and how far it falls (default a third of its blur) */
|
|
544
|
+
const shadowWord = (w: Word | undefined, k: string): { px: number; color?: string; dy?: number } => {
|
|
545
|
+
const [head, ...fall] = w && !w.quoted ? w.text.split(',') : []
|
|
546
|
+
const wrong = (): never => bad(`${k} wants <px>[,<colour>[,<fall>]], its fall 0 to 300, got ${w!.text}`)
|
|
547
|
+
if (fall.length > 2) return wrong()
|
|
548
|
+
const base = sized(w && fall.length === 2 ? { ...w, text: `${head},${fall[0]}` } : w, k)
|
|
549
|
+
if (fall.length < 2) return base
|
|
550
|
+
const dy = Number(fall[1])
|
|
551
|
+
if (!/^\d+(\.\d+)?$/.test(fall[1]!) || dy > 300) return wrong()
|
|
552
|
+
return { ...base, dy }
|
|
553
|
+
}
|
|
527
554
|
/** a title's look words, in TitleFx */
|
|
528
|
-
const FX_WORDS = ['font', 'weight', 'size', 'color', 'gradient', 'outline', 'shadow', 'glow'] as const
|
|
555
|
+
const FX_WORDS = ['font', 'weight', 'size', 'color', 'gradient', 'outline', 'inner', 'drop', 'shadow', 'glow', ...LOGO_WORDS] as const
|
|
529
556
|
const TITLE_WORDS: Record<string, (w: Word | undefined, k: string) => unknown> = {
|
|
530
557
|
style: (w, k) => word(k, w, /^[a-z][a-z0-9-]*$/),
|
|
531
558
|
at: when,
|
|
@@ -544,12 +571,23 @@ const TITLE_WORDS: Record<string, (w: Word | undefined, k: string) => unknown> =
|
|
|
544
571
|
color: (w, k) => colour(k, w && !w.quoted ? w.text : undefined),
|
|
545
572
|
gradient: (w, k) => {
|
|
546
573
|
const cs = w && !w.quoted ? w.text.split(',') : []
|
|
547
|
-
if (cs.length < 2 || cs.length > 4) bad(`${k} wants two to four colours, top to bottom: light,accent`)
|
|
548
|
-
return cs.map((c) =>
|
|
574
|
+
if (cs.length < 2 || cs.length > 4) bad(`${k} wants two to four colours, top to bottom: light,accent (a colour's place colour@0.6)`)
|
|
575
|
+
return cs.map((c) => {
|
|
576
|
+
const [col, at] = c.split('@')
|
|
577
|
+
if (at !== undefined && !/^(0(\.\d+)?|1(\.0+)?)$/.test(at)) bad(`${k}: a colour's place is a share from 0 to 1, got ${c}`)
|
|
578
|
+
return at === undefined ? colour(k, col) : `${colour(k, col)}@${at}`
|
|
579
|
+
})
|
|
549
580
|
},
|
|
550
581
|
outline: sized,
|
|
551
|
-
|
|
582
|
+
inner: sized,
|
|
583
|
+
drop: sized,
|
|
584
|
+
shadow: shadowWord,
|
|
552
585
|
glow: sized,
|
|
586
|
+
fit: (w, k) => num(k, w, 0.1, 1),
|
|
587
|
+
sub: (w, k) => num(k, w, 0.05, 1),
|
|
588
|
+
gap: (w, k) => num(k, w, 0, 1),
|
|
589
|
+
middle: (w, k) => num(k, w, 0, 1),
|
|
590
|
+
shade: (w, k) => num(k, w, 0, 2),
|
|
553
591
|
}
|
|
554
592
|
|
|
555
593
|
/** a title's words, in order, each `+<preset>` in place; `in` and `out` take a move and, when the next word is a
|
|
@@ -589,6 +627,11 @@ function readTitleWords(raw: Word[], presets: ReadonlyMap<string, Word[]>, what
|
|
|
589
627
|
/** a title's look words out of read values */
|
|
590
628
|
const fxOf = (v: Record<string, unknown>): TitleFx => Object.fromEntries(FX_WORDS.filter((k) => v[k] !== undefined).map((k) => [k, v[k]])) as TitleFx
|
|
591
629
|
const builtInStyle = (st: string): st is TitleStyle => (TITLE_STYLES as readonly string[]).includes(st)
|
|
630
|
+
/** a logo word on a title of another style fails: nothing would read it */
|
|
631
|
+
const logoOnly = (style: TitleStyle, fx: TitleFx | undefined): void => {
|
|
632
|
+
const odd = LOGO_WORDS.filter((w) => fx?.[w] !== undefined)
|
|
633
|
+
if (style !== 'logo' && odd.length > 0) bad(`title ${odd.join(', ')}: a logo's words, this title is ${style}`)
|
|
634
|
+
}
|
|
592
635
|
|
|
593
636
|
/** the brand's text styles (`trailer.styles`, each a title's words by name) laid under each title: the defaults of
|
|
594
637
|
* its built in style, then the brand style it names, then its own words, a later one winning. A brand style names
|
|
@@ -621,6 +664,7 @@ export function resolveStyles(t: Pick<Trailer, 'shots'>, styles: Readonly<Record
|
|
|
621
664
|
const under = { ...defaults, ...named }
|
|
622
665
|
ti.style = base as TitleStyle
|
|
623
666
|
const fx = { ...fxOf(under), ...ti.fx }
|
|
667
|
+
logoOnly(ti.style, fx)
|
|
624
668
|
if (Object.keys(fx).length > 0) ti.fx = fx
|
|
625
669
|
if (under.place && !ti.place) ti.place = under.place as 'top' | 'bottom'
|
|
626
670
|
if (under.in && !ti.in) ti.in = under.in as Move
|
|
@@ -1323,6 +1367,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
1323
1367
|
...(p.out ? { out: p.out as Move } : {}),
|
|
1324
1368
|
}
|
|
1325
1369
|
const fx = fxOf(p)
|
|
1370
|
+
if (!ti.named) logoOnly(ti.style, fx)
|
|
1326
1371
|
if (Object.keys(fx).length > 0) ti.fx = fx
|
|
1327
1372
|
if (ti.knock !== undefined && ti.out) bad('title: knock is its exit, give out or knock, not both')
|
|
1328
1373
|
ti.at = landAt(p.at, n, s, (v) => (ti.at = v))
|
package/src/trailer/look.ts
CHANGED
|
@@ -8,18 +8,26 @@ export interface TrailerLookInput {
|
|
|
8
8
|
light?: string
|
|
9
9
|
/** the dark edge under words over footage (default: the ground's edge) */
|
|
10
10
|
edge?: string
|
|
11
|
+
/** the soft band of shade behind a `line` title's words (default: the edge) */
|
|
12
|
+
band?: string
|
|
11
13
|
/** the key colour: the caption's dot, a `line` title's `*word*`, the corner chip (default: the palette's accent) */
|
|
12
14
|
accent?: string
|
|
13
|
-
/** the caption's plate (default: the palette's bg, an accent outline, the palette's ink)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
chip
|
|
15
|
+
/** the caption's plate (default: the palette's bg, an accent outline, the palette's ink) and the soft drop under it
|
|
16
|
+
* and under the chip (default: the edge) */
|
|
17
|
+
plate?: { fill?: string; line?: string; ink?: string; shadow?: string }
|
|
18
|
+
/** the corner chip (default: the accent, ink of the palette's bg, an outline a shade darker than its fill) */
|
|
19
|
+
chip?: { fill?: string; ink?: string; line?: string }
|
|
17
20
|
/** the face: a font file (repo relative, .ttf .otf .woff2) or a family the machine has, and its heavy weight */
|
|
18
21
|
font?: { file?: string; family?: string; weight?: number }
|
|
19
|
-
/** the logo the `logo` title draws over its words, repo relative (default: logo.png at the web root, when present)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
/** the logo the `logo` title draws over its words, repo relative (default: logo.png at the web root, when present);
|
|
23
|
+
* `false`: the word alone */
|
|
24
|
+
logo?: string | false
|
|
25
|
+
/** a `logo` title's subtitle: its colour (default: the light) and the colour of its soft glow (default: the edge) */
|
|
26
|
+
subtitle?: { color?: string; glow?: string }
|
|
27
|
+
/** the words of a short: the `short` and `progress` titles and the vertical's words, each value the look's when left
|
|
28
|
+
* out; `band` the shade behind them (default: their edge), `lit` the glow round a progress bar's newest notch
|
|
29
|
+
* (default: their accent) */
|
|
30
|
+
short?: { font?: { file?: string; family?: string; weight?: number }; light?: string; edge?: string; accent?: string; band?: string; lit?: string }
|
|
23
31
|
/** the vertical short's look (a `vertical` cut), each part `false` to leave it out (vertical.ts) */
|
|
24
32
|
vertical?: VerticalLookInput
|
|
25
33
|
/** text styles by name, each a title line's words (`style big font "Bebas Neue" in letters`): a title's `style`
|
|
@@ -44,13 +52,15 @@ export interface TrailerLook {
|
|
|
44
52
|
ground: [string, string]
|
|
45
53
|
light: string
|
|
46
54
|
edge: string
|
|
55
|
+
band: string
|
|
47
56
|
accent: string
|
|
48
|
-
plate: { fill: string; line: string; ink: string }
|
|
49
|
-
chip: { fill: string; ink: string }
|
|
57
|
+
plate: { fill: string; line: string; ink: string; shadow: string }
|
|
58
|
+
chip: { fill: string; ink: string; line: string }
|
|
50
59
|
font: { file: string | null; family: string; weight: number }
|
|
51
60
|
logo: string | null
|
|
61
|
+
subtitle: { color: string; glow: string }
|
|
52
62
|
/** the short's words: their face and colours */
|
|
53
|
-
short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string }
|
|
63
|
+
short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string; band: string; lit: string }
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
export interface BrandLike {
|
|
@@ -108,20 +118,25 @@ export function lookOf(brand: BrandLike | null, webLogo: string | null = null):
|
|
|
108
118
|
const light = i.light ?? (luminance(p.ink) > 0.45 ? p.ink : '#ffffff')
|
|
109
119
|
const edge = i.edge ?? (luminance(ground[1]) < 0.2 ? ground[1] : darker(p.bg, 0.8))
|
|
110
120
|
const accent = i.accent ?? p.accent
|
|
121
|
+
const chipFill = i.chip?.fill ?? p.accent
|
|
111
122
|
const sh = i.short ?? {}
|
|
123
|
+
const shortEdge = sh.edge ?? edge
|
|
124
|
+
const shortAccent = sh.accent ?? accent
|
|
112
125
|
const shortFont = sh.font ? { file: sh.font.file ?? null, family: sh.font.family ?? (sh.font.file ? 'TrailerShortFace' : font.family), weight: sh.font.weight ?? font.weight } : font
|
|
113
126
|
return {
|
|
114
127
|
ground,
|
|
115
128
|
light,
|
|
116
129
|
edge,
|
|
130
|
+
band: i.band ?? edge,
|
|
117
131
|
accent,
|
|
118
|
-
plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink },
|
|
119
|
-
chip: { fill:
|
|
132
|
+
plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink, shadow: i.plate?.shadow ?? alpha(edge, 0.4) },
|
|
133
|
+
chip: { fill: chipFill, ink: i.chip?.ink ?? (luminance(p.accent) > 0.4 ? darker(p.bg, luminance(p.bg) > 0.5 ? 0.85 : 0.2) : '#ffffff'), line: i.chip?.line ?? darker(chipFill, 0.15) },
|
|
120
134
|
font: { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 },
|
|
121
|
-
logo: i.logo ?? webLogo,
|
|
122
|
-
|
|
135
|
+
logo: i.logo === false ? null : (i.logo ?? webLogo),
|
|
136
|
+
subtitle: { color: i.subtitle?.color ?? light, glow: i.subtitle?.glow ?? edge },
|
|
137
|
+
short: { font: shortFont, light: sh.light ?? light, edge: shortEdge, accent: shortAccent, band: sh.band ?? shortEdge, lit: sh.lit ?? shortAccent },
|
|
123
138
|
}
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
/** the look a short's words wear: the look with the short's face and colours in place */
|
|
127
|
-
export const shortLook = (look: TrailerLook): TrailerLook => ({ ...look, font: look.short.font, light: look.short.light, edge: look.short.edge, accent: look.short.accent })
|
|
142
|
+
export const shortLook = (look: TrailerLook): TrailerLook => ({ ...look, font: look.short.font, light: look.short.light, edge: look.short.edge, accent: look.short.accent, band: look.short.band })
|
package/src/trailer/prune.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// the trailer's cache kept small (wiki build/trailer.md, The cache): what the cuts of every trailer folder still use
|
|
2
|
-
// stays, the rest goes. Runs after
|
|
3
|
-
// build's own key functions; a file younger than an hour is never deleted: another build may
|
|
4
|
-
|
|
2
|
+
// stays, the rest goes. Runs after a stitch when what it reads moved, and by hand (`sk trailer prune [--dry]`). What a
|
|
3
|
+
// cut uses is named by the build's own key functions; a file younger than an hour is never deleted: another build may
|
|
4
|
+
// be writing or reading it
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
5
6
|
import { basename, join, relative } from 'node:path'
|
|
6
7
|
import { cutFiles, load } from './build.ts'
|
|
7
8
|
import { SCORE, musicCues } from './grammar.ts'
|
|
@@ -159,12 +160,10 @@ function filesUnder(dir: string): string[] {
|
|
|
159
160
|
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? filesUnder(join(dir, e.name)) : [join(dir, e.name)]))
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const cache = join(trailerCache(root, 'footage'), '..')
|
|
167
|
-
const live = await liveSet(root)
|
|
163
|
+
const cacheOf = async (root: string): Promise<string> => join((await import('./footage.ts')).trailerCache(root, 'footage'), '..')
|
|
164
|
+
|
|
165
|
+
/** each folder's files that go, as of `now`; `now` Infinity names the files only their age keeps too */
|
|
166
|
+
function planOf(cache: string, live: Live, views: readonly string[], now: number): [string, Doomed[]][] {
|
|
168
167
|
const whole = live.broken.length === 0
|
|
169
168
|
// titles/ holds the vertical's drawings too: only a title's and a card's files are the prune's
|
|
170
169
|
const ownTitle = (n: string): boolean => /^(title|card)-[0-9a-f]{16}\./.test(n)
|
|
@@ -177,10 +176,58 @@ export async function prune(root: string, o: { dry: boolean; now?: number }): Pr
|
|
|
177
176
|
]
|
|
178
177
|
const dead = existsSync(cache) ? readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory() && !FOLDERS.includes(e.name)) : []
|
|
179
178
|
for (const d of dead) plan.push([`${d.name}/ (dead)`, filesUnder(join(cache, d.name)).map(doomed)])
|
|
180
|
-
plan.push(['trailer-view/',
|
|
181
|
-
|
|
179
|
+
plan.push(['trailer-view/', views.filter((f) => existsSync(f) && now - statSync(f).mtimeMs > RENDER_GRACE_MS).map(doomed)])
|
|
180
|
+
return plan
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** what the last whole prune read, and when the first file it spared for its age comes of age */
|
|
184
|
+
interface Stamp {
|
|
185
|
+
key: string
|
|
186
|
+
until: number
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const stampFile = (cache: string): string => join(cache, 'prune.json')
|
|
190
|
+
|
|
191
|
+
/** what the prune reads that a build can change: every cut's text, every file name in the cache's folders, the views
|
|
192
|
+
* to drop. The same key and no spared file of age: the prune would find what it found */
|
|
193
|
+
async function pruneKey(root: string, cache: string): Promise<string> {
|
|
194
|
+
const [{ cutFiles }, { viewsToDrop }] = await Promise.all([import('./build.ts'), import('../review/trailer-desk.ts')])
|
|
195
|
+
const h = new Bun.CryptoHasher('sha1')
|
|
196
|
+
for (const f of cutFiles(root)) h.update(`${f}\0`).update(readFileSync(f)).update('\0')
|
|
197
|
+
const folders = existsSync(cache) ? readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory()) : []
|
|
198
|
+
for (const d of folders.map((e) => e.name).sort()) h.update(`${d}/\0${filesUnder(join(cache, d)).sort().join('\0')}\0`)
|
|
199
|
+
h.update(viewsToDrop(root).sort().join('\0'))
|
|
200
|
+
return h.digest('hex')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** the prune after a build: skipped while nothing it reads moved and no file it spared has come of age */
|
|
204
|
+
export async function pruneIfMoved(root: string, now = Date.now()): Promise<Pruned | null> {
|
|
205
|
+
const cache = await cacheOf(root)
|
|
206
|
+
let s: Stamp | null = null
|
|
207
|
+
try {
|
|
208
|
+
s = JSON.parse(readFileSync(stampFile(cache), 'utf8')) as Stamp
|
|
209
|
+
} catch {}
|
|
210
|
+
if (s !== null && now < s.until && s.key === (await pruneKey(root, cache))) return null
|
|
211
|
+
return prune(root, { dry: false, now })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** plans every folder's prune, then deletes unless `dry`; a whole prune writes its stamp */
|
|
215
|
+
export async function prune(root: string, o: { dry: boolean; now?: number }): Promise<Pruned> {
|
|
216
|
+
const now = o.now ?? Date.now()
|
|
217
|
+
const { viewsToDrop } = await import('../review/trailer-desk.ts')
|
|
218
|
+
const cache = await cacheOf(root)
|
|
219
|
+
const live = await liveSet(root)
|
|
220
|
+
const views = viewsToDrop(root)
|
|
221
|
+
const plan = planOf(cache, live, views, now)
|
|
222
|
+
if (!o.dry && existsSync(cache)) {
|
|
182
223
|
for (const [, ds] of plan) for (const d of ds) rmSync(d.file, { force: true })
|
|
183
|
-
for (const d of
|
|
224
|
+
for (const d of readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory() && !FOLDERS.includes(e.name))) rmSync(join(cache, d.name), { recursive: true, force: true })
|
|
225
|
+
rmSync(stampFile(cache), { force: true })
|
|
226
|
+
if (live.broken.length === 0) {
|
|
227
|
+
const spared = planOf(cache, live, views, Number.POSITIVE_INFINITY).flatMap(([, ds]) => ds.filter((d) => existsSync(d.file)))
|
|
228
|
+
const until = Math.min(Number.MAX_SAFE_INTEGER, ...spared.map((d) => statSync(d.file).mtimeMs + RENDER_GRACE_MS))
|
|
229
|
+
writeFileSync(stampFile(cache), JSON.stringify({ key: await pruneKey(root, cache), until } satisfies Stamp))
|
|
230
|
+
}
|
|
184
231
|
}
|
|
185
232
|
return {
|
|
186
233
|
freed: plan.map(([folder, ds]) => ({ folder, files: ds.length, bytes: ds.reduce((n, d) => n + (d.linked ? 0 : d.bytes), 0), linked: ds.reduce((n, d) => n + (d.linked ? d.bytes : 0), 0) })),
|