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.
- package/CHANGELOG.md +12 -0
- package/package.json +2 -1
- package/src/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +158 -43
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Trailer.ts +108 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +157 -0
- package/src/cli.ts +13 -2
- package/src/core/layout.ts +10 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +5 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/shipkit-files.ts +8 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +23 -0
- package/src/trailer/build.ts +344 -0
- package/src/trailer/ffmpeg.ts +50 -0
- package/src/trailer/footage.ts +104 -0
- package/src/trailer/grammar.ts +915 -0
- package/src/trailer/graph.ts +323 -0
- package/src/trailer/load-unit.ts +14 -0
- package/src/trailer/look.ts +110 -0
- package/src/trailer/plan.ts +133 -0
- package/src/trailer/stitch.ts +150 -0
- package/src/trailer/titles.ts +274 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- 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
|
+
}
|