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.
- package/CHANGELOG.md +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- 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/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- 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 +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/serve-hub.ts +7 -0
- 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 +33 -4
- package/src/trailer/build.ts +170 -29
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- 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/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- 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/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- 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,59 +1,230 @@
|
|
|
1
|
-
// the trailer's drawn frames: one transparent PNG per title
|
|
2
|
-
// look (look.ts). Seven styles
|
|
3
|
-
// short
|
|
4
|
-
|
|
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
|
|
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 =
|
|
15
|
+
const DRAWING = 3
|
|
13
16
|
|
|
14
|
-
|
|
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
|
|
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 (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
/**
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
133
|
-
*
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
const
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
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
|
-
|
|
199
|
-
g
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
|
235
|
-
const
|
|
236
|
-
|
|
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
|
|
258
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
295
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
312
|
-
|
|
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
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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,
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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 */
|