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,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
+ }