rvlib-shipkit 2.3.0 → 2.4.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 (80) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. package/src/wiki/graph-pages.ts +128 -0
@@ -1,59 +1,230 @@
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'
1
+ // the trailer's drawn frames: one transparent PNG per title cut to what it draws, the frames of its entrance and exit
2
+ // beside it, and the card's ground, in the repo's look (look.ts). Seven styles (logo, big, caption, corner, line, and a
3
+ // short's words: short, progress), every word of text drawn by one painter (`ink`) that takes a title's face, size,
4
+ // fill, outline, shadow and glow, and moves letter by letter. Cached on what they draw, so a title edit draws one title
5
+ import { type Canvas, createCanvas, GlobalFonts, loadImage, type Image, type SKRSContext2D } from '@napi-rs/canvas'
5
6
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
6
7
  import { join } from 'node:path'
7
8
  import sharp from 'sharp'
8
- import type { Title, TitleStyle } from './grammar.ts'
9
+ import { hasFamily, registerFont } from './fonts.ts'
10
+ import type { Title, TitleFx, TitlePlace, TitleStyle } from './grammar.ts'
9
11
  import { alpha, darker, shortLook, type TrailerLook } from './look.ts'
12
+ import { type Glyph, inFrame, inFrames, outFrame, outFrames, STILL, type TitleFrame, type TitleMotion, type Whole } from './motion.ts'
10
13
 
11
14
  /** bump when a style's drawing changes: every cached title draws again */
12
- const DRAWING = 1
15
+ const DRAWING = 3
13
16
 
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')
17
+ export { placeOf, type TitlePlace } from './grammar.ts'
18
18
 
19
19
  /** a short's words centred on these shares of the height, inside the phones' safe zone: under the lockup (`top`), the
20
20
  * frame's middle, where the end card's lines stand (`bottom`) */
21
21
  export const SHORT_Y: Record<TitlePlace, number> = { top: 0.31, middle: 0.5, bottom: 0.645 }
22
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 */
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
24
+ * frame. `in` and `out` count the frames of its entrance and exit, drawn beside it (motion.ts `inFrames`,
25
+ * `outFrames`) in the same box */
24
26
  export interface TitleImage {
25
27
  file: string
26
28
  x: number
27
29
  y: number
28
30
  w: number
29
31
  h: number
32
+ in?: number
33
+ out?: number
30
34
  }
31
35
 
32
36
  const registered = new Set<string>()
33
- /** the look's font files registered once under their families */
37
+ /** the look's font files registered once under their families; a family the machine lacks fails loud */
34
38
  export function registerFace(look: TrailerLook, root: string): void {
35
39
  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)
40
+ if (f.file && !registered.has(f.file)) {
41
+ const abs = join(root, f.file)
42
+ if (!existsSync(abs)) throw new Error(`the brand's trailer font ${f.file} is not in the repo`)
43
+ GlobalFonts.registerFromPath(abs, f.family)
44
+ registered.add(f.file)
45
+ }
46
+ if (!hasFamily(f.family)) throw new Error(`the brand's trailer font family ${f.family} is not on this machine: give its file (trailer.font.file)`)
41
47
  }
42
48
  }
43
49
 
50
+ /** a variable face takes its weight on its axis, a static one ignores it */
51
+ const axis = (g: SKRSContext2D, weight: number): void => {
52
+ ;(g as unknown as { fontVariationSettings: string }).fontVariationSettings = `"wght" ${weight}`
53
+ }
54
+
44
55
  export const face = (g: SKRSContext2D, look: TrailerLook, px: number, weight = look.font.weight): void => {
45
56
  const fam = look.font.family.includes(',') ? look.font.family : `"${look.font.family}"`
46
57
  g.font = `${weight} ${Math.round(px)}px ${fam}`
58
+ axis(g, weight)
59
+ }
60
+
61
+ /** how one run of words is painted: its face, its fill (a colour, or stops top to bottom), the accent of its `*word*`,
62
+ * and the passes under the fill: a glow, an outline (its shadow under it), an inner stroke */
63
+ export interface Paint {
64
+ family: string
65
+ weight: number
66
+ px: number
67
+ fill: string | string[]
68
+ key: string
69
+ outline: { w: number; color: string; dy?: number } | null
70
+ inner: { w: number; color: string } | null
71
+ shadow: { blur: number; dy: number; color: string } | null
72
+ glow: { blur: number; color: string } | null
73
+ spacing: number
74
+ }
75
+
76
+ /** the draw in progress: letter by letter when a letter move plays (`glyph`), the plates' and bands' alpha, and the
77
+ * count of letters drawn so far */
78
+ interface Ctx {
79
+ glyph?: (i: number, n: number) => Glyph
80
+ n: number
81
+ i: number
82
+ decor: number
83
+ }
84
+ const restCtx = (): Ctx => ({ n: 0, i: 0, decor: 1 })
85
+
86
+ /** a plate, a band, a glow or the logo image: faded with the title's decor */
87
+ function decor(cx: Ctx, g: SKRSContext2D, draw: () => void): void {
88
+ g.save()
89
+ g.globalAlpha *= cx.decor
90
+ draw()
91
+ g.restore()
92
+ }
93
+
94
+ function setFace(g: SKRSContext2D, p: Paint): void {
95
+ const fam = p.family.includes(',') ? p.family : `"${p.family}"`
96
+ g.font = `${p.weight} ${Math.round(p.px)}px ${fam}`
97
+ axis(g, p.weight)
98
+ ;(g as unknown as { letterSpacing: string }).letterSpacing = `${Math.round(p.spacing)}px`
99
+ }
100
+
101
+ /** the top and bottom of a line of `px` letters standing on `y` as the context's baseline reads it */
102
+ function lineBox(g: SKRSContext2D, y: number, px: number): [number, number] {
103
+ const b = g.textBaseline
104
+ return b === 'top' ? [y, y + px] : b === 'middle' ? [y - px / 2, y + px / 2] : [y - px * 0.8, y + px * 0.2]
47
105
  }
48
106
 
49
- /** the text cut into lines no wider than `max`; `\n` breaks a line */
107
+ /** THE painter: a line of words at (x, y), `*word*` in the accent; at rest as runs (the face's kerning whole), under a
108
+ * letter move letter by letter, each about its own middle. Every pass covers every letter before the next pass, so an
109
+ * outline never lies over a neighbour's fill */
110
+ function ink(cx: Ctx, g: SKRSContext2D, p: Paint, text: string, x: number, y: number, align: 'center' | 'left' = 'center'): void {
111
+ setFace(g, p)
112
+ g.textAlign = 'left'
113
+ const parts = text.split('*').map((t, n) => ({ text: t, key: n % 2 === 1 })).filter((q) => q.text !== '')
114
+ const plain = parts.map((q) => q.text).join('')
115
+ const width = g.measureText(plain).width
116
+ const x0 = align === 'center' ? x - width / 2 : x
117
+ const at = (j: number): number => (j === 0 ? 0 : g.measureText(plain.slice(0, j)).width)
118
+ const units: { text: string; x: number; w: number; key: boolean; i: number | null }[] = []
119
+ let j = 0
120
+ for (const part of parts) {
121
+ if (cx.glyph) {
122
+ for (const ch of [...part.text]) {
123
+ units.push({ text: ch, x: x0 + at(j), w: g.measureText(ch).width, key: part.key, i: /\s/.test(ch) ? null : cx.i++ })
124
+ j += ch.length
125
+ }
126
+ } else {
127
+ units.push({ text: part.text, x: x0 + at(j), w: g.measureText(part.text).width, key: part.key, i: null })
128
+ cx.i += [...part.text.replace(/\s/g, '')].length
129
+ j += part.text.length
130
+ }
131
+ }
132
+ const [top, bottom] = lineBox(g, y, p.px)
133
+ const fill = (key: boolean): string | CanvasGradient => {
134
+ if (key) return p.key
135
+ if (typeof p.fill === 'string') return p.fill
136
+ const grad = g.createLinearGradient(0, top, 0, bottom)
137
+ p.fill.forEach((c, k) => grad.addColorStop(0.1 + (0.9 * k) / Math.max(1, p.fill.length - 1), c))
138
+ return grad as unknown as CanvasGradient
139
+ }
140
+ const pass = (draw: (u: (typeof units)[number]) => void): void => {
141
+ for (const u of units) {
142
+ if (u.text.trim() === '') continue
143
+ const s = u.i !== null && cx.glyph ? cx.glyph(u.i, cx.n) : null
144
+ if (s && s.a <= 0) continue
145
+ g.save()
146
+ if (s) {
147
+ g.globalAlpha *= s.a
148
+ const mx = u.x + u.w / 2
149
+ const my = (top + bottom) / 2
150
+ g.translate(mx + s.dx, my + s.dy)
151
+ g.scale(s.s, s.s)
152
+ g.translate(-mx, -my)
153
+ }
154
+ draw(u)
155
+ g.restore()
156
+ }
157
+ }
158
+ const shadowed = (sh: Paint['shadow']): void => {
159
+ if (!sh) return
160
+ g.shadowColor = sh.color
161
+ g.shadowBlur = sh.blur
162
+ g.shadowOffsetY = sh.dy
163
+ }
164
+ g.lineJoin = 'round'
165
+ if (p.glow) {
166
+ const gl = p.glow
167
+ pass((u) => {
168
+ g.shadowColor = gl.color
169
+ g.shadowBlur = gl.blur
170
+ g.fillStyle = gl.color
171
+ g.fillText(u.text, u.x, y)
172
+ g.fillText(u.text, u.x, y)
173
+ })
174
+ }
175
+ if (p.outline) {
176
+ const o = p.outline
177
+ pass((u) => {
178
+ shadowed(p.shadow)
179
+ g.lineWidth = o.w
180
+ g.strokeStyle = o.color
181
+ g.strokeText(u.text, u.x, y + (o.dy ?? 0))
182
+ })
183
+ }
184
+ if (p.inner) {
185
+ const o = p.inner
186
+ pass((u) => {
187
+ g.lineWidth = o.w
188
+ g.strokeStyle = o.color
189
+ g.strokeText(u.text, u.x, y)
190
+ })
191
+ }
192
+ pass((u) => {
193
+ if (!p.outline) shadowed(p.shadow)
194
+ g.fillStyle = fill(u.key) as string
195
+ g.fillText(u.text, u.x, y)
196
+ })
197
+ }
198
+
199
+ /** a title's own words over its style's paint: `k` scales the pixels a line writes (at a frame 1080 tall); a `font`
200
+ * word's face draws at 400 unless its `weight` says */
201
+ export function paintWith(base: Paint, fx: TitleFx | undefined, look: TrailerLook, k: number, family: string | null): Paint {
202
+ const p: Paint = { ...base }
203
+ if (family) {
204
+ p.family = family
205
+ p.weight = 400
206
+ }
207
+ if (!fx) return p
208
+ const c = (w: string | undefined, fallback: string): string => (w === undefined ? fallback : w === 'light' ? look.light : w === 'edge' ? look.edge : w === 'accent' ? look.accent : w)
209
+ if (fx.weight !== undefined) p.weight = fx.weight
210
+ if (fx.color !== undefined) p.fill = c(fx.color, look.light)
211
+ if (fx.gradient) p.fill = fx.gradient.map((x) => c(x, look.light))
212
+ if (fx.outline) p.outline = fx.outline.px === 0 ? null : { w: fx.outline.px * k, color: c(fx.outline.color, base.outline?.color ?? look.edge) }
213
+ // an inner stroke keeps its share of the outline it sits in, so a thinner outline still shows its edge
214
+ if (fx.outline && base.inner && base.outline) p.inner = p.outline ? { ...base.inner, w: (base.inner.w * p.outline.w) / base.outline.w } : null
215
+ if (fx.shadow) p.shadow = fx.shadow.px === 0 ? null : { blur: fx.shadow.px * k, dy: (fx.shadow.px * k) / 3, color: c(fx.shadow.color, alpha(look.edge, 0.6)) }
216
+ if (fx.glow) p.glow = fx.glow.px === 0 ? null : { blur: fx.glow.px * k, color: c(fx.glow.color, look.accent) }
217
+ return p
218
+ }
219
+
220
+ /** the text cut into lines no wider than `max` with the context's face; `\n` breaks a line; `*` marks take no room */
50
221
  function wrap(g: SKRSContext2D, text: string, max: number): string[] {
51
222
  const out: string[] = []
52
223
  for (const para of text.split('\\n')) {
53
224
  let line = ''
54
225
  for (const w of para.split(/\s+/).filter(Boolean)) {
55
226
  const next = line ? `${line} ${w}` : w
56
- if (line && g.measureText(next).width > max) {
227
+ if (line && g.measureText(next.replaceAll('*', '')).width > max) {
57
228
  out.push(line)
58
229
  line = w
59
230
  } else line = next
@@ -81,245 +252,174 @@ function plate(g: SKRSContext2D, x: number, y: number, w: number, h: number, r:
81
252
  g.stroke()
82
253
  }
83
254
 
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))
255
+ /** a soft band of the edge's shade across the frame, behind words */
256
+ function band(cx: Ctx, g: SKRSContext2D, look: TrailerLook, y0: number, y1: number, W: number, a: number): void {
257
+ decor(cx, g, () => {
258
+ const b = g.createLinearGradient(0, y0, 0, y1)
259
+ b.addColorStop(0, alpha(look.edge, 0))
260
+ b.addColorStop(0.5, alpha(look.edge, a))
261
+ b.addColorStop(1, alpha(look.edge, 0))
262
+ g.fillStyle = b
263
+ g.fillRect(0, y0, W, y1 - y0)
264
+ })
130
265
  }
131
266
 
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
- }
267
+ /** what a title draws: its words, style, place, look, logo, step, its own look words and the family its `font` word
268
+ * registered (null: the look's face) */
269
+ export interface TitleDraw {
270
+ text: string
271
+ style: TitleStyle
272
+ place: TitlePlace
273
+ look: TrailerLook
274
+ logo: Image | null
275
+ step?: Title['step']
276
+ fx?: TitleFx
277
+ family: string | null
167
278
  }
168
279
 
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')
280
+ /** one title over a transparent frame, at rest or under a letter move (`cx`). A title's `\n` breaks its line; a logo's
281
+ * second line is its subtitle */
282
+ function drawInto(g: SKRSContext2D, d: TitleDraw, [W, H]: readonly [number, number], cx: Ctx): void {
283
+ const { text, style, look } = d
284
+ const top = d.place === 'top'
174
285
  const k = Math.min(H, W) / 1080
286
+ const own = (p: Paint): Paint => paintWith(p, d.fx, look, k, d.family)
175
287
  g.textAlign = 'center'
176
288
  g.textBaseline = 'middle'
177
289
  const [first = '', ...more] = text.split('\\n')
290
+ const base = (px: number, weight: number, l: TrailerLook = look): Paint => ({ family: l.font.family, weight, px, fill: l.light, key: l.accent, outline: null, inner: null, shadow: null, glow: null, spacing: 0 })
178
291
  if (style === 'short' || style === 'progress') {
179
292
  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)
293
+ const ks = W / 1080
294
+ const size = (d.fx?.size ?? (style === 'short' ? 100 : 84)) * ks
295
+ const p = paintWith({ ...base(size, sl.font.weight, sl), outline: { w: Math.round(size * 0.16), color: sl.edge }, shadow: { blur: 18 * ks, dy: 4 * ks, color: alpha(sl.edge, 0.5) } }, d.fx, sl, ks, d.family)
296
+ const y = SHORT_Y[d.place]
297
+ g.textBaseline = 'top'
298
+ setFace(g, p)
299
+ const lead = Math.round(size * 1.14)
300
+ if (style === 'short') {
301
+ const lines = text.split('\\n').flatMap((part) => wrap(g, part, W - 100 * ks))
302
+ const t0 = y * H - (lines.length * lead) / 2
303
+ band(cx, g, sl, t0 - 140 * ks, t0 + lines.length * lead + 140 * ks, W, 0.42)
304
+ lines.forEach((line, i) => ink(cx, g, p, line, W / 2, t0 + i * lead))
305
+ } else {
306
+ const [i, n] = [d.step?.i ?? 1, d.step?.n ?? 1]
307
+ const [width, gap, notch, under] = [700 * ks, 14 * ks, 28 * ks, 24 * ks]
308
+ const t0 = y * H - (lead + under + notch) / 2
309
+ band(cx, g, sl, t0 - 110 * ks, t0 + lead + under + notch + 110 * ks, W, 0.36)
310
+ ink(cx, g, p, text, W / 2, t0)
311
+ const nw = (width - gap * (n - 1)) / n
312
+ const bar = t0 + lead + under
313
+ decor(cx, g, () => {
314
+ for (let j = 0; j < n; j++) {
315
+ const nx = (W - width) / 2 + j * (nw + gap)
316
+ g.save()
317
+ if (j === i - 1) {
318
+ g.shadowColor = alpha(sl.accent, 0.9)
319
+ g.shadowBlur = 22 * ks
320
+ }
321
+ g.beginPath()
322
+ g.roundRect(nx, bar, nw, notch, notch / 2)
323
+ g.fillStyle = j < i ? sl.accent : alpha(sl.light, 0.32)
324
+ g.fill()
325
+ g.restore()
326
+ g.lineWidth = 5 * ks
327
+ g.strokeStyle = sl.edge
328
+ g.beginPath()
329
+ g.roundRect(nx, bar, nw, notch, notch / 2)
330
+ g.stroke()
331
+ }
332
+ })
333
+ }
182
334
  } 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)
335
+ // the logo image over the word, the word in a light to accent ramp over a dropped edge, the subtitle spaced under
336
+ decor(cx, g, () => {
337
+ const glow = g.createRadialGradient(W / 2, H * 0.48, 0, W / 2, H * 0.48, Math.max(W, H) * 0.45)
338
+ glow.addColorStop(0, alpha(look.edge, 0.45))
339
+ glow.addColorStop(1, alpha(look.edge, 0))
340
+ g.fillStyle = glow
341
+ g.fillRect(0, 0, W, H)
342
+ })
343
+ const S = d.fx?.size ?? 170
344
+ const markH = d.logo ? Math.min(H * 0.3, W * 0.3) : 0
345
+ setFace(g, own(base(S * k, 900)))
346
+ const kl = Math.min(1, (W * 0.78) / (g.measureText(first.replaceAll('*', '')).width + 16 * k))
347
+ const px = S * k * kl
348
+ const subPx = S * 0.2 * k * Math.max(kl, 0.7)
194
349
  const block = markH + (markH ? px * 0.25 : 0) + (first ? px : 0) + (more.length ? subPx * 2 : 0)
195
350
  let y = H * 0.48 - block / 2
351
+ const logo = d.logo
196
352
  if (logo) {
197
353
  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()
354
+ const at = y
355
+ decor(cx, g, () => {
356
+ g.shadowColor = alpha(look.edge, 0.6)
357
+ g.shadowBlur = 30 * k
358
+ g.drawImage(logo, W / 2 - w / 2, at, w, markH)
359
+ })
203
360
  y += markH + px * 0.25
204
361
  }
205
362
  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)
363
+ const p = own({ ...base(px, 900), fill: [look.light, look.accent, darker(look.accent, 0.2)], outline: { w: 16 * k * kl, color: look.edge, dy: 3 * k }, shadow: { blur: 30 * k, dy: 12 * k, color: alpha(look.edge, 0.6) } })
364
+ ink(cx, g, p, first, W / 2, y + px / 2)
223
365
  y += px
224
366
  }
225
367
  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)
368
+ const sub: Paint = { ...base(subPx, d.fx?.weight ?? (d.family ? 400 : 800)), ...(d.family ? { family: d.family } : {}), spacing: subPx * 0.42, shadow: { blur: subPx * 0.42, dy: 0, color: alpha(look.edge, 0.9) } }
369
+ ink(cx, g, sub, more.join(' ').toUpperCase(), W / 2 + (subPx * 0.42) / 2, y + subPx)
232
370
  }
233
371
  } 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)
372
+ // a strong display line in the upper third: light words in a thick edge outline, an accent stroke inside it
373
+ const p = own({ ...base((d.fx?.size ?? 112) * k, 900), outline: { w: 22 * k, color: look.edge }, inner: { w: 8 * k, color: look.accent }, shadow: { blur: 26 * k, dy: 10 * k, color: alpha(look.edge, 0.55) } })
374
+ setFace(g, p)
237
375
  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
- })
376
+ lines.forEach((l, i) => ink(cx, g, p, l, W / 2, H * 0.26 + (i - (lines.length - 1) / 2) * p.px * 1.12))
255
377
  } else if (style === 'line') {
256
378
  // 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)
379
+ const p = own({ ...base((d.fx?.size ?? 64) * k, look.font.weight), outline: { w: 9 * k, color: alpha(look.edge, 0.82) }, shadow: { blur: 18 * k, dy: 4 * k, color: alpha(look.edge, 0.85) } })
380
+ setFace(g, p)
259
381
  const lines = more.length > 0 ? [first, ...more] : wrap(g, first, W * 0.7)
260
- const lh = px * 1.3
382
+ const lh = p.px * 1.3
261
383
  const cy = top ? H * 0.2 : H * 0.8
262
384
  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
- })
385
+ band(cx, g, look, cy - span, cy + span, W, 0.42)
386
+ lines.forEach((l, i) => ink(cx, g, p, l, W / 2, cy + (i - (lines.length - 1) / 2) * lh))
292
387
  } else if (style === 'caption') {
293
388
  // the plate: its fill, its outline, its ink, an accent dot leading it
294
- const px = 46 * k
295
- face(g, look, px, 900)
389
+ const p = own({ ...base((d.fx?.size ?? 46) * k, 900), fill: look.plate.ink })
390
+ setFace(g, p)
296
391
  const lines = wrap(g, [first, ...more].join(' '), W * 0.66)
297
- const lh = px * 1.28
392
+ const lh = p.px * 1.28
298
393
  const dot = 14 * k
299
- const bw = Math.max(...lines.map((l) => g.measureText(l).width)) + 96 * k + dot * 2
394
+ const bw = Math.max(...lines.map((l) => g.measureText(l.replaceAll('*', '')).width)) + 96 * k + dot * 2
300
395
  const bh = lines.length * lh + 34 * k
301
396
  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))
397
+ decor(cx, g, () => {
398
+ 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)
399
+ g.fillStyle = look.accent
400
+ g.beginPath()
401
+ g.arc(W / 2 - bw / 2 + 34 * k, cy, dot / 2 + 2 * k, 0, Math.PI * 2)
402
+ g.fill()
403
+ })
404
+ lines.forEach((l, i) => ink(cx, g, p, l, W / 2 + dot, cy + (i - (lines.length - 1) / 2) * lh + 2 * k))
309
405
  } else {
310
406
  // corner: a word chip, bottom left (top left when placed at the top)
311
- const px = 54 * k
312
- face(g, look, px, 900)
407
+ const p = own({ ...base((d.fx?.size ?? 54) * k, 900), fill: look.chip.ink })
408
+ setFace(g, p)
313
409
  const t = [first, ...more].join(' ')
314
- const w = g.measureText(t).width + 72 * k
315
- const h = px * 1.5
410
+ const w = g.measureText(t.replaceAll('*', '')).width + 72 * k
411
+ const h = p.px * 1.5
316
412
  const x = 72 * k
317
413
  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)
414
+ decor(cx, g, () => plate(g, x, y, w, h, h / 2, k, look.chip.fill, darker(look.chip.fill, 0.15), look.edge))
415
+ ink(cx, g, p, t, x + 36 * k, y + h / 2 + 2 * k, 'left')
322
416
  }
417
+ }
418
+
419
+ /** one title at rest over a transparent frame, as a PNG */
420
+ export function drawTitle(text: string, style: TitleStyle, size: readonly [number, number], place: TitlePlace, look: TrailerLook, logo: Image | null, step?: Title['step'], fx?: TitleFx, family: string | null = null): Buffer {
421
+ const c = createCanvas(size[0], size[1])
422
+ drawInto(c.getContext('2d'), { text, style, place, look, logo, family, ...(step ? { step } : {}), ...(fx ? { fx } : {}) }, size, restCtx())
323
423
  return Buffer.from(c.toBuffer('image/png'))
324
424
  }
325
425
 
@@ -337,32 +437,148 @@ export function drawCard([W, H]: readonly [number, number], look: TrailerLook):
337
437
 
338
438
  const key = (parts: unknown): string => new Bun.CryptoHasher('sha1').update(JSON.stringify([DRAWING, parts])).digest('hex').slice(0, 16)
339
439
 
440
+ const fileState = (f: string | null, root: string): unknown => {
441
+ if (!f) return null
442
+ const abs = f.startsWith('/') ? f : join(root, f)
443
+ return existsSync(abs) ? [f, statSync(abs).size, statSync(abs).mtimeMs] : [f, 'missing']
444
+ }
445
+
340
446
  /** 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
- })
447
+ export const lookFiles = (look: TrailerLook, root: string): unknown[] => [look.font.file, look.logo, look.short.font.file].map((f) => fileState(f, root))
448
+
449
+ /** what one title draws: its words, style, frame, place, look, step, its own look words, its moves in frames, and the
450
+ * font file its `font` word names */
451
+ export interface TitleRequest {
452
+ root: string
453
+ text: string
454
+ style: TitleStyle
455
+ size: readonly [number, number]
456
+ place: TitlePlace
457
+ look: TrailerLook
458
+ step?: Title['step']
459
+ fx?: TitleFx
460
+ motion?: TitleMotion
461
+ /** the absolute file of its `font` word (a repo file or the cached Google face), null for the look's face */
462
+ font?: string | null
463
+ }
464
+
465
+ const keyParts = (r: TitleRequest): unknown[] => [r.text, r.style, r.size, r.place, r.look, lookFiles(r.look, r.root), r.step ? [r.step.i, r.step.n] : null, r.fx ?? null, r.motion ?? null, fileState(r.font ?? null, r.root)]
347
466
 
348
467
  /** 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`)
468
+ export const titleFile = (dir: string, r: TitleRequest): string => join(dir, `title-${key(keyParts(r))}.png`)
351
469
 
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)
470
+ /** the alpha box of a frame's pixels, null when it draws nothing */
471
+ function boxOf(data: Uint8ClampedArray, W: number, H: number): { x: number; y: number; w: number; h: number } | null {
472
+ let [x0, y0, x1, y1] = [W, H, -1, -1]
473
+ for (let y = 0; y < H; y++) {
474
+ const row = y * W * 4
475
+ for (let x = 0; x < W; x++)
476
+ if (data[row + x * 4 + 3]! > 0) {
477
+ if (x < x0) x0 = x
478
+ if (x > x1) x1 = x
479
+ if (y < y0) y0 = y
480
+ y1 = y
481
+ }
482
+ }
483
+ return x1 < 0 ? null : { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }
484
+ }
485
+
486
+ /** the rows of `box` out of a frame W wide */
487
+ function crop(data: Uint8ClampedArray, W: number, box: { x: number; y: number; w: number; h: number }): Uint8ClampedArray {
488
+ const out = new Uint8ClampedArray(box.w * box.h * 4)
489
+ for (let y = 0; y < box.h; y++) out.set(data.subarray(((box.y + y) * W + box.x) * 4, ((box.y + y) * W + box.x + box.w) * 4), y * box.w * 4)
490
+ return out
491
+ }
492
+
493
+ /** the drawn title moved as a whole: its alpha, offset, scale and turn about its box's middle, a blur, a wipe from the
494
+ * left with a soft edge */
495
+ function moved(g: SKRSContext2D, src: Canvas, w: Whole, mid: readonly [number, number], box: { x: number; w: number }, k: number): void {
496
+ g.save()
497
+ g.globalAlpha = w.a
498
+ if (w.blur > 0.3) g.filter = `blur(${w.blur.toFixed(2)}px)`
499
+ g.translate(mid[0] + w.dx, mid[1] + w.dy)
500
+ g.rotate(w.rot)
501
+ g.scale(w.s, w.s)
502
+ g.translate(-mid[0], -mid[1])
503
+ g.drawImage(src, 0, 0)
504
+ g.restore()
505
+ if (w.wipe < 1) {
506
+ // the wipe's soft edge: what it shows stays, a feather of a tenth of the box fades, the rest goes
507
+ const feather = Math.max(24 * k, box.w * 0.1)
508
+ const edge = box.x + w.wipe * (box.w + feather)
509
+ const m = g.createLinearGradient(edge - feather, 0, edge, 0)
510
+ m.addColorStop(0, 'rgba(0,0,0,1)')
511
+ m.addColorStop(1, 'rgba(0,0,0,0)')
512
+ g.save()
513
+ g.globalCompositeOperation = 'destination-in'
514
+ g.fillStyle = m
515
+ g.fillRect(0, 0, g.canvas.width, g.canvas.height)
516
+ g.restore()
517
+ }
518
+ }
519
+
520
+ /** the PNG of a title at rest and the frames of its entrance and exit, all cut to one box, drawn once per what they
521
+ * draw; the box and the frame counts in `<file>.json` beside it */
522
+ export async function titlePng(dir: string, r: TitleRequest): Promise<TitleImage> {
523
+ const file = titleFile(dir, r)
355
524
  const where = `${file}.json`
356
525
  if (!existsSync(file) || !existsSync(where)) {
357
526
  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 }))
527
+ registerFace(r.look, r.root)
528
+ const family = r.font ? registerFont(r.font) : null
529
+ const logo = r.style === 'logo' && r.look.logo && existsSync(join(r.root, r.look.logo)) ? await loadImage(join(r.root, r.look.logo)) : null
530
+ const [W, H] = r.size
531
+ const k = Math.min(W, H) / 1080
532
+ const d: TitleDraw = { text: r.text, style: r.style, place: r.place, look: r.look, logo, family, ...(r.step ? { step: r.step } : {}), ...(r.fx ? { fx: r.fx } : {}) }
533
+ const still = createCanvas(W, H)
534
+ const rest = restCtx()
535
+ drawInto(still.getContext('2d'), d, r.size, rest)
536
+ const n = rest.i
537
+ const stillData = still.getContext('2d').getImageData(0, 0, W, H).data
538
+ const at = boxOf(stillData, W, H) ?? { x: 0, y: 0, w: 1, h: 1 }
539
+ const mid = [at.x + at.w / 2, at.y + at.h / 2] as const
540
+ const px = (r.fx?.size ?? 80) * k
541
+ const m = r.motion ?? { in: { kind: 'none', frames: 0 }, out: { kind: 'none', frames: 0 } }
542
+ const frameOf = (f: TitleFrame): Uint8ClampedArray => {
543
+ const c = createCanvas(W, H)
544
+ const g = c.getContext('2d')
545
+ let src: Canvas = still
546
+ if (f.glyph) {
547
+ src = createCanvas(W, H)
548
+ drawInto(src.getContext('2d'), d, r.size, { glyph: f.glyph, n, i: 0, decor: f.decor })
549
+ }
550
+ if (f.whole === STILL) g.drawImage(src, 0, 0)
551
+ else moved(g, src, f.whole, mid, at, k)
552
+ return g.getImageData(0, 0, W, H).data
553
+ }
554
+ // each frame kept as its own box only: a logo's frames are each the whole frame
555
+ const frames: { box: ReturnType<typeof boxOf>; data: Uint8ClampedArray; name: string }[] = []
556
+ const keep = (data: Uint8ClampedArray, name: string): void => {
557
+ const box = boxOf(data, W, H)
558
+ frames.push({ box, data: box ? crop(data, W, box) : new Uint8ClampedArray(0), name })
559
+ }
560
+ const nth = (pattern: string, j: number): string => pattern.replace('%04d', String(j).padStart(4, '0'))
561
+ keep(stillData, file)
562
+ const inKind = m.in.kind
563
+ for (let j = 0; j < m.in.frames; j++) keep(frameOf(inFrame(inKind, j, m.in.frames, r.size, px)), nth(inFrames(file), j))
564
+ for (let j = 0; j < m.out.frames; j++) keep(frameOf(outFrame(m.out.kind, j, m.out.frames, r.size, px)), nth(outFrames(file), j))
565
+ // one box for every frame, even sides for the encoder
566
+ const boxes = frames.flatMap((f) => (f.box ? [f.box] : []))
567
+ const x0 = Math.min(...boxes.map((b) => b.x))
568
+ const y0 = Math.min(...boxes.map((b) => b.y))
569
+ const x1 = Math.max(...boxes.map((b) => b.x + b.w))
570
+ const y1 = Math.max(...boxes.map((b) => b.y + b.h))
571
+ const u = boxes.length ? { x: x0, y: y0, w: Math.min(W - x0, x1 - x0 + ((x1 - x0) % 2)), h: Math.min(H - y0, y1 - y0 + ((y1 - y0) % 2)) } : { x: 0, y: 0, w: 2, h: 2 }
572
+ await Promise.all(
573
+ frames.map(async (f) => {
574
+ const out = new Uint8ClampedArray(u.w * u.h * 4)
575
+ if (f.box) for (let y = 0; y < f.box.h; y++) out.set(f.data.subarray(y * f.box.w * 4, (y + 1) * f.box.w * 4), ((f.box.y - u.y + y) * u.w + (f.box.x - u.x)) * 4)
576
+ writeFileSync(f.name, await sharp(Buffer.from(out.buffer), { raw: { width: u.w, height: u.h, channels: 4 } }).png({ compressionLevel: 1, adaptiveFiltering: false }).toBuffer())
577
+ }),
578
+ )
579
+ writeFileSync(where, JSON.stringify({ x: u.x, y: u.y, w: u.w, h: u.h, in: m.in.frames, out: m.out.frames }))
363
580
  }
364
- const { x, y, w, h } = JSON.parse(readFileSync(where, 'utf8')) as TitleImage
365
- return { file, x, y, w, h }
581
+ return { file, ...(JSON.parse(readFileSync(where, 'utf8')) as Omit<TitleImage, 'file'>) }
366
582
  }
367
583
 
368
584
  /** the card's ground's PNG file for a frame and a look */