rvlib-shipkit 2.2.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 (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -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 +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -1,50 +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). Five styles: logo, big, caption, corner, line. Cached on what they draw, so a title edit draws one PNG
3
- 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'
4
6
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
5
7
  import { join } from 'node:path'
6
8
  import sharp from 'sharp'
7
- import type { Title, TitleStyle } from './grammar.ts'
8
- import { alpha, darker, type TrailerLook } from './look.ts'
9
+ import { hasFamily, registerFont } from './fonts.ts'
10
+ import type { Title, TitleFx, TitlePlace, TitleStyle } from './grammar.ts'
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'
9
13
 
10
14
  /** bump when a style's drawing changes: every cached title draws again */
11
- const DRAWING = 1
15
+ const DRAWING = 3
12
16
 
13
- export type TitlePlace = 'top' | 'bottom'
14
- export const placeOf = (ti: Title): TitlePlace => ti.place ?? 'bottom'
17
+ export { placeOf, type TitlePlace } from './grammar.ts'
15
18
 
16
- /** 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 */
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
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 */
17
26
  export interface TitleImage {
18
27
  file: string
19
28
  x: number
20
29
  y: number
21
30
  w: number
22
31
  h: number
32
+ in?: number
33
+ out?: number
23
34
  }
24
35
 
25
36
  const registered = new Set<string>()
26
- /** the look's font file registered once under its family */
37
+ /** the look's font files registered once under their families; a family the machine lacks fails loud */
27
38
  export function registerFace(look: TrailerLook, root: string): void {
28
- if (!look.font.file || registered.has(look.font.file)) return
29
- const abs = join(root, look.font.file)
30
- if (!existsSync(abs)) throw new Error(`the brand's trailer font ${look.font.file} is not in the repo`)
31
- GlobalFonts.registerFromPath(abs, look.font.family)
32
- registered.add(look.font.file)
39
+ for (const f of [look.font, look.short.font]) {
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)`)
47
+ }
48
+ }
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}`
33
53
  }
34
54
 
35
55
  export const face = (g: SKRSContext2D, look: TrailerLook, px: number, weight = look.font.weight): void => {
36
56
  const fam = look.font.family.includes(',') ? look.font.family : `"${look.font.family}"`
37
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]
38
105
  }
39
106
 
40
- /** 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 */
41
221
  function wrap(g: SKRSContext2D, text: string, max: number): string[] {
42
222
  const out: string[] = []
43
223
  for (const para of text.split('\\n')) {
44
224
  let line = ''
45
225
  for (const w of para.split(/\s+/).filter(Boolean)) {
46
226
  const next = line ? `${line} ${w}` : w
47
- if (line && g.measureText(next).width > max) {
227
+ if (line && g.measureText(next.replaceAll('*', '')).width > max) {
48
228
  out.push(line)
49
229
  line = w
50
230
  } else line = next
@@ -72,156 +252,174 @@ function plate(g: SKRSContext2D, x: number, y: number, w: number, h: number, r:
72
252
  g.stroke()
73
253
  }
74
254
 
75
- /** one title over a transparent frame. A title's `\n` breaks its line; a logo's second line is its subtitle */
76
- export function drawTitle(text: string, style: TitleStyle, [W, H]: readonly [number, number], place: TitlePlace, look: TrailerLook, logo: Image | null): Buffer {
77
- const top = place === 'top'
78
- const c = createCanvas(W, H)
79
- const g = c.getContext('2d')
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
+ })
265
+ }
266
+
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
278
+ }
279
+
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'
80
285
  const k = Math.min(H, W) / 1080
286
+ const own = (p: Paint): Paint => paintWith(p, d.fx, look, k, d.family)
81
287
  g.textAlign = 'center'
82
288
  g.textBaseline = 'middle'
83
289
  const [first = '', ...more] = text.split('\\n')
84
- if (style === 'logo') {
85
- // the logo image over the word, the word in the accent over the edge and its soft drop, the subtitle spaced under it
86
- const glow = g.createRadialGradient(W / 2, H * 0.48, 0, W / 2, H * 0.48, Math.max(W, H) * 0.45)
87
- glow.addColorStop(0, alpha(look.edge, 0.45))
88
- glow.addColorStop(1, alpha(look.edge, 0))
89
- g.fillStyle = glow
90
- g.fillRect(0, 0, W, H)
91
- const markH = logo ? Math.min(H * 0.3, W * 0.3) : 0
92
- face(g, look, 170 * k, 900)
93
- const kl = Math.min(1, (W * 0.78) / (g.measureText(first).width + 16 * k))
94
- const px = 170 * k * kl
95
- const subPx = 34 * k * Math.max(kl, 0.7)
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 })
291
+ if (style === 'short' || style === 'progress') {
292
+ const sl = shortLook(look)
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
+ }
334
+ } else if (style === 'logo') {
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)
96
349
  const block = markH + (markH ? px * 0.25 : 0) + (first ? px : 0) + (more.length ? subPx * 2 : 0)
97
350
  let y = H * 0.48 - block / 2
351
+ const logo = d.logo
98
352
  if (logo) {
99
353
  const w = (logo.width / logo.height) * markH
100
- g.save()
101
- g.shadowColor = alpha(look.edge, 0.6)
102
- g.shadowBlur = 30 * k
103
- g.drawImage(logo, W / 2 - w / 2, y, w, markH)
104
- 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
+ })
105
360
  y += markH + px * 0.25
106
361
  }
107
362
  if (first) {
108
- const cy = y + px / 2
109
- face(g, look, px, 900)
110
- g.save()
111
- g.shadowColor = alpha(look.edge, 0.6)
112
- g.shadowBlur = 30 * k
113
- g.shadowOffsetY = 12 * k
114
- g.lineJoin = 'round'
115
- g.lineWidth = 16 * k * kl
116
- g.strokeStyle = look.edge
117
- g.strokeText(first, W / 2, cy + 3 * k)
118
- g.restore()
119
- const grad = g.createLinearGradient(0, cy - px / 2, 0, cy + px / 2)
120
- grad.addColorStop(0.1, look.light)
121
- grad.addColorStop(0.55, look.accent)
122
- grad.addColorStop(1, darker(look.accent, 0.2))
123
- g.fillStyle = grad
124
- 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)
125
365
  y += px
126
366
  }
127
367
  if (more.length) {
128
- face(g, look, subPx, 800)
129
- ;(g as unknown as { letterSpacing: string }).letterSpacing = `${Math.round(subPx * 0.42)}px`
130
- g.shadowColor = alpha(look.edge, 0.9)
131
- g.shadowBlur = subPx * 0.42
132
- g.fillStyle = look.light
133
- 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)
134
370
  }
135
371
  } else if (style === 'big') {
136
- // a strong display line in the upper third: light words in a thick edge outline, an accent under-glow
137
- const px = 112 * k
138
- 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)
139
375
  const lines = wrap(g, [first, ...more].join(' '), W * 0.82)
140
- lines.forEach((l, i) => {
141
- const y = H * 0.26 + (i - (lines.length - 1) / 2) * px * 1.12
142
- g.save()
143
- g.shadowColor = alpha(look.edge, 0.55)
144
- g.shadowBlur = 26 * k
145
- g.shadowOffsetY = 10 * k
146
- g.lineJoin = 'round'
147
- g.lineWidth = 22 * k
148
- g.strokeStyle = look.edge
149
- g.strokeText(l, W / 2, y)
150
- g.restore()
151
- g.lineWidth = 8 * k
152
- g.strokeStyle = look.accent
153
- g.strokeText(l, W / 2, y)
154
- g.fillStyle = look.light
155
- g.fillText(l, W / 2, y)
156
- })
376
+ lines.forEach((l, i) => ink(cx, g, p, l, W / 2, H * 0.26 + (i - (lines.length - 1) / 2) * p.px * 1.12))
157
377
  } else if (style === 'line') {
158
378
  // a tagline: light words centred low on a soft dark band, no plate, the frame stays the picture
159
- const px = 64 * k
160
- 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)
161
381
  const lines = more.length > 0 ? [first, ...more] : wrap(g, first, W * 0.7)
162
- const lh = px * 1.3
382
+ const lh = p.px * 1.3
163
383
  const cy = top ? H * 0.2 : H * 0.8
164
384
  const span = lh * lines.length + 80 * k
165
- const band = g.createLinearGradient(0, cy - span, 0, cy + span)
166
- band.addColorStop(0, alpha(look.edge, 0))
167
- band.addColorStop(0.5, alpha(look.edge, 0.42))
168
- band.addColorStop(1, alpha(look.edge, 0))
169
- g.fillStyle = band
170
- g.fillRect(0, cy - span, W, 2 * span)
171
- lines.forEach((l, i) => {
172
- const y = cy + (i - (lines.length - 1) / 2) * lh
173
- // `*word*` is a key word in the accent, the rest light
174
- const parts = l.split('*').map((t, n) => ({ text: t, key: n % 2 === 1 }))
175
- const plain = parts.map((p) => p.text).join('')
176
- let x = W / 2 - g.measureText(plain).width / 2
177
- g.textAlign = 'left'
178
- g.save()
179
- g.shadowColor = alpha(look.edge, 0.85)
180
- g.shadowBlur = 18 * k
181
- g.shadowOffsetY = 4 * k
182
- g.lineJoin = 'round'
183
- g.lineWidth = 9 * k
184
- g.strokeStyle = alpha(look.edge, 0.82)
185
- g.strokeText(plain, x, y)
186
- g.restore()
187
- for (const p of parts) {
188
- g.fillStyle = p.key ? look.accent : look.light
189
- g.fillText(p.text, x, y)
190
- x += g.measureText(p.text).width
191
- }
192
- g.textAlign = 'center'
193
- })
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))
194
387
  } else if (style === 'caption') {
195
388
  // the plate: its fill, its outline, its ink, an accent dot leading it
196
- const px = 46 * k
197
- face(g, look, px, 900)
389
+ const p = own({ ...base((d.fx?.size ?? 46) * k, 900), fill: look.plate.ink })
390
+ setFace(g, p)
198
391
  const lines = wrap(g, [first, ...more].join(' '), W * 0.66)
199
- const lh = px * 1.28
392
+ const lh = p.px * 1.28
200
393
  const dot = 14 * k
201
- 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
202
395
  const bh = lines.length * lh + 34 * k
203
396
  const cy = top ? H * 0.12 : H * 0.83
204
- 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)
205
- g.fillStyle = look.accent
206
- g.beginPath()
207
- g.arc(W / 2 - bw / 2 + 34 * k, cy, dot / 2 + 2 * k, 0, Math.PI * 2)
208
- g.fill()
209
- g.fillStyle = look.plate.ink
210
- 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))
211
405
  } else {
212
406
  // corner: a word chip, bottom left (top left when placed at the top)
213
- const px = 54 * k
214
- face(g, look, px, 900)
407
+ const p = own({ ...base((d.fx?.size ?? 54) * k, 900), fill: look.chip.ink })
408
+ setFace(g, p)
215
409
  const t = [first, ...more].join(' ')
216
- const w = g.measureText(t).width + 72 * k
217
- const h = px * 1.5
410
+ const w = g.measureText(t.replaceAll('*', '')).width + 72 * k
411
+ const h = p.px * 1.5
218
412
  const x = 72 * k
219
413
  const y = top ? 80 * k : H - 80 * k - h
220
- plate(g, x, y, w, h, h / 2, k, look.chip.fill, darker(look.chip.fill, 0.15), look.edge)
221
- g.fillStyle = look.chip.ink
222
- g.textAlign = 'left'
223
- 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')
224
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())
225
423
  return Buffer.from(c.toBuffer('image/png'))
226
424
  }
227
425
 
@@ -239,33 +437,156 @@ export function drawCard([W, H]: readonly [number, number], look: TrailerLook):
239
437
 
240
438
  const key = (parts: unknown): string => new Bun.CryptoHasher('sha1').update(JSON.stringify([DRAWING, parts])).digest('hex').slice(0, 16)
241
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
+
242
446
  /** what the look reads from disk, as it stands: a new font or logo file draws every title again */
243
- export const lookFiles = (look: TrailerLook, root: string): unknown[] =>
244
- [look.font.file, look.logo].map((f) => {
245
- if (!f) return null
246
- const abs = join(root, f)
247
- return existsSync(abs) ? [f, statSync(abs).size, statSync(abs).mtimeMs] : [f, 'missing']
248
- })
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
+ }
249
464
 
250
- /** the PNG of a title, drawn once per text, style, frame, place and look; its box on the frame in `<file>.json` beside it */
251
- export async function titlePng(dir: string, root: string, text: string, style: TitleStyle, size: readonly [number, number], place: TitlePlace, look: TrailerLook): Promise<TitleImage> {
252
- const file = join(dir, `title-${key([text, style, size, place, look, lookFiles(look, root)])}.png`)
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)]
466
+
467
+ /** a title's PNG file, named by what it draws */
468
+ export const titleFile = (dir: string, r: TitleRequest): string => join(dir, `title-${key(keyParts(r))}.png`)
469
+
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)
253
524
  const where = `${file}.json`
254
525
  if (!existsSync(file) || !existsSync(where)) {
255
526
  mkdirSync(dir, { recursive: true })
256
- registerFace(look, root)
257
- const logo = style === 'logo' && look.logo && existsSync(join(root, look.logo)) ? await loadImage(join(root, look.logo)) : null
258
- const { data, info } = await sharp(drawTitle(text, style, size, place, look, logo)).trim({ threshold: 0 }).png().toBuffer({ resolveWithObject: true })
259
- writeFileSync(file, data)
260
- 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 }))
261
580
  }
262
- const { x, y, w, h } = JSON.parse(readFileSync(where, 'utf8')) as TitleImage
263
- return { file, x, y, w, h }
581
+ return { file, ...(JSON.parse(readFileSync(where, 'utf8')) as Omit<TitleImage, 'file'>) }
264
582
  }
265
583
 
584
+ /** the card's ground's PNG file for a frame and a look */
585
+ export const cardFile = (dir: string, size: readonly [number, number], look: TrailerLook): string => join(dir, `card-${key(['card', size, look.ground])}.png`)
586
+
266
587
  /** the PNG of the card's ground for a frame and a look */
267
588
  export function cardPng(dir: string, size: readonly [number, number], look: TrailerLook): string {
268
- const file = join(dir, `card-${key(['card', size, look.ground])}.png`)
589
+ const file = cardFile(dir, size, look)
269
590
  if (!existsSync(file)) {
270
591
  mkdirSync(dir, { recursive: true })
271
592
  writeFileSync(file, drawCard(size, look))