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.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- 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 +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -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 +167 -0
- package/src/cli.ts +15 -2
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +12 -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 +15 -114
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -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/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +12 -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 +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +233 -0
- package/src/trailer/titles.ts +379 -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 +59 -0
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// the trailer's drawn frames: one transparent PNG per title, cut to what it draws, and the card's ground, in the repo's
|
|
2
|
+
// look (look.ts). Seven styles: logo, big, caption, corner, line, and a short's words (short, progress) in the look's
|
|
3
|
+
// short face. Cached on what they draw, so a title edit draws one PNG
|
|
4
|
+
import { createCanvas, GlobalFonts, loadImage, type Image, type SKRSContext2D } from '@napi-rs/canvas'
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import sharp from 'sharp'
|
|
8
|
+
import type { Title, TitleStyle } from './grammar.ts'
|
|
9
|
+
import { alpha, darker, shortLook, type TrailerLook } from './look.ts'
|
|
10
|
+
|
|
11
|
+
/** bump when a style's drawing changes: every cached title draws again */
|
|
12
|
+
const DRAWING = 1
|
|
13
|
+
|
|
14
|
+
/** where a title stands: as `place` says; a short's caption in the frame's middle, a progress bar at the top; else
|
|
15
|
+
* the bottom */
|
|
16
|
+
export type TitlePlace = 'top' | 'middle' | 'bottom'
|
|
17
|
+
export const placeOf = (ti: Pick<Title, 'place' | 'style'>): TitlePlace => ti.place ?? (ti.style === 'short' ? 'middle' : ti.style === 'progress' ? 'top' : 'bottom')
|
|
18
|
+
|
|
19
|
+
/** a short's words centred on these shares of the height, inside the phones' safe zone: under the lockup (`top`), the
|
|
20
|
+
* frame's middle, where the end card's lines stand (`bottom`) */
|
|
21
|
+
export const SHORT_Y: Record<TitlePlace, number> = { top: 0.31, middle: 0.5, bottom: 0.645 }
|
|
22
|
+
|
|
23
|
+
/** a title's PNG cut to what it draws, and where it sits on the frame: the stitch blends that box, never the whole frame */
|
|
24
|
+
export interface TitleImage {
|
|
25
|
+
file: string
|
|
26
|
+
x: number
|
|
27
|
+
y: number
|
|
28
|
+
w: number
|
|
29
|
+
h: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const registered = new Set<string>()
|
|
33
|
+
/** the look's font files registered once under their families */
|
|
34
|
+
export function registerFace(look: TrailerLook, root: string): void {
|
|
35
|
+
for (const f of [look.font, look.short.font]) {
|
|
36
|
+
if (!f.file || registered.has(f.file)) continue
|
|
37
|
+
const abs = join(root, f.file)
|
|
38
|
+
if (!existsSync(abs)) throw new Error(`the brand's trailer font ${f.file} is not in the repo`)
|
|
39
|
+
GlobalFonts.registerFromPath(abs, f.family)
|
|
40
|
+
registered.add(f.file)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const face = (g: SKRSContext2D, look: TrailerLook, px: number, weight = look.font.weight): void => {
|
|
45
|
+
const fam = look.font.family.includes(',') ? look.font.family : `"${look.font.family}"`
|
|
46
|
+
g.font = `${weight} ${Math.round(px)}px ${fam}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** the text cut into lines no wider than `max`; `\n` breaks a line */
|
|
50
|
+
function wrap(g: SKRSContext2D, text: string, max: number): string[] {
|
|
51
|
+
const out: string[] = []
|
|
52
|
+
for (const para of text.split('\\n')) {
|
|
53
|
+
let line = ''
|
|
54
|
+
for (const w of para.split(/\s+/).filter(Boolean)) {
|
|
55
|
+
const next = line ? `${line} ${w}` : w
|
|
56
|
+
if (line && g.measureText(next).width > max) {
|
|
57
|
+
out.push(line)
|
|
58
|
+
line = w
|
|
59
|
+
} else line = next
|
|
60
|
+
}
|
|
61
|
+
out.push(line)
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** a rounded plate with a soft drop under it */
|
|
67
|
+
function plate(g: SKRSContext2D, x: number, y: number, w: number, h: number, r: number, k: number, fill: string, line: string, edge: string): void {
|
|
68
|
+
g.save()
|
|
69
|
+
g.shadowColor = alpha(edge, 0.4)
|
|
70
|
+
g.shadowBlur = 18 * k
|
|
71
|
+
g.shadowOffsetY = 6 * k
|
|
72
|
+
g.fillStyle = fill
|
|
73
|
+
g.beginPath()
|
|
74
|
+
g.roundRect(x, y, w, h, r)
|
|
75
|
+
g.fill()
|
|
76
|
+
g.restore()
|
|
77
|
+
g.lineWidth = 3 * k
|
|
78
|
+
g.strokeStyle = line
|
|
79
|
+
g.beginPath()
|
|
80
|
+
g.roundRect(x, y, w, h, r)
|
|
81
|
+
g.stroke()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** words in the light on a thick edge outline, centred on `x`, its top at `y` */
|
|
85
|
+
function outlined(g: SKRSContext2D, look: TrailerLook, text: string, x: number, y: number, size: number, k: number, fill: string): void {
|
|
86
|
+
face(g, look, size)
|
|
87
|
+
g.textAlign = 'center'
|
|
88
|
+
g.textBaseline = 'top'
|
|
89
|
+
g.lineJoin = 'round'
|
|
90
|
+
g.lineWidth = Math.round(size * 0.16)
|
|
91
|
+
g.strokeStyle = look.edge
|
|
92
|
+
g.shadowColor = alpha(look.edge, 0.5)
|
|
93
|
+
g.shadowBlur = 18 * k
|
|
94
|
+
g.shadowOffsetY = 4 * k
|
|
95
|
+
g.strokeText(text, x, y)
|
|
96
|
+
g.shadowColor = 'transparent'
|
|
97
|
+
g.fillStyle = fill
|
|
98
|
+
g.fillText(text, x, y)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** a line of words, `*word*` in the accent, the whole line centred on `top` */
|
|
102
|
+
function starred(g: SKRSContext2D, look: TrailerLook, line: string, W: number, top: number, size: number, k: number): void {
|
|
103
|
+
const words = line.split(' ').map((x) => ({ text: x.replaceAll('*', ''), key: x.includes('*') }))
|
|
104
|
+
face(g, look, size)
|
|
105
|
+
const space = g.measureText(' ').width
|
|
106
|
+
let x = (W - (words.reduce((n, w) => n + g.measureText(w.text).width, 0) + space * (words.length - 1))) / 2
|
|
107
|
+
for (const w of words) {
|
|
108
|
+
const ww = g.measureText(w.text).width
|
|
109
|
+
outlined(g, look, w.text, x + ww / 2, top, size, k, w.key ? look.accent : look.light)
|
|
110
|
+
x += ww + space
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** a short's caption: the words large, centred on `y` (a share of the height), a `\n` breaking the line, over a soft
|
|
115
|
+
* band of shade; sized by the frame's width */
|
|
116
|
+
function drawShort(g: SKRSContext2D, look: TrailerLook, text: string, y: number, W: number, H: number): void {
|
|
117
|
+
const k = W / 1080
|
|
118
|
+
const size = Math.round(100 * k)
|
|
119
|
+
face(g, look, size)
|
|
120
|
+
const lines = text.split('\\n').flatMap((part) => wrap(g, part, W - 100 * k))
|
|
121
|
+
const lead = Math.round(size * 1.14)
|
|
122
|
+
const top = y * H - (lines.length * lead) / 2
|
|
123
|
+
const band = g.createLinearGradient(0, top - 140 * k, 0, top + lines.length * lead + 140 * k)
|
|
124
|
+
band.addColorStop(0, alpha(look.edge, 0))
|
|
125
|
+
band.addColorStop(0.5, alpha(look.edge, 0.42))
|
|
126
|
+
band.addColorStop(1, alpha(look.edge, 0))
|
|
127
|
+
g.fillStyle = band
|
|
128
|
+
g.fillRect(0, top - 140 * k, W, lines.length * lead + 280 * k)
|
|
129
|
+
lines.forEach((line, i) => starred(g, look, line, W, top + i * lead, size, k))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** a progress step: its name centred on `y` (a share of the height), a row of notches under it, `i` of `n` filled in
|
|
133
|
+
* the accent, the newest lit; measured at 1080 px wide */
|
|
134
|
+
function drawProgress(g: SKRSContext2D, look: TrailerLook, name: string, i: number, n: number, y: number, W: number, H: number): void {
|
|
135
|
+
const k = W / 1080
|
|
136
|
+
const size = Math.round(84 * k)
|
|
137
|
+
const lead = Math.round(size * 1.14)
|
|
138
|
+
const [width, gap, notch, under] = [700 * k, 14 * k, 28 * k, 24 * k]
|
|
139
|
+
const top = y * H - (lead + under + notch) / 2
|
|
140
|
+
const band = g.createLinearGradient(0, top - 110 * k, 0, top + lead + under + notch + 110 * k)
|
|
141
|
+
band.addColorStop(0, alpha(look.edge, 0))
|
|
142
|
+
band.addColorStop(0.5, alpha(look.edge, 0.36))
|
|
143
|
+
band.addColorStop(1, alpha(look.edge, 0))
|
|
144
|
+
g.fillStyle = band
|
|
145
|
+
g.fillRect(0, top - 110 * k, W, lead + under + notch + 220 * k)
|
|
146
|
+
starred(g, look, name, W, top, size, k)
|
|
147
|
+
const nw = (width - gap * (n - 1)) / n
|
|
148
|
+
const bar = top + lead + under
|
|
149
|
+
for (let j = 0; j < n; j++) {
|
|
150
|
+
const nx = (W - width) / 2 + j * (nw + gap)
|
|
151
|
+
g.save()
|
|
152
|
+
if (j === i - 1) {
|
|
153
|
+
g.shadowColor = alpha(look.accent, 0.9)
|
|
154
|
+
g.shadowBlur = 22 * k
|
|
155
|
+
}
|
|
156
|
+
g.beginPath()
|
|
157
|
+
g.roundRect(nx, bar, nw, notch, notch / 2)
|
|
158
|
+
g.fillStyle = j < i ? look.accent : alpha(look.light, 0.32)
|
|
159
|
+
g.fill()
|
|
160
|
+
g.restore()
|
|
161
|
+
g.lineWidth = 5 * k
|
|
162
|
+
g.strokeStyle = look.edge
|
|
163
|
+
g.beginPath()
|
|
164
|
+
g.roundRect(nx, bar, nw, notch, notch / 2)
|
|
165
|
+
g.stroke()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** one title over a transparent frame. A title's `\n` breaks its line; a logo's second line is its subtitle */
|
|
170
|
+
export function drawTitle(text: string, style: TitleStyle, [W, H]: readonly [number, number], place: TitlePlace, look: TrailerLook, logo: Image | null, step?: Title['step']): Buffer {
|
|
171
|
+
const top = place === 'top'
|
|
172
|
+
const c = createCanvas(W, H)
|
|
173
|
+
const g = c.getContext('2d')
|
|
174
|
+
const k = Math.min(H, W) / 1080
|
|
175
|
+
g.textAlign = 'center'
|
|
176
|
+
g.textBaseline = 'middle'
|
|
177
|
+
const [first = '', ...more] = text.split('\\n')
|
|
178
|
+
if (style === 'short' || style === 'progress') {
|
|
179
|
+
const sl = shortLook(look)
|
|
180
|
+
if (style === 'short') drawShort(g, sl, text, SHORT_Y[place], W, H)
|
|
181
|
+
else drawProgress(g, sl, text, step?.i ?? 1, step?.n ?? 1, SHORT_Y[place], W, H)
|
|
182
|
+
} else if (style === 'logo') {
|
|
183
|
+
// the logo image over the word, the word in the accent over the edge and its soft drop, the subtitle spaced under it
|
|
184
|
+
const glow = g.createRadialGradient(W / 2, H * 0.48, 0, W / 2, H * 0.48, Math.max(W, H) * 0.45)
|
|
185
|
+
glow.addColorStop(0, alpha(look.edge, 0.45))
|
|
186
|
+
glow.addColorStop(1, alpha(look.edge, 0))
|
|
187
|
+
g.fillStyle = glow
|
|
188
|
+
g.fillRect(0, 0, W, H)
|
|
189
|
+
const markH = logo ? Math.min(H * 0.3, W * 0.3) : 0
|
|
190
|
+
face(g, look, 170 * k, 900)
|
|
191
|
+
const kl = Math.min(1, (W * 0.78) / (g.measureText(first).width + 16 * k))
|
|
192
|
+
const px = 170 * k * kl
|
|
193
|
+
const subPx = 34 * k * Math.max(kl, 0.7)
|
|
194
|
+
const block = markH + (markH ? px * 0.25 : 0) + (first ? px : 0) + (more.length ? subPx * 2 : 0)
|
|
195
|
+
let y = H * 0.48 - block / 2
|
|
196
|
+
if (logo) {
|
|
197
|
+
const w = (logo.width / logo.height) * markH
|
|
198
|
+
g.save()
|
|
199
|
+
g.shadowColor = alpha(look.edge, 0.6)
|
|
200
|
+
g.shadowBlur = 30 * k
|
|
201
|
+
g.drawImage(logo, W / 2 - w / 2, y, w, markH)
|
|
202
|
+
g.restore()
|
|
203
|
+
y += markH + px * 0.25
|
|
204
|
+
}
|
|
205
|
+
if (first) {
|
|
206
|
+
const cy = y + px / 2
|
|
207
|
+
face(g, look, px, 900)
|
|
208
|
+
g.save()
|
|
209
|
+
g.shadowColor = alpha(look.edge, 0.6)
|
|
210
|
+
g.shadowBlur = 30 * k
|
|
211
|
+
g.shadowOffsetY = 12 * k
|
|
212
|
+
g.lineJoin = 'round'
|
|
213
|
+
g.lineWidth = 16 * k * kl
|
|
214
|
+
g.strokeStyle = look.edge
|
|
215
|
+
g.strokeText(first, W / 2, cy + 3 * k)
|
|
216
|
+
g.restore()
|
|
217
|
+
const grad = g.createLinearGradient(0, cy - px / 2, 0, cy + px / 2)
|
|
218
|
+
grad.addColorStop(0.1, look.light)
|
|
219
|
+
grad.addColorStop(0.55, look.accent)
|
|
220
|
+
grad.addColorStop(1, darker(look.accent, 0.2))
|
|
221
|
+
g.fillStyle = grad
|
|
222
|
+
g.fillText(first, W / 2, cy)
|
|
223
|
+
y += px
|
|
224
|
+
}
|
|
225
|
+
if (more.length) {
|
|
226
|
+
face(g, look, subPx, 800)
|
|
227
|
+
;(g as unknown as { letterSpacing: string }).letterSpacing = `${Math.round(subPx * 0.42)}px`
|
|
228
|
+
g.shadowColor = alpha(look.edge, 0.9)
|
|
229
|
+
g.shadowBlur = subPx * 0.42
|
|
230
|
+
g.fillStyle = look.light
|
|
231
|
+
g.fillText(more.join(' ').toUpperCase(), W / 2, y + subPx)
|
|
232
|
+
}
|
|
233
|
+
} else if (style === 'big') {
|
|
234
|
+
// a strong display line in the upper third: light words in a thick edge outline, an accent under-glow
|
|
235
|
+
const px = 112 * k
|
|
236
|
+
face(g, look, px, 900)
|
|
237
|
+
const lines = wrap(g, [first, ...more].join(' '), W * 0.82)
|
|
238
|
+
lines.forEach((l, i) => {
|
|
239
|
+
const y = H * 0.26 + (i - (lines.length - 1) / 2) * px * 1.12
|
|
240
|
+
g.save()
|
|
241
|
+
g.shadowColor = alpha(look.edge, 0.55)
|
|
242
|
+
g.shadowBlur = 26 * k
|
|
243
|
+
g.shadowOffsetY = 10 * k
|
|
244
|
+
g.lineJoin = 'round'
|
|
245
|
+
g.lineWidth = 22 * k
|
|
246
|
+
g.strokeStyle = look.edge
|
|
247
|
+
g.strokeText(l, W / 2, y)
|
|
248
|
+
g.restore()
|
|
249
|
+
g.lineWidth = 8 * k
|
|
250
|
+
g.strokeStyle = look.accent
|
|
251
|
+
g.strokeText(l, W / 2, y)
|
|
252
|
+
g.fillStyle = look.light
|
|
253
|
+
g.fillText(l, W / 2, y)
|
|
254
|
+
})
|
|
255
|
+
} else if (style === 'line') {
|
|
256
|
+
// a tagline: light words centred low on a soft dark band, no plate, the frame stays the picture
|
|
257
|
+
const px = 64 * k
|
|
258
|
+
face(g, look, px)
|
|
259
|
+
const lines = more.length > 0 ? [first, ...more] : wrap(g, first, W * 0.7)
|
|
260
|
+
const lh = px * 1.3
|
|
261
|
+
const cy = top ? H * 0.2 : H * 0.8
|
|
262
|
+
const span = lh * lines.length + 80 * k
|
|
263
|
+
const band = g.createLinearGradient(0, cy - span, 0, cy + span)
|
|
264
|
+
band.addColorStop(0, alpha(look.edge, 0))
|
|
265
|
+
band.addColorStop(0.5, alpha(look.edge, 0.42))
|
|
266
|
+
band.addColorStop(1, alpha(look.edge, 0))
|
|
267
|
+
g.fillStyle = band
|
|
268
|
+
g.fillRect(0, cy - span, W, 2 * span)
|
|
269
|
+
lines.forEach((l, i) => {
|
|
270
|
+
const y = cy + (i - (lines.length - 1) / 2) * lh
|
|
271
|
+
// `*word*` is a key word in the accent, the rest light
|
|
272
|
+
const parts = l.split('*').map((t, n) => ({ text: t, key: n % 2 === 1 }))
|
|
273
|
+
const plain = parts.map((p) => p.text).join('')
|
|
274
|
+
let x = W / 2 - g.measureText(plain).width / 2
|
|
275
|
+
g.textAlign = 'left'
|
|
276
|
+
g.save()
|
|
277
|
+
g.shadowColor = alpha(look.edge, 0.85)
|
|
278
|
+
g.shadowBlur = 18 * k
|
|
279
|
+
g.shadowOffsetY = 4 * k
|
|
280
|
+
g.lineJoin = 'round'
|
|
281
|
+
g.lineWidth = 9 * k
|
|
282
|
+
g.strokeStyle = alpha(look.edge, 0.82)
|
|
283
|
+
g.strokeText(plain, x, y)
|
|
284
|
+
g.restore()
|
|
285
|
+
for (const p of parts) {
|
|
286
|
+
g.fillStyle = p.key ? look.accent : look.light
|
|
287
|
+
g.fillText(p.text, x, y)
|
|
288
|
+
x += g.measureText(p.text).width
|
|
289
|
+
}
|
|
290
|
+
g.textAlign = 'center'
|
|
291
|
+
})
|
|
292
|
+
} else if (style === 'caption') {
|
|
293
|
+
// the plate: its fill, its outline, its ink, an accent dot leading it
|
|
294
|
+
const px = 46 * k
|
|
295
|
+
face(g, look, px, 900)
|
|
296
|
+
const lines = wrap(g, [first, ...more].join(' '), W * 0.66)
|
|
297
|
+
const lh = px * 1.28
|
|
298
|
+
const dot = 14 * k
|
|
299
|
+
const bw = Math.max(...lines.map((l) => g.measureText(l).width)) + 96 * k + dot * 2
|
|
300
|
+
const bh = lines.length * lh + 34 * k
|
|
301
|
+
const cy = top ? H * 0.12 : H * 0.83
|
|
302
|
+
plate(g, W / 2 - bw / 2, cy - bh / 2, bw, bh, Math.min(bh / 2, 30 * k), k, look.plate.fill, look.plate.line, look.edge)
|
|
303
|
+
g.fillStyle = look.accent
|
|
304
|
+
g.beginPath()
|
|
305
|
+
g.arc(W / 2 - bw / 2 + 34 * k, cy, dot / 2 + 2 * k, 0, Math.PI * 2)
|
|
306
|
+
g.fill()
|
|
307
|
+
g.fillStyle = look.plate.ink
|
|
308
|
+
lines.forEach((l, i) => g.fillText(l, W / 2 + dot, cy + (i - (lines.length - 1) / 2) * lh + 2 * k))
|
|
309
|
+
} else {
|
|
310
|
+
// corner: a word chip, bottom left (top left when placed at the top)
|
|
311
|
+
const px = 54 * k
|
|
312
|
+
face(g, look, px, 900)
|
|
313
|
+
const t = [first, ...more].join(' ')
|
|
314
|
+
const w = g.measureText(t).width + 72 * k
|
|
315
|
+
const h = px * 1.5
|
|
316
|
+
const x = 72 * k
|
|
317
|
+
const y = top ? 80 * k : H - 80 * k - h
|
|
318
|
+
plate(g, x, y, w, h, h / 2, k, look.chip.fill, darker(look.chip.fill, 0.15), look.edge)
|
|
319
|
+
g.fillStyle = look.chip.ink
|
|
320
|
+
g.textAlign = 'left'
|
|
321
|
+
g.fillText(t, x + 36 * k, y + h / 2 + 2 * k)
|
|
322
|
+
}
|
|
323
|
+
return Buffer.from(c.toBuffer('image/png'))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** the card's ground: the look's ground, darker at the edges */
|
|
327
|
+
export function drawCard([W, H]: readonly [number, number], look: TrailerLook): Buffer {
|
|
328
|
+
const c = createCanvas(W, H)
|
|
329
|
+
const g = c.getContext('2d')
|
|
330
|
+
const grad = g.createRadialGradient(W / 2, H * 0.45, 0, W / 2, H * 0.45, Math.hypot(W, H) * 0.6)
|
|
331
|
+
grad.addColorStop(0, look.ground[0])
|
|
332
|
+
grad.addColorStop(1, look.ground[1])
|
|
333
|
+
g.fillStyle = grad
|
|
334
|
+
g.fillRect(0, 0, W, H)
|
|
335
|
+
return Buffer.from(c.toBuffer('image/png'))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const key = (parts: unknown): string => new Bun.CryptoHasher('sha1').update(JSON.stringify([DRAWING, parts])).digest('hex').slice(0, 16)
|
|
339
|
+
|
|
340
|
+
/** what the look reads from disk, as it stands: a new font or logo file draws every title again */
|
|
341
|
+
export const lookFiles = (look: TrailerLook, root: string): unknown[] =>
|
|
342
|
+
[look.font.file, look.logo, look.short.font.file].map((f) => {
|
|
343
|
+
if (!f) return null
|
|
344
|
+
const abs = join(root, f)
|
|
345
|
+
return existsSync(abs) ? [f, statSync(abs).size, statSync(abs).mtimeMs] : [f, 'missing']
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
/** a title's PNG file, named by what it draws */
|
|
349
|
+
export const titleFile = (dir: string, root: string, text: string, style: TitleStyle, size: readonly [number, number], place: TitlePlace, look: TrailerLook, step?: Title['step']): string =>
|
|
350
|
+
join(dir, `title-${key([text, style, size, place, look, lookFiles(look, root), ...(step ? [step.i, step.n] : [])])}.png`)
|
|
351
|
+
|
|
352
|
+
/** the PNG of a title, drawn once per text, style, frame, place, look and step; its box on the frame in `<file>.json` beside it */
|
|
353
|
+
export async function titlePng(dir: string, root: string, text: string, style: TitleStyle, size: readonly [number, number], place: TitlePlace, look: TrailerLook, step?: Title['step']): Promise<TitleImage> {
|
|
354
|
+
const file = titleFile(dir, root, text, style, size, place, look, step)
|
|
355
|
+
const where = `${file}.json`
|
|
356
|
+
if (!existsSync(file) || !existsSync(where)) {
|
|
357
|
+
mkdirSync(dir, { recursive: true })
|
|
358
|
+
registerFace(look, root)
|
|
359
|
+
const logo = style === 'logo' && look.logo && existsSync(join(root, look.logo)) ? await loadImage(join(root, look.logo)) : null
|
|
360
|
+
const { data, info } = await sharp(drawTitle(text, style, size, place, look, logo, step)).trim({ threshold: 0 }).png().toBuffer({ resolveWithObject: true })
|
|
361
|
+
writeFileSync(file, data)
|
|
362
|
+
writeFileSync(where, JSON.stringify({ x: -(info.trimOffsetLeft ?? 0), y: -(info.trimOffsetTop ?? 0), w: info.width, h: info.height }))
|
|
363
|
+
}
|
|
364
|
+
const { x, y, w, h } = JSON.parse(readFileSync(where, 'utf8')) as TitleImage
|
|
365
|
+
return { file, x, y, w, h }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** the card's ground's PNG file for a frame and a look */
|
|
369
|
+
export const cardFile = (dir: string, size: readonly [number, number], look: TrailerLook): string => join(dir, `card-${key(['card', size, look.ground])}.png`)
|
|
370
|
+
|
|
371
|
+
/** the PNG of the card's ground for a frame and a look */
|
|
372
|
+
export function cardPng(dir: string, size: readonly [number, number], look: TrailerLook): string {
|
|
373
|
+
const file = cardFile(dir, size, look)
|
|
374
|
+
if (!existsSync(file)) {
|
|
375
|
+
mkdirSync(dir, { recursive: true })
|
|
376
|
+
writeFileSync(file, drawCard(size, look))
|
|
377
|
+
}
|
|
378
|
+
return file
|
|
379
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// a `film url` take: a route resolved against the repo's dev url (the url of its one global process, or of the one
|
|
2
|
+
// `base <id>` names), the page filmed by the house shot service (shot/client.ts), its process started through pm when
|
|
3
|
+
// it does not answer. Pure first, the io after
|
|
4
|
+
import type { Film } from './grammar.ts'
|
|
5
|
+
|
|
6
|
+
export type UrlFilm = Extract<Film, { kind: 'url' }>
|
|
7
|
+
|
|
8
|
+
/** a global process of the repo that serves a url */
|
|
9
|
+
export interface DevProcess {
|
|
10
|
+
id: string
|
|
11
|
+
url: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** the page a route films: an http url as written, else the route under the base's own path */
|
|
15
|
+
export function urlOf(route: string, base: string): string {
|
|
16
|
+
return /^https?:\/\//.test(route) ? route : `${base.replace(/\/+$/, '')}${route}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** the process a cut's routes resolve against: the one `base <id>` names, else the repo's only one; or why none */
|
|
20
|
+
export function pickBase(ps: readonly DevProcess[], base: string | null): DevProcess | { error: string } {
|
|
21
|
+
const listed = ps.map((p) => `${p.id} (${p.url})`).join(', ')
|
|
22
|
+
if (base !== null) return ps.find((p) => p.id === base) ?? { error: `base ${base}: no global process of this repo has that id and a url${ps.length ? `; it has ${listed}` : ''}` }
|
|
23
|
+
if (ps.length === 1) return ps[0]!
|
|
24
|
+
if (ps.length === 0) return { error: 'film url: no global process of this repo declares a url (a <name>.shipkit.ts with globalProcess({ id, cmd, url })); or film a full http url' }
|
|
25
|
+
return { error: `film url: this repo serves several urls, name the one its routes start from with a header line base <id>: ${listed}` }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** a portrait cut films its page at a phone's css width */
|
|
29
|
+
const PHONE_CSS_W = 430
|
|
30
|
+
/** a landscape cut films its page no wider than this, in css pixels */
|
|
31
|
+
const DESKTOP_CSS_W = 1920
|
|
32
|
+
|
|
33
|
+
/** the css viewport and the pixel ratio that fill the cut's frame: a viewport given keeps its css size, the ratio
|
|
34
|
+
* making up the frame's width (1 to 4); none: the frame itself, a portrait frame at a phone's width */
|
|
35
|
+
export function viewportOf(size: readonly [number, number], viewport?: readonly [number, number]): { viewport: { width: number; height: number }; dpr: number } {
|
|
36
|
+
const [W, H] = size
|
|
37
|
+
if (viewport) return { viewport: { width: viewport[0], height: viewport[1] }, dpr: +Math.min(4, Math.max(1, W / viewport[0])).toFixed(4) }
|
|
38
|
+
const dpr = +Math.min(4, Math.max(1, W / (H > W ? PHONE_CSS_W : DESKTOP_CSS_W))).toFixed(4)
|
|
39
|
+
return { viewport: { width: Math.round(W / dpr), height: Math.round(H / dpr) }, dpr }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** every global process of the repo that declares a url, by the walk every unit file takes */
|
|
43
|
+
export async function devProcesses(root: string): Promise<DevProcess[]> {
|
|
44
|
+
const { importFresh, repoUnitFiles } = await import('../core/discover-checks.ts')
|
|
45
|
+
const out: DevProcess[] = []
|
|
46
|
+
for (const f of repoUnitFiles(root)) {
|
|
47
|
+
const d = (await importFresh(f)) as { kind?: unknown; id?: unknown; url?: unknown } | undefined
|
|
48
|
+
if (d?.kind === 'globalProcess' && typeof d.id === 'string' && typeof d.url === 'string') out.push({ id: d.id, url: d.url })
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** any http answer, a 404 included, means the server is up */
|
|
54
|
+
const answers = (url: string): Promise<boolean> =>
|
|
55
|
+
fetch(url, { signal: AbortSignal.timeout(2_000) }).then(
|
|
56
|
+
() => true,
|
|
57
|
+
() => false,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async function resolveBase(root: string, base: string | null, say: (l: string) => void): Promise<string> {
|
|
61
|
+
const pick = pickBase(await devProcesses(root), base)
|
|
62
|
+
if ('error' in pick) throw new Error(pick.error)
|
|
63
|
+
if (await answers(pick.url)) return pick.url
|
|
64
|
+
const { pmUp, pmUrl } = await import('../home/home-paths.ts')
|
|
65
|
+
if (process.env['CI'] == null && process.env['SHIPKIT_NO_HOME'] == null && (await pmUp())) {
|
|
66
|
+
say(` starting ${pick.id}: ${pick.url} does not answer (sk gp start ${pick.id})`)
|
|
67
|
+
const { pmSwitchDeps, switchProcess } = await import('../cli/cmd_GlobalProcesses.ts')
|
|
68
|
+
if ((await switchProcess(pick.id, 'start', pmSwitchDeps(say, pmUrl()), 120_000)) !== 0) throw new Error(`${pick.id} did not start through pm: sk gp shows where it stands`)
|
|
69
|
+
return pick.url
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`${pick.url} does not answer and no pm runs here to start it: sk gp start ${pick.id}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** the base of each repo's routes, resolved once per process: two takes filming at once start the process once */
|
|
75
|
+
const bases = new Map<string, Promise<string>>()
|
|
76
|
+
export function baseUrl(root: string, base: string | null, say: (l: string) => void): Promise<string> {
|
|
77
|
+
const k = `${root}\0${base ?? ''}`
|
|
78
|
+
let p = bases.get(k)
|
|
79
|
+
if (!p) {
|
|
80
|
+
p = resolveBase(root, base, say)
|
|
81
|
+
p.catch(() => bases.delete(k))
|
|
82
|
+
bases.set(k, p)
|
|
83
|
+
}
|
|
84
|
+
return p
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface UrlTake {
|
|
88
|
+
root: string
|
|
89
|
+
base: string | null
|
|
90
|
+
size: readonly [number, number]
|
|
91
|
+
fps: number
|
|
92
|
+
/** null: a still */
|
|
93
|
+
seconds: number | null
|
|
94
|
+
/** absolute: a .png for a still, a .mp4 for a clip */
|
|
95
|
+
out: string
|
|
96
|
+
say: (l: string) => void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** film the page of a `film url` line at `out`; the note says what was filmed and how */
|
|
100
|
+
export async function filmUrl(f: UrlFilm, o: UrlTake): Promise<string> {
|
|
101
|
+
const url = /^https?:\/\//.test(f.route) ? f.route : urlOf(f.route, await baseUrl(o.root, o.base, o.say))
|
|
102
|
+
const { clipUrl, shotUrl } = await import('../shot/client.ts')
|
|
103
|
+
const { viewport, dpr } = viewportOf(o.size, f.viewport)
|
|
104
|
+
const wait = f.wait === undefined ? {} : { waitMs: Math.round(f.wait * 1000) }
|
|
105
|
+
const log = (l: string): void => o.say(` ${l}`)
|
|
106
|
+
const r = o.seconds === null ? await shotUrl({ url, out: o.out, viewport, dpr, ...wait }, log) : await clipUrl({ url, out: o.out, viewport, dpr, seconds: o.seconds, fps: o.fps, ...wait }, log)
|
|
107
|
+
return `${url} at ${viewport.width}x${viewport.height}@${dpr}, ${r.via}, ${r.ms} ms${r.note ? `: ${r.note}` : ''}`
|
|
108
|
+
}
|