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.
Files changed (80) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. 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
+ }