rvlib-shipkit 2.1.1 → 2.2.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 (56) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +2 -1
  3. package/src/assets/kits/identity-kit.ts +7 -3
  4. package/src/assets/run-assets.ts +18 -4
  5. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  6. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  7. package/src/cli/cmd_Calls.ts +53 -0
  8. package/src/cli/cmd_Commands.ts +158 -43
  9. package/src/cli/cmd_Shot.ts +63 -0
  10. package/src/cli/cmd_Start.ts +8 -4
  11. package/src/cli/cmd_Trailer.ts +108 -0
  12. package/src/cli/kit/builtins.ts +40 -0
  13. package/src/cli/kit/calls.ts +177 -0
  14. package/src/cli/kit/families.ts +67 -0
  15. package/src/cli/kit/index.ts +8 -0
  16. package/src/cli/kit/pages-check.ts +36 -0
  17. package/src/cli/kit/project-cli.ts +73 -0
  18. package/src/cli/kit/verb.ts +157 -0
  19. package/src/cli.ts +13 -2
  20. package/src/core/layout.ts +10 -2
  21. package/src/core/migrate-layout.ts +31 -8
  22. package/src/core/one-way-in-policy.ts +12 -4
  23. package/src/reflect/prompt-corpus.ts +5 -0
  24. package/src/scaffold/cli-skeleton.ts +149 -0
  25. package/src/scaffold/files/agent-index.md +2 -2
  26. package/src/scaffold/files/tsconfig.json +3 -0
  27. package/src/scaffold/files/verify-skill.md +1 -1
  28. package/src/scaffold/project-scaffold.ts +90 -12
  29. package/src/shipkit-files.ts +8 -4
  30. package/src/shot/client.ts +67 -0
  31. package/src/shot/clip-encoder.ts +146 -0
  32. package/src/shot/dom-clip.ts +124 -0
  33. package/src/shot/serve-shot.ts +54 -0
  34. package/src/shot/shooter.ts +343 -0
  35. package/src/shot/shot-ask.ts +189 -0
  36. package/src/shot/shot.shipkit.ts +14 -0
  37. package/src/smoke/run-smoke.ts +2 -2
  38. package/src/start-repo.ts +29 -14
  39. package/src/suite/judge-suites.ts +56 -10
  40. package/src/suite/select.ts +2 -5
  41. package/src/suite/suite-cli.ts +3 -2
  42. package/src/trailer/adapter.ts +23 -0
  43. package/src/trailer/build.ts +344 -0
  44. package/src/trailer/ffmpeg.ts +50 -0
  45. package/src/trailer/footage.ts +104 -0
  46. package/src/trailer/grammar.ts +915 -0
  47. package/src/trailer/graph.ts +323 -0
  48. package/src/trailer/load-unit.ts +14 -0
  49. package/src/trailer/look.ts +110 -0
  50. package/src/trailer/plan.ts +133 -0
  51. package/src/trailer/stitch.ts +150 -0
  52. package/src/trailer/titles.ts +274 -0
  53. package/src/trailer/url.ts +108 -0
  54. package/src/trailer/vertical-draw.ts +286 -0
  55. package/src/trailer/vertical.ts +148 -0
  56. package/src/trailer/vocab.ts +40 -0
@@ -0,0 +1,286 @@
1
+ // what the vertical short draws itself, full frame pngs ffmpeg lays over the cut (vertical.ts): the lockup at each
2
+ // scale of its pop with the watermark, the watermark alone for the outro, the end card and its image apart (it hops
3
+ // in). In the look's colours and face, sized by the frame's width; each cached on what it draws
4
+ import { type Canvas, createCanvas, type Image, loadImage, type SKRSContext2D } from '@napi-rs/canvas'
5
+ import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { alpha, luminance, type TrailerLook } from './look.ts'
8
+ import { face, lookFiles, registerFace } from './titles.ts'
9
+ import { POP_S, popScale, SAFE_BOTTOM, SAFE_TOP, type VerticalLook } from './vertical.ts'
10
+
11
+ /** bump when a drawing changes: every cached frame draws again */
12
+ const DRAWING = 1
13
+ /** a wordmark's width in the lockup at 1080 px wide: small, and still read on a phone */
14
+ const LOCKUP_W = 560
15
+ const WATERMARK = { margin: 40, height: 72 } as const
16
+
17
+ type Images = Map<string, Image>
18
+
19
+ /** the images a look names, loaded once; a path the brand names that is not in the repo fails loud */
20
+ async function images(root: string, vl: VerticalLook): Promise<Images> {
21
+ const out: Images = new Map()
22
+ for (const p of [vl.lockup?.image, vl.lockup?.logo, vl.watermark?.image, vl.end?.image]) {
23
+ if (!p || out.has(p)) continue
24
+ const abs = join(root, p)
25
+ if (!existsSync(abs)) throw new Error(`the brand's vertical look names ${p}, which is not in the repo (.shipkit/identity/brand.ts, trailer)`)
26
+ out.set(p, await loadImage(abs))
27
+ }
28
+ return out
29
+ }
30
+
31
+ const key = (parts: unknown): string => new Bun.CryptoHasher('sha1').update(JSON.stringify([DRAWING, parts])).digest('hex').slice(0, 16)
32
+
33
+ /** what the drawings read from disk, as it stands: a new image draws again */
34
+ const files = (root: string, look: TrailerLook, vl: VerticalLook): unknown[] => [
35
+ lookFiles(look, root),
36
+ ...[vl.lockup?.image, vl.watermark?.image, vl.end?.image].map((p) => (p && existsSync(join(root, p)) ? [p, statSync(join(root, p)).size, statSync(join(root, p)).mtimeMs] : p)),
37
+ ]
38
+
39
+ const png = (c: Canvas, file: string): string => {
40
+ writeFileSync(file, c.toBuffer('image/png'))
41
+ return file
42
+ }
43
+
44
+ /** a line of words in the face and `fill`, `*word*` in the accent, centred on x */
45
+ function words(g: SKRSContext2D, look: TrailerLook, text: string, cx: number, cy: number, px: number, fill: string): void {
46
+ face(g, look, px, 900)
47
+ const parts = text.split('*').map((t, i) => ({ t, key: i % 2 === 1 }))
48
+ let x = cx - g.measureText(parts.map((p) => p.t).join('')).width / 2
49
+ g.textAlign = 'left'
50
+ g.textBaseline = 'middle'
51
+ for (const p of parts) {
52
+ g.fillStyle = p.key ? look.accent : fill
53
+ g.fillText(p.t, x, cy)
54
+ x += g.measureText(p.t).width
55
+ }
56
+ }
57
+
58
+ /** a name as the `logo` title draws it: the edge under it, light into the accent down its height */
59
+ function name(g: SKRSContext2D, look: TrailerLook, text: string, x: number, cy: number, px: number, align: 'left' | 'center'): void {
60
+ face(g, look, px, 900)
61
+ g.textAlign = align
62
+ g.textBaseline = 'middle'
63
+ g.save()
64
+ g.lineJoin = 'round'
65
+ g.lineWidth = px * 0.14
66
+ g.strokeStyle = look.edge
67
+ g.shadowColor = alpha(look.edge, 0.55)
68
+ g.shadowBlur = px * 0.25
69
+ g.shadowOffsetY = px * 0.06
70
+ g.strokeText(text, x, cy)
71
+ g.restore()
72
+ const grad = g.createLinearGradient(0, cy - px / 2, 0, cy + px / 2)
73
+ grad.addColorStop(0.1, look.light)
74
+ grad.addColorStop(0.6, look.accent)
75
+ g.fillStyle = grad
76
+ g.fillText(text, x, cy)
77
+ }
78
+
79
+ /** the lockup from `top` at `scale`, centred: the wordmark, else the logo and the name in a row; its tag under it.
80
+ * Returns where it ends */
81
+ function lockup(g: SKRSContext2D, look: TrailerLook, lk: NonNullable<VerticalLook['lockup']>, img: Images, W: number, top: number, scale: number): number {
82
+ const k = (W / 1080) * scale
83
+ let y = top
84
+ const mark = lk.image ? img.get(lk.image) : undefined
85
+ if (mark) {
86
+ const w = LOCKUP_W * k
87
+ const h = (w * mark.height) / mark.width
88
+ g.drawImage(mark, (W - w) / 2, y, w, h)
89
+ y += h + 10 * k
90
+ } else {
91
+ const logo = lk.logo ? img.get(lk.logo) : undefined
92
+ const iconH = logo ? 112 * k : 0
93
+ const iconW = logo ? (iconH * logo.width) / logo.height : 0
94
+ const gap = logo ? 26 * k : 0
95
+ face(g, look, 88 * k, 900)
96
+ const room = W * 0.84 - iconW - gap
97
+ const px = 88 * k * Math.min(1, room / Math.max(1, g.measureText(lk.name).width))
98
+ face(g, look, px, 900)
99
+ const tw = g.measureText(lk.name).width
100
+ const rowH = Math.max(iconH, px * 1.15)
101
+ const x0 = (W - (iconW + gap + tw)) / 2
102
+ if (logo) {
103
+ g.save()
104
+ g.shadowColor = alpha(look.edge, 0.6)
105
+ g.shadowBlur = 20 * k
106
+ g.drawImage(logo, x0, y + (rowH - iconH) / 2, iconW, iconH)
107
+ g.restore()
108
+ }
109
+ name(g, look, lk.name, x0 + iconW + gap, y + rowH / 2, px, 'left')
110
+ y += rowH + 14 * k
111
+ }
112
+ if (!lk.tag) return y
113
+ const size = 30 * k
114
+ face(g, look, size, 900)
115
+ const tag = lk.tag.toUpperCase()
116
+ const spacing = 3 * k
117
+ const tw = [...tag].reduce((n, ch) => n + g.measureText(ch).width + spacing, -spacing)
118
+ const [pw, ph] = [tw + size * 1.2, Math.round(size * 1.5)]
119
+ const x = (W - pw) / 2
120
+ g.fillStyle = look.chip.fill
121
+ g.strokeStyle = look.edge
122
+ g.lineWidth = 4 * k
123
+ g.beginPath()
124
+ g.roundRect(x, y, pw, ph, ph / 2)
125
+ g.fill()
126
+ g.stroke()
127
+ g.fillStyle = look.chip.ink
128
+ g.textAlign = 'left'
129
+ g.textBaseline = 'middle'
130
+ let tx = x + size * 0.6
131
+ for (const ch of tag) {
132
+ g.fillText(ch, tx, y + ph / 2 + 2 * k)
133
+ tx += g.measureText(ch).width + spacing
134
+ }
135
+ return y + ph
136
+ }
137
+
138
+ /** the watermark in its bottom corner: the image, the word beside it */
139
+ function watermark(g: SKRSContext2D, look: TrailerLook, wm: NonNullable<VerticalLook['watermark']>, img: Images, [W, H]: readonly [number, number]): void {
140
+ const k = W / 1080
141
+ const h = WATERMARK.height * k
142
+ const m = WATERMARK.margin * k
143
+ const pic = wm.image ? img.get(wm.image) : undefined
144
+ const pw = pic ? (h * pic.width) / pic.height : 0
145
+ const px = h * 0.46
146
+ face(g, look, px, 900)
147
+ const tw = wm.text ? g.measureText(wm.text).width : 0
148
+ const gap = pic && wm.text ? 12 * k : 0
149
+ const width = pw + gap + tw
150
+ const x = wm.corner === 'left' ? m : W - m - width
151
+ const top = H - m - h
152
+ g.save()
153
+ g.globalAlpha = 0.92
154
+ g.shadowColor = alpha(look.edge, 0.5)
155
+ g.shadowBlur = 12 * k
156
+ if (pic) g.drawImage(pic, x, top, pw, h)
157
+ if (wm.text) {
158
+ g.textAlign = 'left'
159
+ g.textBaseline = 'middle'
160
+ g.fillStyle = look.light
161
+ g.fillText(wm.text, x + pw + gap, top + h / 2)
162
+ }
163
+ g.restore()
164
+ }
165
+
166
+ /** what the short wears, frame by frame of the lockup's pop (one frame when it has no lockup), the watermark drawn
167
+ * in: `wear-<key>-NN.png`; null when it wears nothing */
168
+ export async function drawWear(dir: string, root: string, look: TrailerLook, vl: VerticalLook, size: readonly [number, number], fps: number): Promise<{ pattern: string; frames: number } | null> {
169
+ if (!vl.lockup && !vl.watermark) return null
170
+ const frames = vl.lockup ? Math.round(POP_S * fps) + 1 : 1
171
+ const stem = `wear-${key([size, fps, look, vl.lockup, vl.watermark, files(root, look, vl)])}`
172
+ const pattern = join(dir, `${stem}-%02d.png`)
173
+ const at = (f: number): string => join(dir, `${stem}-${String(f).padStart(2, '0')}.png`)
174
+ if (existsSync(at(frames - 1))) return { pattern, frames }
175
+ mkdirSync(dir, { recursive: true })
176
+ registerFace(look, root)
177
+ const img = await images(root, vl)
178
+ const [W, H] = size
179
+ const top = Math.round(H * SAFE_TOP) + 8 * (W / 1080)
180
+ const probe = createCanvas(W, H).getContext('2d')
181
+ const h1 = vl.lockup ? lockup(probe, look, vl.lockup, img, W, 0, 1) : 0
182
+ for (let f = 0; f < frames; f++) {
183
+ const c = createCanvas(W, H)
184
+ const g = c.getContext('2d')
185
+ if (vl.lockup) {
186
+ // a soft shade from the top, then the lockup scaled about its middle: the pop grows in place
187
+ const shade = g.createLinearGradient(0, 0, 0, top + h1 + 120)
188
+ shade.addColorStop(0, alpha(look.edge, 0.5))
189
+ shade.addColorStop(1, alpha(look.edge, 0))
190
+ g.fillStyle = shade
191
+ g.fillRect(0, 0, W, top + h1 + 120)
192
+ const s = popScale(f / fps)
193
+ const hk = lockup(probe, look, vl.lockup, img, W, 0, s)
194
+ lockup(g, look, vl.lockup, img, W, top + (h1 - hk) / 2, s)
195
+ }
196
+ if (vl.watermark) watermark(g, look, vl.watermark, img, size)
197
+ png(c, at(f))
198
+ }
199
+ return { pattern, frames }
200
+ }
201
+
202
+ /** the watermark alone, laid over the outro; null without one */
203
+ export async function drawMark(dir: string, root: string, look: TrailerLook, vl: VerticalLook, size: readonly [number, number]): Promise<string | null> {
204
+ if (!vl.watermark) return null
205
+ const file = join(dir, `mark-${key([size, look, vl.watermark, files(root, look, vl)])}.png`)
206
+ if (existsSync(file)) return file
207
+ mkdirSync(dir, { recursive: true })
208
+ registerFace(look, root)
209
+ const c = createCanvas(size[0], size[1])
210
+ watermark(c.getContext('2d'), look, vl.watermark, await images(root, vl), size)
211
+ return png(c, file)
212
+ }
213
+
214
+ /** the end card: the look's ground, a seam round it, a glow where its image lands, the name, a rule, the lines; the
215
+ * image apart, which ffmpeg hops in at `at` */
216
+ export async function drawEnd(dir: string, root: string, look: TrailerLook, vl: VerticalLook, size: readonly [number, number]): Promise<{ card: string; hero: { file: string; x: number; y: number } | null } | null> {
217
+ const end = vl.end
218
+ if (!end) return null
219
+ const stem = `end-${key([size, look, end, files(root, look, vl)])}`
220
+ const [cardFile, heroFile, whereFile] = ['card.png', 'hero.png', 'json'].map((x) => join(dir, `${stem}.${x}`)) as [string, string, string]
221
+ if (existsSync(cardFile) && existsSync(whereFile)) return { card: cardFile, hero: JSON.parse(await Bun.file(whereFile).text()) as { file: string; x: number; y: number } | null }
222
+ mkdirSync(dir, { recursive: true })
223
+ registerFace(look, root)
224
+ const img = await images(root, vl)
225
+ const [W, H] = size
226
+ const k = W / 1080
227
+ const c = createCanvas(W, H)
228
+ const g = c.getContext('2d')
229
+ const ground = g.createRadialGradient(W / 2, H * 0.45, 0, W / 2, H * 0.45, Math.hypot(W, H) * 0.6)
230
+ ground.addColorStop(0, look.ground[0])
231
+ ground.addColorStop(1, look.ground[1])
232
+ g.fillStyle = ground
233
+ g.fillRect(0, 0, W, H)
234
+ const pic = end.image ? img.get(end.image) : undefined
235
+ let [pw, ph] = pic ? [520 * k, (520 * k * pic.height) / pic.width] : [0, 0]
236
+ if (ph > H * 0.24) [pw, ph] = [(pw * H * 0.24) / ph, H * 0.24]
237
+ // the lines as large as the card's width allows, read on a phone
238
+ let linePx = 64 * k
239
+ for (; linePx > 40 * k; linePx -= 2 * k) {
240
+ face(g, look, linePx, 900)
241
+ if (Math.max(0, ...end.lines.map((l) => g.measureText(l.replaceAll('*', '')).width)) <= W - 200 * k) break
242
+ }
243
+ const lineStep = Math.round(linePx * 1.5)
244
+ face(g, look, 120 * k, 900)
245
+ const namePx = 120 * k * Math.min(1, (W * 0.72) / Math.max(1, g.measureText(end.name).width))
246
+ const block = (pic ? ph + 40 * k : 0) + namePx * 1.1 + 24 * k + 8 * k + (end.lines.length ? 50 * k + end.lines.length * lineStep : 0)
247
+ const top = Math.round(H * SAFE_TOP + (H * (1 - SAFE_TOP - SAFE_BOTTOM) - block) / 2)
248
+ const at = { x: Math.round((W - pw) / 2), y: top }
249
+ if (pic) {
250
+ const glow = g.createRadialGradient(W / 2, top + ph * 0.55, 20 * k, W / 2, top + ph * 0.55, 460 * k)
251
+ glow.addColorStop(0, alpha(look.accent, 0.38))
252
+ glow.addColorStop(1, alpha(look.accent, 0))
253
+ g.fillStyle = glow
254
+ g.fillRect(0, 0, W, H)
255
+ }
256
+ g.setLineDash([22 * k, 14 * k])
257
+ g.lineWidth = 6 * k
258
+ g.strokeStyle = alpha(look.accent, 0.75)
259
+ g.beginPath()
260
+ g.roundRect(44 * k, 44 * k, W - 88 * k, H - 88 * k, 56 * k)
261
+ g.stroke()
262
+ g.setLineDash([])
263
+ // the lines in the plate's ink on a light ground, else in the light
264
+ const ink = luminance(look.ground[0]) > 0.5 ? look.plate.ink : look.light
265
+ let y = top + (pic ? ph + 40 * k : 0)
266
+ name(g, look, end.name, W / 2, y + (namePx * 1.1) / 2, namePx, 'center')
267
+ y += namePx * 1.1 + 24 * k
268
+ g.fillStyle = look.accent
269
+ g.beginPath()
270
+ g.roundRect(W / 2 - 60 * k, y, 120 * k, 8 * k, 4 * k)
271
+ g.fill()
272
+ y += 8 * k + 50 * k
273
+ for (const l of end.lines) {
274
+ words(g, look, l, W / 2, y + lineStep / 2, linePx, ink)
275
+ y += lineStep
276
+ }
277
+ png(c, cardFile)
278
+ let hero: { file: string; x: number; y: number } | null = null
279
+ if (pic) {
280
+ const hc = createCanvas(Math.round(pw), Math.round(ph))
281
+ hc.getContext('2d').drawImage(pic, 0, 0, Math.round(pw), Math.round(ph))
282
+ hero = { file: png(hc, heroFile), ...at }
283
+ }
284
+ writeFileSync(whereFile, JSON.stringify(hero))
285
+ return { card: cardFile, hero }
286
+ }
@@ -0,0 +1,148 @@
1
+ // the vertical short: the stitched cut finished with what the brand's `trailer.vertical` data draws (a lockup popping
2
+ // in over every frame, a watermark in a bottom corner), then an outro clip and the end card its image hops onto. The
3
+ // look resolved from that data and the ffmpeg arguments of the finish, as data. Pure: vertical-draw.ts draws the
4
+ // frames, build.ts runs the arguments
5
+ import type { Vertical } from './grammar.ts'
6
+ import { ENCODERS, type Encoder } from './graph.ts'
7
+ import type { BrandLike, TrailerLook } from './look.ts'
8
+
9
+ /** the platforms' own overlays: their tabs over the top tenth, the caption and the buttons over the bottom quarter */
10
+ export const SAFE_TOP = 0.1
11
+ export const SAFE_BOTTOM = 0.25
12
+
13
+ /** the lockup's elastic pop as the short opens: from 0.8 through about 1.06 at 0.12 s, settled to 1 by POP_S */
14
+ export const POP_S = 0.42
15
+ export const popScale = (t: number): number => (t >= POP_S ? 1 : 1 - 0.2 * Math.exp(-t / 0.1) * Math.cos((2 * Math.PI * t) / 0.24))
16
+
17
+ /** the cross fade into the end card, and the card's length with it: a 1.2 s hold */
18
+ export const END_FADE = 0.5
19
+ export const END_S = END_FADE + 1.2
20
+
21
+ export interface VerticalLook {
22
+ lockup: { image: string | null; logo: string | null; name: string; tag: string | null } | null
23
+ watermark: { image: string | null; text: string | null; corner: 'left' | 'right' } | null
24
+ end: { image: string | null; name: string; lines: string[]; seconds: number } | null
25
+ }
26
+
27
+ /** the brand's vertical data with every value it leaves out filled: the logo, the brand's name (`name` for a repo
28
+ * with no brand), its tagline; `mark` the cut's own corner for the watermark */
29
+ export function verticalOf(brand: BrandLike | null, look: TrailerLook, name: string, mark?: Vertical['mark']): VerticalLook {
30
+ const v = brand?.trailer?.vertical ?? {}
31
+ const named = brand?.name ?? name
32
+ const lk = v.lockup === false ? null : (v.lockup ?? {})
33
+ const wm = v.watermark === false ? null : (v.watermark ?? {})
34
+ const end = v.end === false ? null : (v.end ?? {})
35
+ const markImage = wm && ('image' in wm ? (wm.image ?? null) : look.logo)
36
+ return {
37
+ lockup: lk && { image: lk.image ?? null, logo: look.logo, name: lk.name ?? named, tag: lk.tag ?? null },
38
+ watermark: wm && { image: markImage, text: 'text' in wm ? (wm.text ?? null) : markImage ? null : named, corner: mark ?? wm.corner ?? 'left' },
39
+ end: end && { image: end.image ?? look.logo, name: end.name ?? named, lines: end.lines ?? (brand?.tagline ? [brand.tagline] : []), seconds: end.seconds ?? END_S },
40
+ }
41
+ }
42
+
43
+ /** what the finish reads: the cut, the frames drawn over it, the outro clip, the end card */
44
+ export interface FinishInput {
45
+ cut: string
46
+ /** the cut's length, seconds */
47
+ seconds: number
48
+ size: readonly [number, number]
49
+ fps: number
50
+ /** the lockup's pop, frame by frame, the watermark drawn in (`%02d`), its last frame held; null: nothing over the cut */
51
+ wear: { pattern: string; frames: number } | null
52
+ /** the watermark alone, a full frame png laid over the outro */
53
+ mark: string | null
54
+ outro: { clip: string; seconds: number; audio: boolean } | null
55
+ end: { card: string; seconds: number; hero: { file: string; x: number; y: number } | null } | null
56
+ encoder: Encoder
57
+ }
58
+
59
+ const n3 = (x: number): string => (+x.toFixed(4)).toString()
60
+
61
+ /** the image of the end card hops in once its fade is under way: up from below, eased, then two small bounces settling */
62
+ export function hopY(y: number, k: number): string {
63
+ const p0 = END_FADE * 0.6
64
+ const land = p0 + 0.45
65
+ return `${y}+${n3(90 * k)}*pow(1-min(max(t-${n3(p0)},0)/0.45,1),3)-${n3(26 * k)}*abs(sin(PI*max(t-${n3(land)},0)/0.42))*exp(-2.2*max(t-${n3(land)},0))*gte(t,${n3(land)})`
66
+ }
67
+
68
+ /** the ffmpeg arguments of the finish: the cut under what it wears, a hard cut into the outro, a cross fade into the
69
+ * end card, the sound carried under each and faded out with the card */
70
+ export function finishArgs(a: FinishInput, out: string): string[] {
71
+ const [W, H] = a.size
72
+ const fps = a.fps
73
+ const inputs: string[] = []
74
+ const graph: string[] = []
75
+ let n = 0
76
+ const input = (...args: string[]): number => {
77
+ inputs.push(...args)
78
+ return n++
79
+ }
80
+ const held = (seconds: number, file: string): number => input('-loop', '1', '-framerate', String(fps), '-t', n3(seconds), '-i', file)
81
+ const even = `setsar=1,format=yuv420p,settb=AVTB,fps=${fps}`
82
+
83
+ const c = input('-i', a.cut)
84
+ if (a.wear) {
85
+ // a still overlay is one frame held (eof_action repeat), never a looped stream: no frames queue in the graph
86
+ const w = input('-framerate', String(fps), '-i', a.wear.pattern)
87
+ graph.push(`[${c}:v][${w}:v]overlay=0:0:eof_action=repeat,${even}[v0]`)
88
+ } else graph.push(`[${c}:v]${even}[v0]`)
89
+ graph.push(`[${c}:a]aresample=48000,aformat=channel_layouts=stereo,apad,atrim=duration=${n3(a.seconds)}[a0]`)
90
+ const lengths = [a.seconds]
91
+ const fades = [0]
92
+
93
+ if (a.outro) {
94
+ const i = lengths.length
95
+ const o = input('-i', a.outro.clip)
96
+ const fit = `[${o}:v]scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},setsar=1,fps=${fps},trim=duration=${n3(a.outro.seconds)},setpts=PTS-STARTPTS`
97
+ if (a.mark) {
98
+ const m = input('-i', a.mark)
99
+ graph.push(`${fit}[ov${i}]`, `[ov${i}][${m}:v]overlay=0:0:eof_action=repeat,${even}[v${i}]`)
100
+ } else graph.push(`${fit},${even}[v${i}]`)
101
+ graph.push(
102
+ a.outro.audio
103
+ ? `[${o}:a]aresample=48000,aformat=channel_layouts=stereo,apad,atrim=duration=${n3(a.outro.seconds)}[a${i}]`
104
+ : `anullsrc=r=48000:cl=stereo,atrim=duration=${n3(a.outro.seconds)}[a${i}]`,
105
+ )
106
+ lengths.push(a.outro.seconds)
107
+ fades.push(0)
108
+ }
109
+
110
+ if (a.end) {
111
+ const i = lengths.length
112
+ const s = a.end.seconds
113
+ const card = held(s, a.end.card)
114
+ if (a.end.hero) {
115
+ const h = held(s, a.end.hero.file)
116
+ graph.push(`[${h}:v]format=rgba,fade=in:st=${n3(END_FADE * 0.6)}:d=0.3:alpha=1[hero]`)
117
+ graph.push(`[${card}:v][hero]overlay=x=${a.end.hero.x}:y='${hopY(a.end.hero.y, W / 1080)}':eval=frame:shortest=1,${even}[v${i}]`)
118
+ } else graph.push(`[${card}:v]${even}[v${i}]`)
119
+ graph.push(`anullsrc=r=48000:cl=stereo,atrim=duration=${n3(s)}[a${i}]`)
120
+ lengths.push(s)
121
+ fades.push(Math.min(END_FADE, s / 2))
122
+ }
123
+
124
+ let v = 'v0'
125
+ let au = 'a0'
126
+ let offset = 0
127
+ for (let i = 1; i < lengths.length; i++) {
128
+ const fade = fades[i]!
129
+ offset += lengths[i - 1]! - fade
130
+ if (fade === 0) graph.push(`[${v}][v${i}]concat=n=2:v=1:a=0,settb=AVTB,fps=${fps}[xv${i}]`, `[${au}][a${i}]concat=n=2:v=0:a=1[xa${i}]`)
131
+ else graph.push(`[${v}][v${i}]xfade=transition=fade:duration=${n3(fade)}:offset=${n3(offset)}[xv${i}]`, `[${au}][a${i}]acrossfade=d=${n3(fade)}[xa${i}]`)
132
+ v = `xv${i}`
133
+ au = `xa${i}`
134
+ }
135
+ return [
136
+ '-hide_banner', '-loglevel', 'error', '-y',
137
+ ...inputs,
138
+ '-filter_complex', graph.join(';\n'),
139
+ '-map', `[${v}]`, '-map', `[${au}]`,
140
+ ...ENCODERS[a.encoder], '-r', String(fps),
141
+ '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart',
142
+ out,
143
+ ]
144
+ }
145
+
146
+ /** the finished short's length */
147
+ export const finishSeconds = (a: Pick<FinishInput, 'seconds' | 'outro' | 'end'>): number =>
148
+ a.seconds + (a.outro?.seconds ?? 0) + (a.end ? a.end.seconds - Math.min(END_FADE, a.end.seconds / 2) : 0)
@@ -0,0 +1,40 @@
1
+ // the film words of a repo: its project CLI's film rows (the CLI kit's `film: 'still' | 'clip'` mark), read from
2
+ // `<cli> commands --json`, the `cli` script of package.json. Read only when a film line does not read as a core
3
+ // film (build.ts load), so a cut of core films never spawns anything
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import type { FilmVerb } from './grammar.ts'
7
+
8
+ /** the project CLI's argv from the `cli` script of package.json, null when there is none */
9
+ export function projectCli(root: string): string[] | null {
10
+ const pkg = join(root, 'package.json')
11
+ if (!existsSync(pkg)) return null
12
+ const cli = (JSON.parse(readFileSync(pkg, 'utf8')) as { scripts?: Record<string, string> }).scripts?.cli
13
+ if (typeof cli !== 'string' || cli.trim() === '') return null
14
+ return cli.trim().split(/\s+/)
15
+ }
16
+
17
+ /** the film rows in a `commands --json` answer: an array of rows, or `{ commands: [...] }` */
18
+ export function filmRows(json: unknown): FilmVerb[] {
19
+ const rows = Array.isArray(json) ? json : Array.isArray((json as { commands?: unknown })?.commands) ? (json as { commands: unknown[] }).commands : []
20
+ return rows.flatMap((r) => {
21
+ const o = r as { path?: unknown; film?: unknown; description?: unknown }
22
+ if (o.film !== 'still' && o.film !== 'clip') return []
23
+ const path = Array.isArray(o.path) ? o.path.map(String) : typeof o.path === 'string' ? o.path.trim().split(/\s+/) : []
24
+ if (path.length === 0) return []
25
+ return [{ path, film: o.film, ...(typeof o.description === 'string' ? { description: o.description } : {}) }]
26
+ })
27
+ }
28
+
29
+ /** the project CLI's film rows; null when the repo has no project CLI. A CLI that does not answer is an error */
30
+ export function readVocab(root: string, cli: readonly string[] | null): FilmVerb[] | null {
31
+ if (cli === null) return null
32
+ const r = Bun.spawnSync([...cli, 'commands', '--json'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
33
+ const text = r.stdout.toString()
34
+ try {
35
+ if (r.exitCode !== 0) throw new Error(`exit ${r.exitCode}`)
36
+ return filmRows(JSON.parse(text))
37
+ } catch (e) {
38
+ throw new Error(`the project CLI (${cli.join(' ')}) did not answer \`commands --json\` with its rows: ${(e as Error).message}${r.stderr.length ? `\n${r.stderr.toString().trim().split('\n').slice(-4).join('\n')}` : ''}`)
39
+ }
40
+ }