rvlib-shipkit 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -1,16 +1,13 @@
1
1
  // what a build does, as data: each shot's take and its key, which takes film, the pieces the stitch renders. Pure: the
2
2
  // io (stamps on disk, the world's git stamp, ffmpeg) lives in footage.ts and stitch.ts, which read these answers
3
- import type { Film, Shot, Trailer } from './grammar.ts'
3
+ import type { Film, Shot, TakeLength, Trailer } from './grammar.ts'
4
4
  import { starts } from './grammar.ts'
5
5
 
6
- /** bump when the core films differently from the same line: every core take films again */
7
- export const FILM_VERSION = 1
6
+ /** bump when the core films differently from the same line: every take films again */
7
+ export const FILM_VERSION = 2
8
8
 
9
- /**
10
- * the key of a take: what films, never how the cut uses it. The formula of pkm-multi's `footageKey`, verbatim, so a
11
- * take filmed there under the same version, recipe, size, fps and seconds keeps its file
12
- */
13
- export function footageKey(version: number | readonly number[], recipe: unknown, size: readonly [number, number], fps: number, seconds: number | null): string {
9
+ /** the key of a take: what films (its version, its recipe, the frame, the rate, its seconds), never how the cut uses it */
10
+ export function footageKey(version: unknown, recipe: unknown, size: readonly [number, number], fps: number, seconds: number | null): string {
14
11
  return new Bun.CryptoHasher('sha1').update(JSON.stringify([version, recipe, size, fps, seconds])).digest('hex').slice(0, 12)
15
12
  }
16
13
 
@@ -21,20 +18,70 @@ export const isTake = (f: Film | null): f is TakeFilm => f?.kind === 'run' || f?
21
18
  /** the take is one picture, held for the shot */
22
19
  export const takeIsStill = (f: TakeFilm): boolean => (f.kind === 'cli' ? f.output === 'still' : f.still === true)
23
20
 
24
- /** the seconds a clip take films: what the shot shows, rounded up to 2 s, so a small edit never films again; a still: none */
25
- export function takeSeconds(s: Pick<Shot, 'from' | 'length'>, f: TakeFilm): number | null {
26
- return takeIsStill(f) ? null : Math.ceil((s.from + s.length) / 2) * 2
21
+ /** the seconds a clip take films: what the shot shows rounded up to 2 s (so a small edit never films again), exactly
22
+ * that, or none when it films as long as it plays; a still: none */
23
+ export function takeSeconds(s: Pick<Shot, 'from' | 'length'>, f: TakeFilm, length: TakeLength = defaultLength(f)): number | null {
24
+ if (takeIsStill(f) || length === 'own') return null
25
+ return length === 'exact' ? +(s.from + s.length).toFixed(3) : Math.ceil((s.from + s.length) / 2) * 2
27
26
  }
28
27
 
29
- /** the film line as its key reads it: the line it sits on is never part of what films */
30
- export function recipeOf(f: TakeFilm): Omit<TakeFilm, 'line'> {
28
+ const defaultLength = (f: TakeFilm): TakeLength => (f.kind === 'cli' && f.length === 'own' ? 'own' : 'steps')
29
+
30
+ /** the film line as its key reads it: the line it sits on is never part of what films, nor its row's marks (the
31
+ * spec's version and length key them) */
32
+ export function recipeOf(f: TakeFilm): unknown {
33
+ if (f.kind === 'cli') {
34
+ const { line: _l, version: _v, length: _n, ...recipe } = f
35
+ return recipe
36
+ }
31
37
  const { line: _line, ...recipe } = f
32
38
  return recipe
33
39
  }
34
40
 
41
+ /** what one take is, past its words: its version, how long it films, a world of its own, what its key reads. A row
42
+ * gives the defaults, the repo's trailer unit refines one line (adapter.ts `take`) */
43
+ export interface TakeSpec {
44
+ version: number | string | null
45
+ length: TakeLength
46
+ /** a fixed world (`rev:<commit>`): the take is never stale */
47
+ world: string | null
48
+ /** the paths its world is, when its row names them (over the unit's `stamp`) */
49
+ stamp?: string[]
50
+ recipe: unknown
51
+ }
52
+
53
+ /** a recipe file's text as its key reads it: comment lines (`#` first), trailing comments (a space, `#`, then a space or
54
+ * the line's end, outside double quotes) and blank lines left out; a `#` inside a value (`#ff0000`) is content */
55
+ export function recipeText(text: string): string {
56
+ return text
57
+ .split('\n')
58
+ .map((l) => {
59
+ if (l.trimStart().startsWith('#')) return ''
60
+ let q = false
61
+ for (let i = 0; i < l.length; i++) {
62
+ if (l[i] === '"') q = !q
63
+ else if (l[i] === '#' && !q && /\s/.test(l[i - 1] ?? '') && /^(\s|$)/.test(l.slice(i + 1, i + 2))) return l.slice(0, i).trim()
64
+ }
65
+ return l.trim()
66
+ })
67
+ .filter(Boolean)
68
+ .join('\n')
69
+ }
70
+
71
+ /** the `@<file>` words of a film line */
72
+ export const recipeWords = (argv: readonly string[]): string[] => argv.filter((a) => /^@\S/.test(a))
73
+
74
+ /** a take's spec from its row alone; `files` the text of each `@<file>` word, as recipeText reads it */
75
+ export function specOf(f: TakeFilm, files: Readonly<Record<string, string>> = {}): TakeSpec {
76
+ const read = Object.keys(files).length > 0 ? { files: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, new Bun.CryptoHasher('sha1').update(v).digest('hex').slice(0, 12)])) } : {}
77
+ return { version: f.kind === 'cli' ? (f.version ?? null) : null, length: defaultLength(f), world: null, recipe: { ...(recipeOf(f) as object), ...read }, ...(f.kind === 'cli' && f.stamp ? { stamp: f.stamp } : {}) }
78
+ }
79
+
35
80
  /** the take's file name, by its key alone: shots on one film line, in one cut or in several, share one file */
36
- export function takeName(s: Pick<Shot, 'from' | 'length'>, f: TakeFilm, t: Pick<Trailer, 'size' | 'fps'>): string {
37
- return `${footageKey(FILM_VERSION, recipeOf(f), t.size, t.fps, takeSeconds(s, f))}.${takeIsStill(f) ? 'png' : 'mp4'}`
81
+ export function takeName(s: Pick<Shot, 'from' | 'length'>, f: TakeFilm, t: Pick<Trailer, 'size' | 'fps'>, spec: TakeSpec = specOf(f)): string {
82
+ // a fixed world is what films: two past commits are two takes, whatever the recipe keeps of their words
83
+ const recipe = spec.world === null ? spec.recipe : { world: spec.world, recipe: spec.recipe }
84
+ return `${footageKey([FILM_VERSION, spec.version ?? 0], recipe, t.size, t.fps, takeSeconds(s, f, spec.length))}.${takeIsStill(f) ? 'png' : 'mp4'}`
38
85
  }
39
86
 
40
87
  /** the world a take was filmed on, beside it */
@@ -46,9 +93,13 @@ export interface Stamp {
46
93
  seconds: number
47
94
  /** what the film said of it, its first line */
48
95
  note: string
96
+ /** each frame's state, run length encoded (states.ts), when the film said it */
97
+ states?: string
98
+ /** its world was fixed by its spec (a film of a past commit): never stale */
99
+ fixed?: true
49
100
  }
50
101
 
51
- export const fresh = (stamp: Stamp, world: string): boolean => stamp.world === world
102
+ export const fresh = (stamp: Stamp, world: string): boolean => stamp.fixed === true || stamp.world === world
52
103
 
53
104
  /** a take as a build sees it: its shot, its file, its stamp when it was filmed */
54
105
  export interface Take {
@@ -56,6 +107,9 @@ export interface Take {
56
107
  film: TakeFilm
57
108
  file: string
58
109
  stamp: Stamp | null
110
+ spec: TakeSpec
111
+ /** the words the film runs: each `@<file>` as its repo relative path */
112
+ argv?: string[]
59
113
  }
60
114
 
61
115
  /** `--refilm`: every stale take, or the takes of the named shots */
@@ -63,7 +117,11 @@ export type Refilm = 'stale' | ReadonlySet<string> | null
63
117
 
64
118
  /** the takes to film, each group sharing one file and filmed once by its first shot: a missing take, a stale one under
65
119
  * `--refilm`, or one whose shot `--refilm` names */
66
- export function takesToFilm(takes: readonly Take[], world: string, refilm: Refilm, only?: ReadonlySet<string>): Take[][] {
120
+ /** the world a take is measured against: one for the cut, or one a take (a row that names its own paths) */
121
+ export type World = string | ((tk: Take) => string)
122
+ export const worldFor = (w: World, tk: Take): string => (typeof w === 'string' ? w : w(tk))
123
+
124
+ export function takesToFilm(takes: readonly Take[], world: World, refilm: Refilm, only?: ReadonlySet<string>): Take[][] {
67
125
  const groups = new Map<string, Take[]>()
68
126
  for (const tk of takes) {
69
127
  if (only !== undefined && !only.has(tk.shot.name)) continue
@@ -74,14 +132,15 @@ export function takesToFilm(takes: readonly Take[], world: string, refilm: Refil
74
132
  return [...groups.values()].filter((g) => {
75
133
  const first = g[0]!
76
134
  if (first.stamp === null) return true
77
- if (refilm === 'stale') return !fresh(first.stamp, world)
135
+ if (refilm === 'stale') return !fresh(first.stamp, worldFor(world, first))
78
136
  return refilm instanceof Set && g.some((tk) => refilm.has(tk.shot.name))
79
137
  })
80
138
  }
81
139
 
82
- /** the argv a project CLI film runs: its words, then the file and, for a clip, its seconds */
83
- export function cliArgv(cli: readonly string[], f: Extract<Film, { kind: 'cli' }>, out: string, seconds: number | null): string[] {
84
- return [...cli, ...f.argv, '--out', out, ...(seconds === null ? [] : ['--seconds', String(seconds)])]
140
+ /** the argv a project CLI film runs: its words (`argv`, the recipe files resolved), then the file and, for a clip, its
141
+ * seconds */
142
+ export function cliArgv(cli: readonly string[], f: Extract<Film, { kind: 'cli' }>, out: string, seconds: number | null, argv: readonly string[] = f.argv): string[] {
143
+ return [...cli, ...argv, '--out', out, ...(seconds === null ? [] : ['--seconds', String(seconds)])]
85
144
  }
86
145
 
87
146
  /** a `film run` command with its marks filled: `{out}`, `{seconds}`, `{w}`, `{h}`, `{fps}` */
@@ -0,0 +1,193 @@
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 every stitch and by hand (`sk trailer prune [--dry]`). What a cut uses is named by the
3
+ // build's own key functions; a file younger than an hour is never deleted: another build may be writing or reading it
4
+ import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
5
+ import { basename, join, relative } from 'node:path'
6
+ import { cutFiles, load } from './build.ts'
7
+ import { SCORE, musicCues } from './grammar.ts'
8
+ import { pieces } from './plan.ts'
9
+
10
+ /** the renders kept per section: the one the cut plays and the one before it, so undoing an edit renders nothing */
11
+ const KEEP_PER_SECTION = 2
12
+ /** a file younger than this is never deleted */
13
+ export const RENDER_GRACE_MS = 60 * 60_000
14
+ /** the folders the trailer's code writes; any other folder of the cache is dead and goes whole */
15
+ const FOLDERS = ['footage', 'pieces', 'sound', 'score', 'titles']
16
+
17
+ /** what the cuts use, by file name in each folder; `broken` names the cuts that do not read */
18
+ export interface Live {
19
+ footage: Set<string>
20
+ /** the render stems of pieces/, pieces/mix/ and pieces/vertical/ */
21
+ stems: Set<string>
22
+ sound: Set<string>
23
+ score: Set<string>
24
+ titles: Set<string>
25
+ broken: string[]
26
+ }
27
+
28
+ /** the live set: each cut read, the name of every cache file its build reads, by the build's own key functions */
29
+ export async function liveSet(root: string): Promise<Live> {
30
+ const live: Live = { footage: new Set(), stems: new Set(), sound: new Set(), score: new Set(), titles: new Set(), broken: [] }
31
+ const [{ takesOfLoaded }, { trailerCache }, { scoreWavFile }, { scoreModuleFile, scoreModule }, titles, { lookOf }, { loadBrand, identityLogo }] = await Promise.all([
32
+ import('./build.ts'),
33
+ import('./footage.ts'),
34
+ import('./score/sound.ts'),
35
+ import('./score/score.ts'),
36
+ import('./titles.ts'),
37
+ import('./look.ts'),
38
+ import('../assets/run-assets.ts'),
39
+ ])
40
+ const brand = await loadBrand(root)
41
+ const look = typeof brand === 'string' ? null : lookOf(brand, await identityLogo(root))
42
+ for (const file of cutFiles(root)) {
43
+ // whatever a cut throws (its parse, the unit's take, its score), it is named and the keyed folders stay
44
+ try {
45
+ const l = await load(file, root, { check: false })
46
+ const why = l.errors[0]
47
+ if (why) {
48
+ live.broken.push(`${relative(root, file)}:${why.line}: ${why.message}`)
49
+ continue
50
+ }
51
+ const t = l.t
52
+ const names = { footage: takesOfLoaded(l).map((tk) => basename(tk.file)), stems: [] as string[], titles: [] as string[], score: [] as string[], sound: [] as string[] }
53
+ for (const p of pieces(t)) names.stems.push(`${t.name}-${p.name}`)
54
+ names.stems.push(`mix-${t.name}`, `${t.name}-vertical`)
55
+ if (look) {
56
+ const dir = trailerCache(root, 'titles')
57
+ names.titles.push(basename(titles.cardFile(dir, t.size, look)))
58
+ for (const s of t.shots) for (const ti of s.titles) names.titles.push(basename(titles.titleFile(dir, root, ti.text, ti.style, t.size, titles.placeOf(ti), look, ti.step)))
59
+ }
60
+ if (t.score && l.score) {
61
+ names.score.push(basename(scoreModuleFile(scoreModule(t.score, t.sections).text, trailerCache(root, 'score'))))
62
+ const sound = trailerCache(root, 'sound')
63
+ names.sound.push(basename(scoreWavFile(l.score, l.score.seconds, sound)))
64
+ for (const { cue, at, end } of musicCues(t)) if (cue.song === SCORE) names.sound.push(basename(scoreWavFile(l.score, cue.from + end - at, sound)))
65
+ }
66
+ for (const k of ['footage', 'stems', 'titles', 'score', 'sound'] as const) for (const n of names[k]) live[k].add(n)
67
+ } catch (e) {
68
+ live.broken.push(`${relative(root, file)}: ${(e as Error).message}`)
69
+ }
70
+ }
71
+ return live
72
+ }
73
+
74
+ /** a file the prune deletes: its bytes, and whether another hard link keeps them on disk */
75
+ interface Doomed {
76
+ file: string
77
+ bytes: number
78
+ linked: boolean
79
+ }
80
+
81
+ const doomed = (file: string): Doomed => {
82
+ const s = statSync(file)
83
+ return { file, bytes: s.size, linked: s.nlink > 1 }
84
+ }
85
+
86
+ /** the files of `dir` that `keep` refuses, past the grace */
87
+ export function unlisted(dir: string, keep: (name: string) => boolean, now = Date.now()): Doomed[] {
88
+ if (!existsSync(dir)) return []
89
+ return readdirSync(dir, { withFileTypes: true })
90
+ .filter((e) => e.isFile() && !keep(e.name))
91
+ .map((e) => join(dir, e.name))
92
+ .filter((f) => now - statSync(f).mtimeMs > RENDER_GRACE_MS)
93
+ .map(doomed)
94
+ }
95
+
96
+ /** a file stays when a cut names it, and every file named after it (its stamp `<file>.json`, its box, its levels) */
97
+ export const keepListed =
98
+ (names: ReadonlySet<string>) =>
99
+ (name: string): boolean =>
100
+ names.has(name) || [...names].some((n) => name.startsWith(`${n}.`))
101
+
102
+ const RENDER_RE = /^(.+)-([0-9a-f]{16})(?:\.part)?(?:\.\w+)+$/
103
+
104
+ /** the renders of `dir` that go: every render of a stem no cut has (a renamed section, a shot's preview), and past the
105
+ * newest `keep` of each live stem (a cache hit touches it). `stems` null: no stem is dropped */
106
+ export function oldRenders(dir: string, stems: ReadonlySet<string> | null, keep = KEEP_PER_SECTION, now = Date.now()): Doomed[] {
107
+ if (!existsSync(dir)) return []
108
+ const byStem = new Map<string, Map<string, number>>()
109
+ for (const e of readdirSync(dir, { withFileTypes: true }).filter((x) => x.isFile())) {
110
+ const m = RENDER_RE.exec(e.name)
111
+ if (!m) continue
112
+ const keys = byStem.get(m[1]!) ?? new Map<string, number>()
113
+ keys.set(m[2]!, Math.max(keys.get(m[2]!) ?? 0, statSync(join(dir, e.name)).mtimeMs))
114
+ byStem.set(m[1]!, keys)
115
+ }
116
+ const kept = new Set<string>()
117
+ for (const [stem, keys] of byStem) {
118
+ if (stems !== null && !stems.has(stem)) continue
119
+ for (const [k] of [...keys].sort((x, y) => y[1] - x[1]).slice(0, keep)) kept.add(`${stem}-${k}`)
120
+ }
121
+ return unlisted(
122
+ dir,
123
+ (name) => {
124
+ const m = RENDER_RE.exec(name)
125
+ return m === null ? stems === null : kept.has(`${m[1]}-${m[2]}`)
126
+ },
127
+ now,
128
+ )
129
+ }
130
+
131
+ /** what one folder frees */
132
+ export interface Freed {
133
+ folder: string
134
+ files: number
135
+ /** bytes the disk gets back */
136
+ bytes: number
137
+ /** bytes another hard link keeps */
138
+ linked: number
139
+ }
140
+
141
+ export interface Pruned {
142
+ freed: Freed[]
143
+ /** the cuts that do not read: the folders keyed on what the cuts use were left alone */
144
+ broken: string[]
145
+ }
146
+
147
+ function filesUnder(dir: string): string[] {
148
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? filesUnder(join(dir, e.name)) : [join(dir, e.name)]))
149
+ }
150
+
151
+ /** plans every folder's prune, then deletes unless `dry` */
152
+ export async function prune(root: string, o: { dry: boolean; now?: number }): Promise<Pruned> {
153
+ const now = o.now ?? Date.now()
154
+ const [{ trailerCache }, { viewsToDrop }] = await Promise.all([import('./footage.ts'), import('../review/trailer-desk.ts')])
155
+ const cache = join(trailerCache(root, 'footage'), '..')
156
+ const live = await liveSet(root)
157
+ const whole = live.broken.length === 0
158
+ // titles/ holds the vertical's drawings too: only a title's and a card's files are the prune's
159
+ const ownTitle = (n: string): boolean => /^(title|card)-[0-9a-f]{16}\./.test(n)
160
+ const plan: [string, Doomed[]][] = [
161
+ ['footage/', whole ? unlisted(join(cache, 'footage'), keepListed(live.footage), now) : []],
162
+ ['pieces/', ['', 'mix', 'vertical'].flatMap((d) => oldRenders(join(cache, 'pieces', d), whole ? live.stems : null, KEEP_PER_SECTION, now))],
163
+ ['sound/', whole ? unlisted(join(cache, 'sound'), keepListed(live.sound), now) : []],
164
+ ['score/', whole ? unlisted(join(cache, 'score'), keepListed(live.score), now) : []],
165
+ ['titles/', whole ? unlisted(join(cache, 'titles'), (n) => !ownTitle(n) || keepListed(live.titles)(n), now) : []],
166
+ ]
167
+ const dead = existsSync(cache) ? readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory() && !FOLDERS.includes(e.name)) : []
168
+ for (const d of dead) plan.push([`${d.name}/ (dead)`, filesUnder(join(cache, d.name)).map(doomed)])
169
+ plan.push(['trailer-view/', viewsToDrop(root).filter((f) => now - statSync(f).mtimeMs > RENDER_GRACE_MS).map(doomed)])
170
+ if (!o.dry) {
171
+ for (const [, ds] of plan) for (const d of ds) rmSync(d.file, { force: true })
172
+ for (const d of dead) rmSync(join(cache, d.name), { recursive: true, force: true })
173
+ }
174
+ return {
175
+ 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) })),
176
+ broken: live.broken,
177
+ }
178
+ }
179
+
180
+ const gb = (n: number): string => (n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : `${(n / 1e6).toFixed(1)} MB`)
181
+
182
+ /** the report, a line a folder; `short` folds it into one line for the end of a build */
183
+ export function sayPruned(p: Pruned, dry: boolean, short = false): string[] {
184
+ const total = p.freed.reduce((n, f) => n + f.bytes, 0)
185
+ const files = p.freed.reduce((n, f) => n + f.files, 0)
186
+ if (short) return [`cache pruned: ${files} files, ${gb(total)} freed${p.broken.length > 0 ? ` (a cut does not read, the keyed folders left alone: ${p.broken[0]})` : ''}`]
187
+ return [
188
+ dry ? 'trailer cache, a dry run: nothing deleted' : 'trailer cache pruned',
189
+ ...p.freed.map((f) => ` ${f.folder.padEnd(20)} ${String(f.files).padStart(5)} files ${gb(f.bytes).padStart(9)}${f.linked > 0 ? ` (and ${gb(f.linked)} a hard link keeps)` : ''}`),
190
+ ` ${'total'.padEnd(20)} ${String(files).padStart(5)} files ${gb(total).padStart(9)}${dry ? ' would be freed' : ' freed'}`,
191
+ ...(p.broken.length > 0 ? [` footage, sound, score, titles and unknown sections left alone: a cut does not read (${p.broken.join('; ')})`] : []),
192
+ ]
193
+ }
@@ -0,0 +1,240 @@
1
+ // composition helpers for a score's `code` and `play` lines: they only build pattern strings in the grammar of
2
+ // pattern.ts, so a score stays plain note data once evaluated. Chords are written per bar, bars split by '|', a bar
3
+ // holding several chords splits evenly: 'C | Am | F G | C'.
4
+
5
+ import { noteToMidi } from './pattern.ts'
6
+
7
+ const NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
8
+
9
+ export function midiToNote(midi: number): string {
10
+ const oct = Math.floor(midi / 12) - 1
11
+ if (oct < 0 || oct > 8) throw new Error(`note ${midi} outside octaves 0..8`)
12
+ return `${NAMES[midi % 12]}${oct}`
13
+ }
14
+
15
+ /** joins pattern fragments into one row string */
16
+ export function seq(...parts: string[]): string {
17
+ return parts.join(' ')
18
+ }
19
+
20
+ export function rep(part: string, n: number): string {
21
+ return Array.from({ length: n }, () => part).join(' ')
22
+ }
23
+
24
+ function splitMods(tok: string): { mods: string; body: string } {
25
+ const i = tok.lastIndexOf(':')
26
+ return i === -1 ? { mods: '', body: tok } : { mods: tok.slice(0, i + 1), body: tok.slice(i + 1) }
27
+ }
28
+
29
+ /** shifts every note token by `semis` (plain rows or `mel` shorthand), modifiers, holds, rests and drums kept */
30
+ export function transpose(text: string, semis: number): string {
31
+ return text
32
+ .split(/\s+/)
33
+ .filter((t) => t !== '')
34
+ .map((tok) => {
35
+ const { mods, body } = splitMods(tok)
36
+ const [note = '', len] = body.split('/')
37
+ const midi = noteToMidi(note)
38
+ return midi === null ? tok : mods + midiToNote(midi + semis) + (len === undefined ? '' : `/${len}`)
39
+ })
40
+ .join(' ')
41
+ }
42
+
43
+ /**
44
+ * durations shorthand: `E5/4` is E5 held 4 rows, `-/2` two rows of silence,
45
+ * a bare token lasts `unit` rows. Modifiers glue before the note:
46
+ * `v10:E5/4`, `@bell:C6/2`. Drum letters work the same way.
47
+ */
48
+ export function mel(text: string, unit = 1): string {
49
+ const out: string[] = []
50
+ for (const tok of text.split(/\s+/)) {
51
+ if (tok === '' || tok === '|') continue
52
+ const slash = tok.lastIndexOf('/')
53
+ const head = slash === -1 ? tok : tok.slice(0, slash)
54
+ const len = slash === -1 ? unit : Number(tok.slice(slash + 1))
55
+ if (!Number.isInteger(len) || len < 1) throw new Error(`mel token "${tok}": bad length`)
56
+ const { body } = splitMods(head)
57
+ out.push(head)
58
+ for (let i = 1; i < len; i++) out.push(body === '-' ? '-' : '.')
59
+ }
60
+ return out.join(' ')
61
+ }
62
+
63
+ // chord qualities as semitone stacks above the root
64
+ const QUALITIES: Record<string, number[]> = {
65
+ '': [0, 4, 7],
66
+ m: [0, 3, 7],
67
+ '7': [0, 4, 7, 10],
68
+ m7: [0, 3, 7, 10],
69
+ maj7: [0, 4, 7, 11],
70
+ dim: [0, 3, 6],
71
+ dim7: [0, 3, 6, 9],
72
+ aug: [0, 4, 8],
73
+ sus4: [0, 5, 7],
74
+ sus2: [0, 2, 7],
75
+ add9: [0, 4, 7, 14],
76
+ m9: [0, 3, 7, 10, 14],
77
+ '6': [0, 4, 7, 9],
78
+ m6: [0, 3, 7, 9],
79
+ '5': [0, 7],
80
+ }
81
+
82
+ export interface Chord {
83
+ /** pitch class 0..11 of the bass note (the slash note when given) */
84
+ bass: number
85
+ root: number
86
+ tones: number[]
87
+ }
88
+
89
+ /** 'C', 'F#m7', 'Bbmaj7', 'G/B' */
90
+ export function parseChord(sym: string): Chord {
91
+ const [main = '', slash] = sym.split('/')
92
+ const m = /^([A-G])([#b]?)(.*)$/.exec(main)
93
+ if (!m) throw new Error(`chord "${sym}": no root`)
94
+ const pc = (l: string, a: string) => ((noteToMidi(`${l}${a}4`) ?? 0) + 120) % 12
95
+ const root = pc(m[1] ?? 'C', m[2] ?? '')
96
+ const q = QUALITIES[m[3] ?? '']
97
+ if (!q) throw new Error(`chord "${sym}": unknown quality "${m[3]}"`)
98
+ let bass = root
99
+ if (slash !== undefined) {
100
+ const s = /^([A-G])([#b]?)$/.exec(slash)
101
+ if (!s) throw new Error(`chord "${sym}": bad slash bass`)
102
+ bass = pc(s[1] ?? 'C', s[2] ?? '')
103
+ }
104
+ return { root, bass, tones: q }
105
+ }
106
+
107
+ interface Seg {
108
+ chord: Chord
109
+ rows: number
110
+ }
111
+
112
+ /** the chord chart as segments; `bar` is rows per bar */
113
+ export function chart(text: string, bar = 16): Seg[] {
114
+ const segs: Seg[] = []
115
+ for (const b of text.split('|')) {
116
+ const syms = b.split(/\s+/).filter((s) => s !== '')
117
+ if (syms.length === 0) continue
118
+ if (bar % syms.length !== 0) throw new Error(`chart bar "${b}" does not split ${bar} rows evenly`)
119
+ for (const s of syms) segs.push({ chord: parseChord(s), rows: bar / syms.length })
120
+ }
121
+ return segs
122
+ }
123
+
124
+ /** lowest midi of pitch class `pc` at or above the C of `oct` */
125
+ function at(pc: number, oct: number): number {
126
+ return 12 * (oct + 1) + pc
127
+ }
128
+
129
+ export type BassStyle =
130
+ | 'hold' // root held the whole segment
131
+ | 'half' // root, then fifth at half
132
+ | 'quarter' // root on every beat
133
+ | 'octave' // eighths bouncing root, octave
134
+ | 'drive' // straight eighths on the root, octave jump on the last
135
+ | 'gallop' // eighth + two sixteenths per beat
136
+ | 'walk' // quarters root, third, fifth, a step to the next root
137
+ | 'waltz' // root on the beat, chord fifth on beats 2 and 3 (3 beat bars)
138
+ | 'pulse' // root on beats 1 and 3, rests between
139
+
140
+ /** a bass line over a chart, in octave `oct` (2 = the usual register) */
141
+ export function bass(text: string, style: BassStyle, oct = 2, bar = 16, beat = 4): string {
142
+ const segs = chart(text, bar)
143
+ const out: string[] = []
144
+ segs.forEach((seg, i) => {
145
+ const r = at(seg.chord.bass, oct)
146
+ const fifth = at(seg.chord.root, oct) + 7
147
+ const third = at(seg.chord.root, oct) + (seg.chord.tones[1] ?? 4)
148
+ const next = segs[(i + 1) % segs.length]?.chord.bass ?? seg.chord.bass
149
+ const rows: string[] = []
150
+ const put = (row: number, midi: number | '-') => {
151
+ rows[row] = midi === '-' ? '-' : midiToNote(midi)
152
+ }
153
+ for (let k = 0; k < seg.rows; k++) rows.push('.')
154
+ switch (style) {
155
+ case 'hold':
156
+ put(0, r)
157
+ break
158
+ case 'half':
159
+ put(0, r)
160
+ if (seg.rows >= 2) put(seg.rows / 2, fifth > r + 9 ? fifth - 12 : fifth)
161
+ break
162
+ case 'quarter':
163
+ for (let k = 0; k < seg.rows; k += beat) put(k, r)
164
+ break
165
+ case 'octave':
166
+ for (let k = 0; k < seg.rows; k += beat / 2) put(k, (k / (beat / 2)) % 2 === 0 ? r : r + 12)
167
+ break
168
+ case 'drive':
169
+ for (let k = 0; k < seg.rows; k += beat / 2) put(k, k === seg.rows - beat / 2 && seg.rows >= bar ? r + 12 : r)
170
+ break
171
+ case 'gallop':
172
+ for (let k = 0; k < seg.rows; k += beat) {
173
+ put(k, r)
174
+ if (k + 2 < seg.rows) put(k + 2, r)
175
+ if (k + 3 < seg.rows) put(k + 3, k + beat >= seg.rows ? r + 12 : r)
176
+ }
177
+ break
178
+ case 'walk': {
179
+ const target = at(next, oct)
180
+ const approach = target > r ? target - 1 : target + 1
181
+ const steps = [r, third, fifth > r + 9 ? fifth - 12 : fifth, approach]
182
+ for (let k = 0, j = 0; k < seg.rows; k += beat, j++) put(k, steps[Math.min(j, 3)] ?? r)
183
+ if (seg.rows <= beat * 2) {
184
+ rows.fill('.')
185
+ put(0, r)
186
+ if (seg.rows > beat) put(beat, approach)
187
+ }
188
+ break
189
+ }
190
+ case 'waltz':
191
+ for (let k = 0; k < seg.rows; k += beat) put(k, k === 0 ? r : fifth > r + 9 ? fifth - 12 : fifth)
192
+ break
193
+ case 'pulse':
194
+ for (let k = 0; k < seg.rows; k += beat * 2) {
195
+ put(k, r)
196
+ if (k + beat < seg.rows) put(k + beat, '-')
197
+ }
198
+ break
199
+ }
200
+ out.push(...rows)
201
+ })
202
+ return out.join(' ')
203
+ }
204
+
205
+ /**
206
+ * an arpeggio over a chart: `shape` indexes the chord tones upward (0 root,
207
+ * 1 third, 2 fifth, 3 the root an octave up for a triad...), one index per
208
+ * step of `step` rows, cycling through the segment.
209
+ */
210
+ export function arp(text: string, shape: number[], oct = 4, step = 2, bar = 16): string {
211
+ const out: string[] = []
212
+ for (const seg of chart(text, bar)) {
213
+ const n = seg.chord.tones.length
214
+ for (let k = 0, j = 0; k < seg.rows; k += step, j++) {
215
+ const idx = shape[j % shape.length] ?? 0
216
+ const iv = (seg.chord.tones[idx % n] ?? 0) + 12 * Math.floor(idx / n)
217
+ out.push(midiToNote(at(seg.chord.root, oct) + iv))
218
+ for (let h = 1; h < step && k + h < seg.rows; h++) out.push('.')
219
+ }
220
+ }
221
+ return out.join(' ')
222
+ }
223
+
224
+ /**
225
+ * one voice of a held chord: tone `which` of each chord, placed at or above
226
+ * `floor` (a midi note), re-struck per segment. Stack two or three pad tracks
227
+ * on different tones for a full chord.
228
+ */
229
+ export function voice(text: string, which: number, floor: number, bar = 16, restrike = 0): string {
230
+ const out: string[] = []
231
+ for (const seg of chart(text, bar)) {
232
+ const n = seg.chord.tones.length
233
+ const iv = (seg.chord.tones[which % n] ?? 0) + 12 * Math.floor(which / n)
234
+ let m = seg.chord.root + iv
235
+ while (m < floor) m += 12
236
+ const note = midiToNote(m)
237
+ for (let k = 0; k < seg.rows; k++) out.push(k === 0 || (restrike > 0 && k % restrike === 0) ? note : '.')
238
+ }
239
+ return out.join(' ')
240
+ }