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.
- package/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +183 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +83 -14
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +389 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
package/src/trailer/titles.ts
CHANGED
|
@@ -1,50 +1,230 @@
|
|
|
1
|
-
// the trailer's drawn frames: one transparent PNG per title
|
|
2
|
-
// look (look.ts).
|
|
3
|
-
|
|
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
|
|
8
|
-
import {
|
|
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 =
|
|
15
|
+
const DRAWING = 3
|
|
12
16
|
|
|
13
|
-
export type TitlePlace
|
|
14
|
-
export const placeOf = (ti: Title): TitlePlace => ti.place ?? 'bottom'
|
|
17
|
+
export { placeOf, type TitlePlace } from './grammar.ts'
|
|
15
18
|
|
|
16
|
-
/** a
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
101
|
-
g
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
109
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
137
|
-
const
|
|
138
|
-
|
|
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
|
|
160
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
197
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
214
|
-
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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 =
|
|
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))
|