rvlib-shipkit 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/serve-hub.ts +7 -0
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +170 -29
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// THE one way a trailer makes a sound: a command that writes `{out}`, cached on its words and the content of each file
|
|
2
|
+
// it reads. A voice, an sfx, a music bed and a MIDI file are presets of it (presets.ts). The cache is
|
|
3
|
+
// `.shipkit/cache/sound/<key>.wav` with `<key>.json` (the command, its measures) and `<key>.png` (its waveform), out of
|
|
4
|
+
// the trailer's cache so a trailer prune never deletes a paid line. One sound is made at a time, under nice, never
|
|
5
|
+
// while the machine's load is over 16
|
|
6
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs'
|
|
7
|
+
import { loadavg } from 'node:os'
|
|
8
|
+
import { join, relative, resolve } from 'node:path'
|
|
9
|
+
import { ffmpeg } from '../ffmpeg.ts'
|
|
10
|
+
import type { SoundRun } from '../grammar.ts'
|
|
11
|
+
import { measure, measureLine, SOUND_RATE, waveformPng } from './audio.ts'
|
|
12
|
+
|
|
13
|
+
/** the folder every made sound of the repo is kept in */
|
|
14
|
+
export const soundCache = (root: string): string => join(root, '.shipkit', 'cache', 'sound')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
export interface RunPlace {
|
|
18
|
+
root: string
|
|
19
|
+
/** the .trailer's folder, where a `reads` file is looked for first */
|
|
20
|
+
dir: string
|
|
21
|
+
/** a music cue's length: `{seconds}` is it rounded up to 10 s */
|
|
22
|
+
seconds?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** a music cue's seconds as the command reads them: rounded up to 10, so a small edit makes nothing again */
|
|
26
|
+
export const runSeconds = (s: number | undefined): number => Math.max(10, Math.ceil((s ?? 0) / 10) * 10)
|
|
27
|
+
|
|
28
|
+
const q = (s: string): string => `'${s.replaceAll("'", "'\\''")}'`
|
|
29
|
+
|
|
30
|
+
const escape = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
31
|
+
|
|
32
|
+
/** the command with `{seconds}` filled, each read file found (beside the .trailer first, then from the repo root) and
|
|
33
|
+
* named in the command by its repo relative path, as a film's `@<file>` is: the command, run at the root, reads the
|
|
34
|
+
* file the key hashed. Then the key and the cache file */
|
|
35
|
+
export function runSpec(r: SoundRun, p: RunPlace): { cmd: string; reads: { path: string; abs: string }[]; key: string; file: string } {
|
|
36
|
+
let cmd = r.cmd.replaceAll('{seconds}', String(runSeconds(p.seconds)))
|
|
37
|
+
if (!cmd.includes('{out}')) throw new Error(`a sound run writes {out}: ${r.cmd}`)
|
|
38
|
+
const reads = (r.reads ?? []).map((given) => {
|
|
39
|
+
const abs = [resolve(p.dir, given), resolve(p.root, given)].find((f) => existsSync(f))
|
|
40
|
+
if (!abs) throw new Error(`sound run reads ${given}: no such file beside the .trailer or from the repo root`)
|
|
41
|
+
const rel = relative(p.root, abs)
|
|
42
|
+
const path = rel.startsWith('..') ? abs : rel
|
|
43
|
+
if (path !== given) cmd = cmd.replace(new RegExp(`(^|[\\s'"=])${escape(given)}(?=$|[\\s'"])`, 'g'), (_, before: string) => `${before}${path}`)
|
|
44
|
+
return { path, abs }
|
|
45
|
+
})
|
|
46
|
+
const h = new Bun.CryptoHasher('sha1').update(cmd)
|
|
47
|
+
for (const x of reads) h.update(`\0${x.path}\0`).update(readFileSync(x.abs))
|
|
48
|
+
const key = h.digest('hex').slice(0, 16)
|
|
49
|
+
return { cmd, reads, key, file: join(soundCache(p.root), `${key}.wav`) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
53
|
+
|
|
54
|
+
/** the sound's file, made when the cache misses: the command run in the repo root, its output kept as a 48 kHz wav */
|
|
55
|
+
export function soundRun(r: SoundRun, p: RunPlace, say: (line: string) => void): Promise<{ file: string; made: boolean }> {
|
|
56
|
+
const spec = runSpec(r, p)
|
|
57
|
+
if (existsSync(spec.file)) return Promise.resolve({ file: spec.file, made: false })
|
|
58
|
+
const job = queue.then(() => make(spec, p.root, say))
|
|
59
|
+
queue = job.catch(() => undefined)
|
|
60
|
+
return job
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** the process holding a lock file: alive, or the lock is stale */
|
|
64
|
+
function holderAlive(lock: string): boolean {
|
|
65
|
+
let pid = 0
|
|
66
|
+
try {
|
|
67
|
+
pid = Number(readFileSync(lock, 'utf8').trim())
|
|
68
|
+
} catch {
|
|
69
|
+
return true // written this instant: its pid is not in it yet
|
|
70
|
+
}
|
|
71
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
72
|
+
try {
|
|
73
|
+
process.kill(pid, 0)
|
|
74
|
+
return true
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return (e as NodeJS.ErrnoException).code === 'EPERM'
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `<key>.lock` beside the sound, across processes: taken with O_EXCL, its pid inside; a lock of a dead process is
|
|
81
|
+
* taken over. Null when the sound was made while this one waited */
|
|
82
|
+
async function lockSound(spec: ReturnType<typeof runSpec>, say: (line: string) => void): Promise<(() => void) | null> {
|
|
83
|
+
const lock = `${spec.file.slice(0, -4)}.lock`
|
|
84
|
+
for (let said = false; ; ) {
|
|
85
|
+
if (existsSync(spec.file)) return null
|
|
86
|
+
try {
|
|
87
|
+
const fd = openSync(lock, 'wx')
|
|
88
|
+
writeSync(fd, String(process.pid))
|
|
89
|
+
closeSync(fd)
|
|
90
|
+
if (existsSync(spec.file)) {
|
|
91
|
+
rmSync(lock, { force: true })
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
return () => rmSync(lock, { force: true })
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
97
|
+
}
|
|
98
|
+
if (!holderAlive(lock)) {
|
|
99
|
+
rmSync(lock, { force: true })
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (!said) say(`sound: ${spec.key} is being made by another process: waiting for it`)
|
|
103
|
+
said = true
|
|
104
|
+
await Bun.sleep(200)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function make(spec: ReturnType<typeof runSpec>, root: string, say: (line: string) => void): Promise<{ file: string; made: boolean }> {
|
|
109
|
+
mkdirSync(soundCache(root), { recursive: true })
|
|
110
|
+
const unlock = await lockSound(spec, say)
|
|
111
|
+
if (!unlock) return { file: spec.file, made: false }
|
|
112
|
+
try {
|
|
113
|
+
return await makeLocked(spec, root, say)
|
|
114
|
+
} finally {
|
|
115
|
+
unlock()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** a command that has not answered in this long is killed, loud: a hung box never stalls a build */
|
|
120
|
+
const runTimeoutS = (): number => Number(process.env.SK_SOUND_TIMEOUT_S ?? 30 * 60)
|
|
121
|
+
/** a `.make-*` folder older than this was left by a killed process */
|
|
122
|
+
const STALE_MAKE_MS = 60 * 60 * 1000
|
|
123
|
+
|
|
124
|
+
/** a process group ended: TERM, then KILL 5 s later */
|
|
125
|
+
export function killGroup(pid: number): void {
|
|
126
|
+
const kill = (sig: NodeJS.Signals): void => {
|
|
127
|
+
try {
|
|
128
|
+
process.kill(-pid, sig)
|
|
129
|
+
} catch {
|
|
130
|
+
// already gone
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
kill('SIGTERM')
|
|
134
|
+
setTimeout(() => kill('SIGKILL'), 5000).unref()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const prepared = new Map<string, Promise<void>>()
|
|
138
|
+
|
|
139
|
+
/** once per process and repo, before the first sound it makes: the folders killed makes left swept, then a wait while
|
|
140
|
+
* the machine's load is over 16 */
|
|
141
|
+
async function prepare(root: string, say: (line: string) => void): Promise<void> {
|
|
142
|
+
const cache = soundCache(root)
|
|
143
|
+
for (const e of readdirSync(cache)) {
|
|
144
|
+
const f = join(cache, e)
|
|
145
|
+
if (e.startsWith('.make-') && Date.now() - statSync(f).mtimeMs > STALE_MAKE_MS) rmSync(f, { recursive: true, force: true })
|
|
146
|
+
}
|
|
147
|
+
for (let waited = 0; loadavg()[0]! > 16; waited += 5) {
|
|
148
|
+
if (waited === 0) say(`sound: the load is ${loadavg()[0]!.toFixed(1)}, over 16: waiting before the first sound`)
|
|
149
|
+
if (waited > 600) throw new Error('sound: the load stayed over 16 for 10 minutes')
|
|
150
|
+
await Bun.sleep(5000)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function makeLocked(spec: ReturnType<typeof runSpec>, root: string, say: (line: string) => void): Promise<{ file: string; made: boolean }> {
|
|
155
|
+
let ready = prepared.get(root)
|
|
156
|
+
if (!ready) prepared.set(root, (ready = prepare(root, say)))
|
|
157
|
+
await ready
|
|
158
|
+
const dir = mkdtempSync(join(soundCache(root), '.make-'))
|
|
159
|
+
try {
|
|
160
|
+
const out = join(dir, 'out.wav')
|
|
161
|
+
say(`sound: making ${spec.key}: ${spec.cmd.length > 160 ? `${spec.cmd.slice(0, 157)}…` : spec.cmd}`)
|
|
162
|
+
const t0 = performance.now()
|
|
163
|
+
// its own process group: a timeout kills the command and every child holding its pipes
|
|
164
|
+
const proc = Bun.spawn(['nice', '-n', '15', 'bash', '-c', spec.cmd.replaceAll('{out}', q(out))], { cwd: root, stdout: 'pipe', stderr: 'pipe', detached: true })
|
|
165
|
+
let timedOut = false
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
timedOut = true
|
|
168
|
+
killGroup(proc.pid)
|
|
169
|
+
}, runTimeoutS() * 1000)
|
|
170
|
+
const [text, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]).finally(() => clearTimeout(timer))
|
|
171
|
+
if (timedOut) throw new Error(`sound ${spec.key} did not answer in ${runTimeoutS()} s and was killed: ${spec.cmd}`)
|
|
172
|
+
for (const l of text.split('\n')) if (/^(ok|ElevenLabs|WARN|designing)/.test(l)) say(` ${l}`)
|
|
173
|
+
if (code !== 0 || !existsSync(out)) {
|
|
174
|
+
const tail = `${err}\n${text}`.trim().split('\n').slice(-4).join('\n ')
|
|
175
|
+
throw new Error(`sound ${spec.key} ${code !== 0 ? `exited ${code}` : 'wrote no {out}'}: ${spec.cmd}\n ${tail}`)
|
|
176
|
+
}
|
|
177
|
+
const wav = join(dir, 'kept.wav')
|
|
178
|
+
await ffmpeg(['-y', '-v', 'error', '-i', out, '-ac', '2', '-ar', String(SOUND_RATE), '-c:a', 'pcm_s16le', wav])
|
|
179
|
+
const m = await measure(wav)
|
|
180
|
+
if (existsSync(`${out}.png`)) renameSync(`${out}.png`, `${spec.file.slice(0, -4)}.png`)
|
|
181
|
+
else await waveformPng(wav, `${spec.file.slice(0, -4)}.png`)
|
|
182
|
+
writeFileSync(`${spec.file.slice(0, -4)}.json`, `${JSON.stringify({ cmd: spec.cmd, reads: spec.reads.map((x) => x.path), made: new Date().toISOString(), ms: Math.round(performance.now() - t0), measures: m }, null, 1)}\n`)
|
|
183
|
+
renameSync(wav, spec.file)
|
|
184
|
+
say(`sound: ${relative(root, spec.file)} in ${((performance.now() - t0) / 1000).toFixed(1)} s: ${measureLine(m)}`)
|
|
185
|
+
return { file: spec.file, made: true }
|
|
186
|
+
} finally {
|
|
187
|
+
rmSync(dir, { recursive: true, force: true })
|
|
188
|
+
}
|
|
189
|
+
}
|