rvlib-shipkit 2.1.1 → 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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -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/assets/kits/identity-kit.ts +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,124 @@
1
+ // the look every trailer wears, drawn from the repo's brand (`.shipkit/identity/brand.ts`): the card's ground, the
2
+ // title colours, the face and the logo. A repo changes its look in that data, never in code; a value it leaves out is
3
+ // derived from its palette, and a repo with no brand gets the house default. Pure
4
+ export interface TrailerLookInput {
5
+ /** the card's ground, middle then edge (default: the palette's bg, then a darker bg) */
6
+ ground?: [string, string]
7
+ /** words over footage (default: the palette's ink when it is light, else white) */
8
+ light?: string
9
+ /** the dark edge under words over footage (default: the ground's edge) */
10
+ edge?: string
11
+ /** the key colour: the caption's dot, a `line` title's `*word*`, the corner chip (default: the palette's accent) */
12
+ accent?: string
13
+ /** the caption's plate (default: the palette's bg, an accent outline, the palette's ink) */
14
+ plate?: { fill?: string; line?: string; ink?: string }
15
+ /** the corner chip (default: the accent, ink of the palette's bg) */
16
+ chip?: { fill?: string; ink?: string }
17
+ /** the face: a font file (repo relative, .ttf .otf .woff2) or a family the machine has, and its heavy weight */
18
+ 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
+ logo?: string
21
+ /** the words of a short: the `short` and `progress` titles and the vertical's words, each value the look's when left out */
22
+ short?: { font?: { file?: string; family?: string; weight?: number }; light?: string; edge?: string; accent?: string }
23
+ /** the vertical short's look (a `vertical` cut), each part `false` to leave it out (vertical.ts) */
24
+ vertical?: VerticalLookInput
25
+ }
26
+
27
+ /** what a vertical short wears, image paths repo relative; its colours and face are the look's */
28
+ export interface VerticalLookInput {
29
+ /** over every frame of the short, under the platforms' top tabs: a wordmark image, else the logo and the name; a
30
+ * tag pill under it */
31
+ lockup?: { image?: string; name?: string; tag?: string } | false
32
+ /** a bottom corner of the short and its outro: an image (default: the logo) and a word beside it (default: the
33
+ * name, when there is no image) */
34
+ watermark?: { image?: string; text?: string; corner?: 'left' | 'right' } | false
35
+ /** the card the short ends on: an image that hops in (default: the logo), the name, lines with `*word*` in the
36
+ * accent (default: the tagline), its length in seconds */
37
+ end?: { image?: string; name?: string; lines?: string[]; seconds?: number } | false
38
+ }
39
+
40
+ export interface TrailerLook {
41
+ ground: [string, string]
42
+ light: string
43
+ edge: string
44
+ accent: string
45
+ plate: { fill: string; line: string; ink: string }
46
+ chip: { fill: string; ink: string }
47
+ font: { file: string | null; family: string; weight: number }
48
+ logo: string | null
49
+ /** the short's words: their face and colours */
50
+ short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string }
51
+ }
52
+
53
+ export interface BrandLike {
54
+ name: string
55
+ tagline?: string
56
+ palette: { bg: string; accent: string; ink: string }
57
+ trailer?: TrailerLookInput
58
+ }
59
+
60
+ /** named faces first: a generic sans-serif can resolve to one with no Latin-1 accents (the og card uses it too) */
61
+ export const HOUSE_FAMILIES = 'Helvetica, Arial, "DejaVu Sans", "Liberation Sans", "Noto Sans", sans-serif'
62
+
63
+ /** the palette a repo with no brand wears: a deep ink ground, warm accent, cream words */
64
+ export const HOUSE_PALETTE = { bg: '#1d1b2e', accent: '#ffb547', ink: '#fbf5ea' } as const
65
+
66
+ /** `#rgb` or `#rrggbb` as [r, g, b], 0 to 255; null for any other colour text */
67
+ export function rgbOf(c: string): [number, number, number] | null {
68
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim())
69
+ if (!m) return null
70
+ const h = m[1]!.length === 3 ? [...m[1]!].map((x) => x + x).join('') : m[1]!
71
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number]
72
+ }
73
+
74
+ const hex = (rgb: readonly number[]): string => `#${rgb.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('')}`
75
+
76
+ /** the colour mixed toward black by `k`, 0 to 1; a colour that is not hex stays as it is */
77
+ export function darker(c: string, k: number): string {
78
+ const rgb = rgbOf(c)
79
+ return rgb ? hex(rgb.map((v) => v * (1 - k))) : c
80
+ }
81
+
82
+ /** relative luminance, 0 dark to 1 light; 0.5 for a colour that is not hex */
83
+ export function luminance(c: string): number {
84
+ const rgb = rgbOf(c)
85
+ if (!rgb) return 0.5
86
+ const [r, g, b] = rgb.map((v) => {
87
+ const s = v / 255
88
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
89
+ }) as [number, number, number]
90
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
91
+ }
92
+
93
+ /** the colour with an alpha, as canvas reads it; a colour that is not hex stays as it is */
94
+ export function alpha(c: string, a: number): string {
95
+ const rgb = rgbOf(c)
96
+ return rgb ? `rgba(${rgb.join(',')},${a})` : c
97
+ }
98
+
99
+ /** the look of a brand, every value its own or derived from its palette; `webLogo` the web root's logo.png when present */
100
+ export function lookOf(brand: BrandLike | null, webLogo: string | null = null): TrailerLook {
101
+ const p = brand?.palette ?? HOUSE_PALETTE
102
+ const i = brand?.trailer ?? {}
103
+ const ground: [string, string] = i.ground ?? [p.bg, darker(p.bg, luminance(p.bg) > 0.5 ? 0.25 : 0.45)]
104
+ const font = { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 }
105
+ const light = i.light ?? (luminance(p.ink) > 0.45 ? p.ink : '#ffffff')
106
+ const edge = i.edge ?? (luminance(ground[1]) < 0.2 ? ground[1] : darker(p.bg, 0.8))
107
+ const accent = i.accent ?? p.accent
108
+ const sh = i.short ?? {}
109
+ 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
110
+ return {
111
+ ground,
112
+ light,
113
+ edge,
114
+ accent,
115
+ plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink },
116
+ chip: { fill: i.chip?.fill ?? p.accent, ink: i.chip?.ink ?? (luminance(p.accent) > 0.4 ? darker(p.bg, luminance(p.bg) > 0.5 ? 0.85 : 0.2) : '#ffffff') },
117
+ font: { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 },
118
+ logo: i.logo ?? webLogo,
119
+ short: { font: shortFont, light: sh.light ?? light, edge: sh.edge ?? edge, accent: sh.accent ?? accent },
120
+ }
121
+ }
122
+
123
+ /** the look a short's words wear: the look with the short's face and colours in place */
124
+ export const shortLook = (look: TrailerLook): TrailerLook => ({ ...look, font: look.short.font, light: look.short.light, edge: look.short.edge, accent: look.short.accent })
@@ -0,0 +1,129 @@
1
+ // the verbs over a built cut (wiki build/trailer.md, The verbs and Versions): each section's levels, a passage clipped
2
+ // with its sound (and the same passage of a kept version), a version kept, the take file of a shot
3
+ import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import { basename, join, relative } from 'node:path'
5
+ import { type Loaded, load, Refused, type Say, takesOfLoaded } from './build.ts'
6
+ import { ffmpeg } from './ffmpeg.ts'
7
+ import { starts, type Trailer } from './grammar.ts'
8
+
9
+ const outOf = (l: Pick<Loaded, 'dir'>): string => join(l.dir, 'out')
10
+
11
+ /** the built video of a cut, refused when the build never ran */
12
+ function builtOf(l: Pick<Loaded, 'dir' | 'file' | 't'>, cwd: string): string {
13
+ const f = join(outOf(l), `${l.t.name}.mp4`)
14
+ if (!existsSync(f)) throw new Refused(`no ${relative(cwd, f)}: sk trailer ${relative(cwd, l.file)} first`)
15
+ return f
16
+ }
17
+
18
+ /** a moment of the cut on the video clock: a section or a mark, a shot, or seconds; `end` takes its end */
19
+ export function momentOf(t: Pick<Trailer, 'sections' | 'timelineAt' | 'shots'>, name: string, end: boolean): number {
20
+ if (/^\d+(\.\d+)?$/.test(name)) return Number(name)
21
+ if (t.timelineAt !== null) {
22
+ const sec = t.sections.find((s) => s.name === name)
23
+ if (sec) return t.timelineAt + sec.at + (end ? sec.seconds : 0)
24
+ for (const s of t.sections) {
25
+ const m = s.marks.find((x) => x.name === name)
26
+ if (m) return t.timelineAt + s.at + m.at
27
+ }
28
+ }
29
+ const i = t.shots.findIndex((s) => s.name === name)
30
+ if (i >= 0) return starts(t)[i]! + (end ? t.shots[i]!.length : 0)
31
+ throw new Refused(`${name}: no section, mark or shot of that name (or write seconds)`)
32
+ }
33
+
34
+ /** each section's loudness in the built cut, and its step from the section before */
35
+ export async function levels(l: Loaded, say: Say, cwd: string): Promise<void> {
36
+ const video = builtOf(l, cwd)
37
+ const at0 = l.t.timelineAt
38
+ if (l.t.sections.length === 0 || at0 === null) throw new Refused('levels: this cut has no section')
39
+ const rows = await Promise.all(
40
+ l.t.sections.map(async (s) => {
41
+ const err = await ffmpeg(['-loglevel', 'info', '-ss', String(at0 + s.at), '-t', String(s.seconds), '-i', video, '-vn', '-af', 'volumedetect', '-f', 'null', '-'])
42
+ const mean = Number(/mean_volume: (-?[\d.]+)/.exec(err)?.[1] ?? Number.NaN)
43
+ const max = Number(/max_volume: (-?[\d.]+)/.exec(err)?.[1] ?? Number.NaN)
44
+ return { s, mean, max }
45
+ }),
46
+ )
47
+ say(`${relative(cwd, video)}: each section's mean and peak, dB (video clock)`)
48
+ rows.forEach((r, i) => {
49
+ const prev = rows[i - 1]
50
+ const step = prev ? `${r.mean - prev.mean >= 0 ? '+' : ''}${(r.mean - prev.mean).toFixed(1)}` : ''
51
+ say(` ${r.s.name.padEnd(12)} ${(at0 + r.s.at).toFixed(1).padStart(6)} s ${r.s.seconds.toFixed(1).padStart(5)} s mean ${r.mean.toFixed(1).padStart(6)} peak ${r.max.toFixed(1).padStart(5)} ${step.padStart(5)}`)
52
+ })
53
+ }
54
+
55
+ /** a kept version's video: the one mp4 of `versions/<v>/` */
56
+ export function versionVideo(dir: string, v: string): string | null {
57
+ const d = join(dir, 'versions', v)
58
+ if (!existsSync(d)) return null
59
+ const f = readdirSync(d).find((x) => x.endsWith('.mp4'))
60
+ return f ? join(d, f) : null
61
+ }
62
+
63
+ /** a version's cut text read as today's tool reads it, with the cut's own recipes, and its video */
64
+ async function versionCut(l: Loaded, v: string, cwd: string): Promise<{ t: Trailer; video: string }> {
65
+ const text = join(l.dir, 'versions', `${v}.trailer`)
66
+ if (!existsSync(text)) throw new Refused(`no ${relative(cwd, text)}`)
67
+ const kept = await load(text, l.root, { dir: l.dir, check: false })
68
+ if (kept.errors.length > 0) throw new Refused(`${v} does not read with today's tool (an older version builds only in a worktree at its tag): ${kept.errors[0]!.message}`)
69
+ const video = versionVideo(l.dir, v) ?? join(l.dir, 'versions', v, `${basename(l.dir)}-${v}.mp4`)
70
+ if (!existsSync(video)) throw new Refused(`no ${relative(cwd, video)}`)
71
+ return { t: kept.t, video }
72
+ }
73
+
74
+ /** the passage from `from` to the end of `to` as its own mp4, sound included; with a version, the same passage of it too */
75
+ export async function clip(l: Loaded, from: string, to: string, pad: number, version: string | undefined, say: Say, cwd: string): Promise<void> {
76
+ const cut = async (t: Trailer, video: string, out: string): Promise<void> => {
77
+ const a = Math.max(0, momentOf(t, from, false) - pad)
78
+ const b = momentOf(t, to, true) + pad
79
+ if (b <= a) throw new Refused(`clip: ${to} ends before ${from} starts`)
80
+ await ffmpeg(['-ss', String(a), '-to', String(b), '-i', video, '-c:v', 'libx264', '-crf', '18', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', out], { nice: 10 })
81
+ say(`ok: ${relative(cwd, out)} (${a.toFixed(2)} to ${b.toFixed(2)} s)`)
82
+ }
83
+ const tag = from === to ? from : `${from}-${to}`
84
+ await cut(l.t, builtOf(l, cwd), join(outOf(l), `clip-${tag}.mp4`))
85
+ if (version) {
86
+ const v = await versionCut(l, version, cwd)
87
+ await cut(v.t, v.video, join(outOf(l), `clip-${tag}-${version}.mp4`))
88
+ }
89
+ }
90
+
91
+ /** keep the built cut as a version: its text, its video, its strip and each shot's take, cloned so they cost nothing */
92
+ export function keep(l: Loaded, v: string, say: Say, cwd: string): void {
93
+ if (!/^v\d+$/.test(v)) throw new Refused(`keep: a version is v<n>, not ${v}`)
94
+ const text = join(l.dir, 'versions', `${v}.trailer`)
95
+ const dir = join(l.dir, 'versions', v)
96
+ if (existsSync(text) || existsSync(dir)) throw new Refused(`${v} is kept already: versions are never overwritten`)
97
+ const video = builtOf(l, cwd)
98
+ mkdirSync(join(dir, 'shots'), { recursive: true })
99
+ writeFileSync(text, readFileSync(l.file, 'utf8'))
100
+ // a build rewrites its files in place: a link would turn the version into the next build, a clone costs nothing on APFS
101
+ const clone = (a: string, b: string): void => copyFileSync(a, b, constants.COPYFILE_FICLONE)
102
+ clone(video, join(dir, `${basename(l.dir)}-${v}.mp4`))
103
+ const strip = join(outOf(l), `${l.t.name}.strip.png`)
104
+ if (existsSync(strip)) clone(strip, join(dir, `${l.t.name}.strip.png`))
105
+ const shots = join(outOf(l), `${l.t.name}-shots`)
106
+ const files = existsSync(shots) ? readdirSync(shots) : []
107
+ for (const f of files) clone(join(shots, f), join(dir, 'shots', f))
108
+ // the clones are the machine's: git keeps the text and the index, never a version's folder
109
+ const ignored = Bun.spawnSync(['git', 'check-ignore', '-q', dir], { cwd: l.root }).exitCode === 0
110
+ const ignore = join(l.dir, 'versions', '.gitignore')
111
+ if (!ignored && !existsSync(ignore)) writeFileSync(ignore, '*/\n')
112
+ say(`ok: kept ${v}: ${relative(cwd, text)}, ${relative(cwd, dir)}/ (the video, the strip, ${files.length} shots)`)
113
+ say(`then: its row in ${relative(cwd, join(l.dir, 'versions', 'index.md'))}, commit ${relative(cwd, text)} and that index, git tag trailer-${basename(l.dir)}-${v}, push the commit and the tag`)
114
+ }
115
+
116
+ /** the take file of each named shot or footage block */
117
+ export function footage(l: Loaded, names: string[], say: Say, cwd: string): void {
118
+ const takes = takesOfLoaded(l)
119
+ for (const name of names) {
120
+ const s = [...l.t.shots, ...l.t.footage].find((x) => x.name === name)
121
+ if (!s) throw new Refused(`${name}: no shot or footage block of that name`)
122
+ const tk = takes.find((x) => x.shot === s)
123
+ if (!tk) {
124
+ say(`${name} no take: its film is ${s.film?.kind ?? 'nothing'}`)
125
+ continue
126
+ }
127
+ say(`${name} ${relative(cwd, tk.file)}${existsSync(tk.file) ? '' : ' (not filmed)'}`)
128
+ }
129
+ }
@@ -0,0 +1,192 @@
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
+ // 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, TakeLength, Trailer } from './grammar.ts'
4
+ import { starts } from './grammar.ts'
5
+
6
+ /** bump when the core films differently from the same line: every take films again */
7
+ export const FILM_VERSION = 2
8
+
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 {
11
+ return new Bun.CryptoHasher('sha1').update(JSON.stringify([version, recipe, size, fps, seconds])).digest('hex').slice(0, 12)
12
+ }
13
+
14
+ /** a film that writes a take of its own: the others are read where they lie (a clip, an image) or drawn (a card) */
15
+ export type TakeFilm = Extract<Film, { kind: 'run' | 'cli' | 'url' }>
16
+ export const isTake = (f: Film | null): f is TakeFilm => f?.kind === 'run' || f?.kind === 'cli' || f?.kind === 'url'
17
+
18
+ /** the take is one picture, held for the shot */
19
+ export const takeIsStill = (f: TakeFilm): boolean => (f.kind === 'cli' ? f.output === 'still' : f.still === true)
20
+
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
26
+ }
27
+
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
+ }
37
+ const { line: _line, ...recipe } = f
38
+ return recipe
39
+ }
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
+
80
+ /** the take's file name, by its key alone: shots on one film line, in one cut or in several, share one file */
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'}`
85
+ }
86
+
87
+ /** the world a take was filmed on, beside it */
88
+ export interface Stamp {
89
+ /** the repo's state when it filmed (footage.ts worldStamp) */
90
+ world: string
91
+ filmedAt: string
92
+ /** the take's own length, seconds; 0 for a still */
93
+ seconds: number
94
+ /** what the film said of it, its first line */
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
100
+ }
101
+
102
+ export const fresh = (stamp: Stamp, world: string): boolean => stamp.fixed === true || stamp.world === world
103
+
104
+ /** a take as a build sees it: its shot, its file, its stamp when it was filmed */
105
+ export interface Take {
106
+ shot: Shot
107
+ film: TakeFilm
108
+ file: string
109
+ stamp: Stamp | null
110
+ spec: TakeSpec
111
+ /** the words the film runs: each `@<file>` as its repo relative path */
112
+ argv?: string[]
113
+ }
114
+
115
+ /** `--refilm`: every stale take, or the takes of the named shots */
116
+ export type Refilm = 'stale' | ReadonlySet<string> | null
117
+
118
+ /** the takes to film, each group sharing one file and filmed once by its first shot: a missing take, a stale one under
119
+ * `--refilm`, or one whose shot `--refilm` names */
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[][] {
125
+ const groups = new Map<string, Take[]>()
126
+ for (const tk of takes) {
127
+ if (only !== undefined && !only.has(tk.shot.name)) continue
128
+ const g = groups.get(tk.file)
129
+ if (g) g.push(tk)
130
+ else groups.set(tk.file, [tk])
131
+ }
132
+ return [...groups.values()].filter((g) => {
133
+ const first = g[0]!
134
+ if (first.stamp === null) return true
135
+ if (refilm === 'stale') return !fresh(first.stamp, worldFor(world, first))
136
+ return refilm instanceof Set && g.some((tk) => refilm.has(tk.shot.name))
137
+ })
138
+ }
139
+
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)])]
144
+ }
145
+
146
+ /** a `film run` command with its marks filled: `{out}`, `{seconds}`, `{w}`, `{h}`, `{fps}` */
147
+ export function runCommand(f: Extract<Film, { kind: 'run' }>, o: { out: string; seconds: number | null; size: readonly [number, number]; fps: number }): string {
148
+ const q = (s: string): string => `'${s.replaceAll("'", "'\\''")}'`
149
+ return f.cmd
150
+ .replaceAll('{out}', q(o.out))
151
+ .replaceAll('{seconds}', String(o.seconds ?? 0))
152
+ .replaceAll('{w}', String(o.size[0]))
153
+ .replaceAll('{h}', String(o.size[1]))
154
+ .replaceAll('{fps}', String(o.fps))
155
+ }
156
+
157
+ /** the first frame at or after a time: a shot, a cut or a title starts on it */
158
+ export const frameAt = (seconds: number, fps: number): number => Math.ceil(seconds * fps - 1e-6) || 0
159
+
160
+ /** each shot's frames on the cut, [a, b): a cut's frames are those both shots hold; a hard cut's shots share none */
161
+ export function frameSpans(t: Pick<Trailer, 'shots' | 'fps'>): { a: number; b: number }[] {
162
+ const st = starts(t)
163
+ return t.shots.map((s, i) => ({ a: frameAt(st[i]!, t.fps), b: frameAt(st[i]! + s.length, t.fps) }))
164
+ }
165
+
166
+ /** a piece of the cut rendered alone: a section, or the shots before the first section (`start`) */
167
+ export interface Piece {
168
+ name: string
169
+ /** its shots, [first, end) */
170
+ first: number
171
+ end: number
172
+ /** the first shot its graph draws: the shot before `first` when the cut into the section overlaps it */
173
+ chain: number
174
+ /** its frames on the cut, [from, to) */
175
+ from: number
176
+ to: number
177
+ }
178
+
179
+ export const START_PIECE = 'start'
180
+
181
+ /** the cut split at its sections; the pieces' frames tile the cut */
182
+ export function pieces(t: Pick<Trailer, 'shots' | 'fps'>): Piece[] {
183
+ const spans = frameSpans(t)
184
+ const opens = t.shots.flatMap((s, i) => (i > 0 && s.section ? [i] : []))
185
+ const firsts = [0, ...opens]
186
+ return firsts.map((first, k) => {
187
+ const end = firsts[k + 1] ?? t.shots.length
188
+ const from = spans[first]!.a
189
+ const chain = first > 0 && spans[first - 1]!.b > from ? first - 1 : first
190
+ return { name: k === 0 ? (t.shots[0]?.section?.name ?? START_PIECE) : t.shots[first]!.section!.name, first, end, chain, from, to: end < t.shots.length ? spans[end]!.a : spans.at(-1)!.b }
191
+ })
192
+ }
@@ -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
+ }