rvlib-shipkit 2.5.0 → 2.6.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 +4 -0
- package/package.json +1 -1
- package/src/trailer/build.ts +3 -2
- package/src/trailer/grammar.ts +56 -11
- package/src/trailer/look.ts +31 -16
- package/src/trailer/prune.ts +59 -12
- package/src/trailer/score/dsp.ts +87 -27
- package/src/trailer/score/pattern.ts +1 -1
- package/src/trailer/score/synth.ts +26 -5
- package/src/trailer/titles.ts +63 -34
- package/src/trailer/vocab.ts +65 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2.6.0
|
|
2
|
+
|
|
3
|
+
- **A trailer's score sounds as the game's browser synth, section for section.** A note that starts inside a 128 frame block takes the phase the browser gives it (it runs at the default 440 Hz until its own frame of that block), so overlapping notes of one pitch, a timpani roll, add or cancel as in the browser; `osc: 'square'` is the browser's own square wave, no longer a sine; the master's compressor and limiter are the browser's kernel (32 frame divisions, adaptive release, the sine warp on the gain). pkm-multi's main4 score: 38 of 38 sections within 0.1 dB of the game's render (were 18), each octave band within 0.15 dB. Every score renders again once.
|
|
4
|
+
|
|
1
5
|
## 2.5.0
|
|
2
6
|
|
|
3
7
|
- **A review card hands itself to any agent session.** A small copy icon on the card's header (or C) copies one block: its title and state, its repo, `cd <repo> && sk review show <id>`, its link here and on the phone when the repo has a public host; a toast says what was copied. `sk review show <id> --paste` prints the same block.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
package/src/trailer/build.ts
CHANGED
|
@@ -629,10 +629,11 @@ function shelve(l: Loaded, takes: readonly Take[]): void {
|
|
|
629
629
|
|
|
630
630
|
/** the cache trimmed after a stitch, said in one line */
|
|
631
631
|
async function pruneAfter(root: string, say: Say): Promise<void> {
|
|
632
|
-
const {
|
|
632
|
+
const { pruneIfMoved, sayPruned } = await import('./prune.ts')
|
|
633
633
|
// the cut is written: a prune that fails is said, never the build's failure
|
|
634
634
|
try {
|
|
635
|
-
|
|
635
|
+
const p = await pruneIfMoved(root)
|
|
636
|
+
for (const line of p === null ? ['cache prune skipped: nothing it reads moved since the last one'] : sayPruned(p, false, true)) say(line)
|
|
636
637
|
} catch (e) {
|
|
637
638
|
say(`WARN the cache prune failed, the cut is written: ${(e as Error).message}`)
|
|
638
639
|
}
|
package/src/trailer/grammar.ts
CHANGED
|
@@ -167,11 +167,12 @@ export interface Title {
|
|
|
167
167
|
export type TitlePlace = 'top' | 'middle' | 'bottom'
|
|
168
168
|
export const placeOf = (ti: Pick<Title, 'place' | 'style'>): TitlePlace => ti.place ?? (ti.style === 'short' ? 'middle' : ti.style === 'progress' ? 'top' : 'bottom')
|
|
169
169
|
|
|
170
|
-
/** a colour as a line writes it: `#rgb`, `#rrggbb`, or one of the look's (titles.ts resolves it) */
|
|
170
|
+
/** a colour as a line writes it: `#rgb`, `#rrggbb`, `#rrggbbaa`, or one of the look's (titles.ts resolves it) */
|
|
171
171
|
export const LOOK_COLOURS = ['light', 'edge', 'accent'] as const
|
|
172
172
|
|
|
173
173
|
/** a title's look over its style's: its face (a font file or a Google Fonts family), weight, size in pixels of a frame
|
|
174
|
-
* 1080 tall, fill
|
|
174
|
+
* 1080 tall, fill (a gradient's colours each `colour[@share]`, top to bottom), and its outline, inner stroke, drop,
|
|
175
|
+
* shadow (and its fall) and glow, each a size in those pixels and a colour; a logo's layout (LOGO_WORDS) */
|
|
175
176
|
export interface TitleFx {
|
|
176
177
|
font?: string
|
|
177
178
|
weight?: number
|
|
@@ -179,10 +180,25 @@ export interface TitleFx {
|
|
|
179
180
|
color?: string
|
|
180
181
|
gradient?: string[]
|
|
181
182
|
outline?: { px: number; color?: string }
|
|
182
|
-
|
|
183
|
+
inner?: { px: number; color?: string }
|
|
184
|
+
drop?: { px: number; color?: string }
|
|
185
|
+
shadow?: { px: number; color?: string; dy?: number }
|
|
183
186
|
glow?: { px: number; color?: string }
|
|
187
|
+
/** the widest share of the frame's width the word takes */
|
|
188
|
+
fit?: number
|
|
189
|
+
/** the subtitle's height to the word's */
|
|
190
|
+
sub?: number
|
|
191
|
+
/** the space between the word and its subtitle, a share of the word's height */
|
|
192
|
+
gap?: number
|
|
193
|
+
/** the word's middle at this share of the frame's height (default: the whole block's middle at 0.48) */
|
|
194
|
+
middle?: number
|
|
195
|
+
/** the soft shade round the logo, its radius a share of the frame's longer side; 0 for none */
|
|
196
|
+
shade?: number
|
|
184
197
|
}
|
|
185
198
|
|
|
199
|
+
/** the look words only a logo title reads */
|
|
200
|
+
export const LOGO_WORDS = ['fit', 'sub', 'gap', 'middle', 'shade'] as const
|
|
201
|
+
|
|
186
202
|
/** how a made sound is made (design call 9): its seed, the voice or engine it is made `by`, its length (`for`), an sfx
|
|
187
203
|
* trimmed to its first `hit` */
|
|
188
204
|
export interface SoundGen {
|
|
@@ -403,8 +419,8 @@ interface Word {
|
|
|
403
419
|
|
|
404
420
|
/** a line's words: a quoted run is one word (quoted), `#` outside quotes ends the line. Only double quotes quote, as in
|
|
405
421
|
* the full language, so a word opening on a single quote is refused: it would reach a command with the quotes inside */
|
|
406
|
-
/** a hex colour at `k` (`#rgb`, `#rrggbb`, then the word's end or a
|
|
407
|
-
const colourAt = (line: string, k: number): boolean => /^#([0-9a-f]{3}|[0-9a-f]{6})(?=[\s,"]|$)/i.test(line.slice(k))
|
|
422
|
+
/** a hex colour at `k` (`#rgb`, `#rrggbb`, `#rrggbbaa`, then the word's end, a comma or a gradient place `@`): a word, never a comment */
|
|
423
|
+
const colourAt = (line: string, k: number): boolean => /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})(?=[\s,"@]|$)/i.test(line.slice(k))
|
|
408
424
|
|
|
409
425
|
export function tokens(line: string): { words: Word[] } | { error: string } {
|
|
410
426
|
const words: Word[] = []
|
|
@@ -511,9 +527,9 @@ const sizeWord = (what: string, w: Word | undefined, lo: number, hi: number): [n
|
|
|
511
527
|
return [x, y]
|
|
512
528
|
}
|
|
513
529
|
|
|
514
|
-
/** a colour word: `#rgb`, `#rrggbb`, or the look's light, edge or accent */
|
|
530
|
+
/** a colour word: `#rgb`, `#rrggbb`, `#rrggbbaa` (its last two digits the alpha), or the look's light, edge or accent */
|
|
515
531
|
const colour = (k: string, text: string | undefined): string => {
|
|
516
|
-
if (!text || !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|light|edge|accent)$/i.test(text)) return bad(`${k} wants a colour: #rgb, #rrggbb, ${LOOK_COLOURS.join(', ')}${text ? `, got ${text}` : ''}`)
|
|
532
|
+
if (!text || !/^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|light|edge|accent)$/i.test(text)) return bad(`${k} wants a colour: #rgb, #rrggbb, #rrggbbaa, ${LOOK_COLOURS.join(', ')}${text ? `, got ${text}` : ''}`)
|
|
517
533
|
return text
|
|
518
534
|
}
|
|
519
535
|
/** `<px>[,<colour>]`: an outline, a shadow or a glow, its size in pixels of a frame 1080 tall */
|
|
@@ -524,8 +540,19 @@ const sized = (w: Word | undefined, k: string): { px: number; color?: string } =
|
|
|
524
540
|
if (!Number.isFinite(px) || px < 0 || px > 300 || more !== undefined) return bad(`${k} wants <px>[,<colour>], its size 0 to 300, got ${w.text}`)
|
|
525
541
|
return { px, ...(c !== undefined ? { color: colour(k, c) } : {}) }
|
|
526
542
|
}
|
|
543
|
+
/** `<px>[,<colour>[,<fall>]]`: a shadow's blur, its colour and how far it falls (default a third of its blur) */
|
|
544
|
+
const shadowWord = (w: Word | undefined, k: string): { px: number; color?: string; dy?: number } => {
|
|
545
|
+
const [head, ...fall] = w && !w.quoted ? w.text.split(',') : []
|
|
546
|
+
const wrong = (): never => bad(`${k} wants <px>[,<colour>[,<fall>]], its fall 0 to 300, got ${w!.text}`)
|
|
547
|
+
if (fall.length > 2) return wrong()
|
|
548
|
+
const base = sized(w && fall.length === 2 ? { ...w, text: `${head},${fall[0]}` } : w, k)
|
|
549
|
+
if (fall.length < 2) return base
|
|
550
|
+
const dy = Number(fall[1])
|
|
551
|
+
if (!/^\d+(\.\d+)?$/.test(fall[1]!) || dy > 300) return wrong()
|
|
552
|
+
return { ...base, dy }
|
|
553
|
+
}
|
|
527
554
|
/** a title's look words, in TitleFx */
|
|
528
|
-
const FX_WORDS = ['font', 'weight', 'size', 'color', 'gradient', 'outline', 'shadow', 'glow'] as const
|
|
555
|
+
const FX_WORDS = ['font', 'weight', 'size', 'color', 'gradient', 'outline', 'inner', 'drop', 'shadow', 'glow', ...LOGO_WORDS] as const
|
|
529
556
|
const TITLE_WORDS: Record<string, (w: Word | undefined, k: string) => unknown> = {
|
|
530
557
|
style: (w, k) => word(k, w, /^[a-z][a-z0-9-]*$/),
|
|
531
558
|
at: when,
|
|
@@ -544,12 +571,23 @@ const TITLE_WORDS: Record<string, (w: Word | undefined, k: string) => unknown> =
|
|
|
544
571
|
color: (w, k) => colour(k, w && !w.quoted ? w.text : undefined),
|
|
545
572
|
gradient: (w, k) => {
|
|
546
573
|
const cs = w && !w.quoted ? w.text.split(',') : []
|
|
547
|
-
if (cs.length < 2 || cs.length > 4) bad(`${k} wants two to four colours, top to bottom: light,accent`)
|
|
548
|
-
return cs.map((c) =>
|
|
574
|
+
if (cs.length < 2 || cs.length > 4) bad(`${k} wants two to four colours, top to bottom: light,accent (a colour's place colour@0.6)`)
|
|
575
|
+
return cs.map((c) => {
|
|
576
|
+
const [col, at] = c.split('@')
|
|
577
|
+
if (at !== undefined && !/^(0(\.\d+)?|1(\.0+)?)$/.test(at)) bad(`${k}: a colour's place is a share from 0 to 1, got ${c}`)
|
|
578
|
+
return at === undefined ? colour(k, col) : `${colour(k, col)}@${at}`
|
|
579
|
+
})
|
|
549
580
|
},
|
|
550
581
|
outline: sized,
|
|
551
|
-
|
|
582
|
+
inner: sized,
|
|
583
|
+
drop: sized,
|
|
584
|
+
shadow: shadowWord,
|
|
552
585
|
glow: sized,
|
|
586
|
+
fit: (w, k) => num(k, w, 0.1, 1),
|
|
587
|
+
sub: (w, k) => num(k, w, 0.05, 1),
|
|
588
|
+
gap: (w, k) => num(k, w, 0, 1),
|
|
589
|
+
middle: (w, k) => num(k, w, 0, 1),
|
|
590
|
+
shade: (w, k) => num(k, w, 0, 2),
|
|
553
591
|
}
|
|
554
592
|
|
|
555
593
|
/** a title's words, in order, each `+<preset>` in place; `in` and `out` take a move and, when the next word is a
|
|
@@ -589,6 +627,11 @@ function readTitleWords(raw: Word[], presets: ReadonlyMap<string, Word[]>, what
|
|
|
589
627
|
/** a title's look words out of read values */
|
|
590
628
|
const fxOf = (v: Record<string, unknown>): TitleFx => Object.fromEntries(FX_WORDS.filter((k) => v[k] !== undefined).map((k) => [k, v[k]])) as TitleFx
|
|
591
629
|
const builtInStyle = (st: string): st is TitleStyle => (TITLE_STYLES as readonly string[]).includes(st)
|
|
630
|
+
/** a logo word on a title of another style fails: nothing would read it */
|
|
631
|
+
const logoOnly = (style: TitleStyle, fx: TitleFx | undefined): void => {
|
|
632
|
+
const odd = LOGO_WORDS.filter((w) => fx?.[w] !== undefined)
|
|
633
|
+
if (style !== 'logo' && odd.length > 0) bad(`title ${odd.join(', ')}: a logo's words, this title is ${style}`)
|
|
634
|
+
}
|
|
592
635
|
|
|
593
636
|
/** the brand's text styles (`trailer.styles`, each a title's words by name) laid under each title: the defaults of
|
|
594
637
|
* its built in style, then the brand style it names, then its own words, a later one winning. A brand style names
|
|
@@ -621,6 +664,7 @@ export function resolveStyles(t: Pick<Trailer, 'shots'>, styles: Readonly<Record
|
|
|
621
664
|
const under = { ...defaults, ...named }
|
|
622
665
|
ti.style = base as TitleStyle
|
|
623
666
|
const fx = { ...fxOf(under), ...ti.fx }
|
|
667
|
+
logoOnly(ti.style, fx)
|
|
624
668
|
if (Object.keys(fx).length > 0) ti.fx = fx
|
|
625
669
|
if (under.place && !ti.place) ti.place = under.place as 'top' | 'bottom'
|
|
626
670
|
if (under.in && !ti.in) ti.in = under.in as Move
|
|
@@ -1323,6 +1367,7 @@ export function parseTrailer(text: string, o: ParseOptions = {}): { trailer: Tra
|
|
|
1323
1367
|
...(p.out ? { out: p.out as Move } : {}),
|
|
1324
1368
|
}
|
|
1325
1369
|
const fx = fxOf(p)
|
|
1370
|
+
if (!ti.named) logoOnly(ti.style, fx)
|
|
1326
1371
|
if (Object.keys(fx).length > 0) ti.fx = fx
|
|
1327
1372
|
if (ti.knock !== undefined && ti.out) bad('title: knock is its exit, give out or knock, not both')
|
|
1328
1373
|
ti.at = landAt(p.at, n, s, (v) => (ti.at = v))
|
package/src/trailer/look.ts
CHANGED
|
@@ -8,18 +8,26 @@ export interface TrailerLookInput {
|
|
|
8
8
|
light?: string
|
|
9
9
|
/** the dark edge under words over footage (default: the ground's edge) */
|
|
10
10
|
edge?: string
|
|
11
|
+
/** the soft band of shade behind a `line` title's words (default: the edge) */
|
|
12
|
+
band?: string
|
|
11
13
|
/** the key colour: the caption's dot, a `line` title's `*word*`, the corner chip (default: the palette's accent) */
|
|
12
14
|
accent?: string
|
|
13
|
-
/** the caption's plate (default: the palette's bg, an accent outline, the palette's ink)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
chip
|
|
15
|
+
/** the caption's plate (default: the palette's bg, an accent outline, the palette's ink) and the soft drop under it
|
|
16
|
+
* and under the chip (default: the edge) */
|
|
17
|
+
plate?: { fill?: string; line?: string; ink?: string; shadow?: string }
|
|
18
|
+
/** the corner chip (default: the accent, ink of the palette's bg, an outline a shade darker than its fill) */
|
|
19
|
+
chip?: { fill?: string; ink?: string; line?: string }
|
|
17
20
|
/** the face: a font file (repo relative, .ttf .otf .woff2) or a family the machine has, and its heavy weight */
|
|
18
21
|
font?: { file?: string; family?: string; weight?: number }
|
|
19
|
-
/** the logo the `logo` title draws over its words, repo relative (default: logo.png at the web root, when present)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
/** the logo the `logo` title draws over its words, repo relative (default: logo.png at the web root, when present);
|
|
23
|
+
* `false`: the word alone */
|
|
24
|
+
logo?: string | false
|
|
25
|
+
/** a `logo` title's subtitle: its colour (default: the light) and the colour of its soft glow (default: the edge) */
|
|
26
|
+
subtitle?: { color?: string; glow?: string }
|
|
27
|
+
/** the words of a short: the `short` and `progress` titles and the vertical's words, each value the look's when left
|
|
28
|
+
* out; `band` the shade behind them (default: their edge), `lit` the glow round a progress bar's newest notch
|
|
29
|
+
* (default: their accent) */
|
|
30
|
+
short?: { font?: { file?: string; family?: string; weight?: number }; light?: string; edge?: string; accent?: string; band?: string; lit?: string }
|
|
23
31
|
/** the vertical short's look (a `vertical` cut), each part `false` to leave it out (vertical.ts) */
|
|
24
32
|
vertical?: VerticalLookInput
|
|
25
33
|
/** text styles by name, each a title line's words (`style big font "Bebas Neue" in letters`): a title's `style`
|
|
@@ -44,13 +52,15 @@ export interface TrailerLook {
|
|
|
44
52
|
ground: [string, string]
|
|
45
53
|
light: string
|
|
46
54
|
edge: string
|
|
55
|
+
band: string
|
|
47
56
|
accent: string
|
|
48
|
-
plate: { fill: string; line: string; ink: string }
|
|
49
|
-
chip: { fill: string; ink: string }
|
|
57
|
+
plate: { fill: string; line: string; ink: string; shadow: string }
|
|
58
|
+
chip: { fill: string; ink: string; line: string }
|
|
50
59
|
font: { file: string | null; family: string; weight: number }
|
|
51
60
|
logo: string | null
|
|
61
|
+
subtitle: { color: string; glow: string }
|
|
52
62
|
/** the short's words: their face and colours */
|
|
53
|
-
short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string }
|
|
63
|
+
short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string; band: string; lit: string }
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
export interface BrandLike {
|
|
@@ -108,20 +118,25 @@ export function lookOf(brand: BrandLike | null, webLogo: string | null = null):
|
|
|
108
118
|
const light = i.light ?? (luminance(p.ink) > 0.45 ? p.ink : '#ffffff')
|
|
109
119
|
const edge = i.edge ?? (luminance(ground[1]) < 0.2 ? ground[1] : darker(p.bg, 0.8))
|
|
110
120
|
const accent = i.accent ?? p.accent
|
|
121
|
+
const chipFill = i.chip?.fill ?? p.accent
|
|
111
122
|
const sh = i.short ?? {}
|
|
123
|
+
const shortEdge = sh.edge ?? edge
|
|
124
|
+
const shortAccent = sh.accent ?? accent
|
|
112
125
|
const shortFont = sh.font ? { file: sh.font.file ?? null, family: sh.font.family ?? (sh.font.file ? 'TrailerShortFace' : font.family), weight: sh.font.weight ?? font.weight } : font
|
|
113
126
|
return {
|
|
114
127
|
ground,
|
|
115
128
|
light,
|
|
116
129
|
edge,
|
|
130
|
+
band: i.band ?? edge,
|
|
117
131
|
accent,
|
|
118
|
-
plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink },
|
|
119
|
-
chip: { fill:
|
|
132
|
+
plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink, shadow: i.plate?.shadow ?? alpha(edge, 0.4) },
|
|
133
|
+
chip: { fill: chipFill, ink: i.chip?.ink ?? (luminance(p.accent) > 0.4 ? darker(p.bg, luminance(p.bg) > 0.5 ? 0.85 : 0.2) : '#ffffff'), line: i.chip?.line ?? darker(chipFill, 0.15) },
|
|
120
134
|
font: { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 },
|
|
121
|
-
logo: i.logo ?? webLogo,
|
|
122
|
-
|
|
135
|
+
logo: i.logo === false ? null : (i.logo ?? webLogo),
|
|
136
|
+
subtitle: { color: i.subtitle?.color ?? light, glow: i.subtitle?.glow ?? edge },
|
|
137
|
+
short: { font: shortFont, light: sh.light ?? light, edge: shortEdge, accent: shortAccent, band: sh.band ?? shortEdge, lit: sh.lit ?? shortAccent },
|
|
123
138
|
}
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
/** the look a short's words wear: the look with the short's face and colours in place */
|
|
127
|
-
export const shortLook = (look: TrailerLook): TrailerLook => ({ ...look, font: look.short.font, light: look.short.light, edge: look.short.edge, accent: look.short.accent })
|
|
142
|
+
export const shortLook = (look: TrailerLook): TrailerLook => ({ ...look, font: look.short.font, light: look.short.light, edge: look.short.edge, accent: look.short.accent, band: look.short.band })
|
package/src/trailer/prune.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// the trailer's cache kept small (wiki build/trailer.md, The cache): what the cuts of every trailer folder still use
|
|
2
|
-
// stays, the rest goes. Runs after
|
|
3
|
-
// build's own key functions; a file younger than an hour is never deleted: another build may
|
|
4
|
-
|
|
2
|
+
// stays, the rest goes. Runs after a stitch when what it reads moved, and by hand (`sk trailer prune [--dry]`). What a
|
|
3
|
+
// cut uses is named by the build's own key functions; a file younger than an hour is never deleted: another build may
|
|
4
|
+
// be writing or reading it
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
5
6
|
import { basename, join, relative } from 'node:path'
|
|
6
7
|
import { cutFiles, load } from './build.ts'
|
|
7
8
|
import { SCORE, musicCues } from './grammar.ts'
|
|
@@ -159,12 +160,10 @@ function filesUnder(dir: string): string[] {
|
|
|
159
160
|
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? filesUnder(join(dir, e.name)) : [join(dir, e.name)]))
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const cache = join(trailerCache(root, 'footage'), '..')
|
|
167
|
-
const live = await liveSet(root)
|
|
163
|
+
const cacheOf = async (root: string): Promise<string> => join((await import('./footage.ts')).trailerCache(root, 'footage'), '..')
|
|
164
|
+
|
|
165
|
+
/** each folder's files that go, as of `now`; `now` Infinity names the files only their age keeps too */
|
|
166
|
+
function planOf(cache: string, live: Live, views: readonly string[], now: number): [string, Doomed[]][] {
|
|
168
167
|
const whole = live.broken.length === 0
|
|
169
168
|
// titles/ holds the vertical's drawings too: only a title's and a card's files are the prune's
|
|
170
169
|
const ownTitle = (n: string): boolean => /^(title|card)-[0-9a-f]{16}\./.test(n)
|
|
@@ -177,10 +176,58 @@ export async function prune(root: string, o: { dry: boolean; now?: number }): Pr
|
|
|
177
176
|
]
|
|
178
177
|
const dead = existsSync(cache) ? readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory() && !FOLDERS.includes(e.name)) : []
|
|
179
178
|
for (const d of dead) plan.push([`${d.name}/ (dead)`, filesUnder(join(cache, d.name)).map(doomed)])
|
|
180
|
-
plan.push(['trailer-view/',
|
|
181
|
-
|
|
179
|
+
plan.push(['trailer-view/', views.filter((f) => existsSync(f) && now - statSync(f).mtimeMs > RENDER_GRACE_MS).map(doomed)])
|
|
180
|
+
return plan
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** what the last whole prune read, and when the first file it spared for its age comes of age */
|
|
184
|
+
interface Stamp {
|
|
185
|
+
key: string
|
|
186
|
+
until: number
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const stampFile = (cache: string): string => join(cache, 'prune.json')
|
|
190
|
+
|
|
191
|
+
/** what the prune reads that a build can change: every cut's text, every file name in the cache's folders, the views
|
|
192
|
+
* to drop. The same key and no spared file of age: the prune would find what it found */
|
|
193
|
+
async function pruneKey(root: string, cache: string): Promise<string> {
|
|
194
|
+
const [{ cutFiles }, { viewsToDrop }] = await Promise.all([import('./build.ts'), import('../review/trailer-desk.ts')])
|
|
195
|
+
const h = new Bun.CryptoHasher('sha1')
|
|
196
|
+
for (const f of cutFiles(root)) h.update(`${f}\0`).update(readFileSync(f)).update('\0')
|
|
197
|
+
const folders = existsSync(cache) ? readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory()) : []
|
|
198
|
+
for (const d of folders.map((e) => e.name).sort()) h.update(`${d}/\0${filesUnder(join(cache, d)).sort().join('\0')}\0`)
|
|
199
|
+
h.update(viewsToDrop(root).sort().join('\0'))
|
|
200
|
+
return h.digest('hex')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** the prune after a build: skipped while nothing it reads moved and no file it spared has come of age */
|
|
204
|
+
export async function pruneIfMoved(root: string, now = Date.now()): Promise<Pruned | null> {
|
|
205
|
+
const cache = await cacheOf(root)
|
|
206
|
+
let s: Stamp | null = null
|
|
207
|
+
try {
|
|
208
|
+
s = JSON.parse(readFileSync(stampFile(cache), 'utf8')) as Stamp
|
|
209
|
+
} catch {}
|
|
210
|
+
if (s !== null && now < s.until && s.key === (await pruneKey(root, cache))) return null
|
|
211
|
+
return prune(root, { dry: false, now })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** plans every folder's prune, then deletes unless `dry`; a whole prune writes its stamp */
|
|
215
|
+
export async function prune(root: string, o: { dry: boolean; now?: number }): Promise<Pruned> {
|
|
216
|
+
const now = o.now ?? Date.now()
|
|
217
|
+
const { viewsToDrop } = await import('../review/trailer-desk.ts')
|
|
218
|
+
const cache = await cacheOf(root)
|
|
219
|
+
const live = await liveSet(root)
|
|
220
|
+
const views = viewsToDrop(root)
|
|
221
|
+
const plan = planOf(cache, live, views, now)
|
|
222
|
+
if (!o.dry && existsSync(cache)) {
|
|
182
223
|
for (const [, ds] of plan) for (const d of ds) rmSync(d.file, { force: true })
|
|
183
|
-
for (const d of
|
|
224
|
+
for (const d of readdirSync(cache, { withFileTypes: true }).filter((e) => e.isDirectory() && !FOLDERS.includes(e.name))) rmSync(join(cache, d.name), { recursive: true, force: true })
|
|
225
|
+
rmSync(stampFile(cache), { force: true })
|
|
226
|
+
if (live.broken.length === 0) {
|
|
227
|
+
const spared = planOf(cache, live, views, Number.POSITIVE_INFINITY).flatMap(([, ds]) => ds.filter((d) => existsSync(d.file)))
|
|
228
|
+
const until = Math.min(Number.MAX_SAFE_INTEGER, ...spared.map((d) => statSync(d.file).mtimeMs + RENDER_GRACE_MS))
|
|
229
|
+
writeFileSync(stampFile(cache), JSON.stringify({ key: await pruneKey(root, cache), until } satisfies Stamp))
|
|
230
|
+
}
|
|
184
231
|
}
|
|
185
232
|
return {
|
|
186
233
|
freed: plan.map(([folder, ds]) => ({ folder, files: ds.length, bytes: ds.reduce((n, d) => n + (d.linked ? 0 : d.bytes), 0), linked: ds.reduce((n, d) => n + (d.linked ? d.bytes : 0), 0) })),
|
package/src/trailer/score/dsp.ts
CHANGED
|
@@ -126,45 +126,105 @@ function staticCurve(c: CompressorDef): { saturate: (x: number) => number; makeu
|
|
|
126
126
|
return { saturate, makeup: (1 / saturate(1)) ** 0.6 }
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/** a
|
|
130
|
-
|
|
129
|
+
/** the release a browser compressor takes at 15, 10, 5 and 0 dB from its target, shares of its release time */
|
|
130
|
+
const RELEASE_ZONES = [0.09, 0.16, 0.42, 0.98] as const
|
|
131
|
+
/** a browser compressor sets its envelope's rate once a division of this many frames */
|
|
132
|
+
const DIVISION = 32
|
|
133
|
+
|
|
134
|
+
/** a stereo compressor in place, as a browser's (its dynamics compressor kernel): 6 ms of lookahead; a detector on the
|
|
135
|
+
* louder channel's attenuation that lets go over 2.5 ms; a gain that attacks at a rate set by the deepest attenuation
|
|
136
|
+
* it chases and releases faster the more it compresses (a curve through the four release zones); a sine warp on the
|
|
137
|
+
* gain, the makeup on top */
|
|
131
138
|
export function compress(l: Float32Array, r: Float32Array, rate: number, c: CompressorDef): void {
|
|
132
139
|
const { saturate, makeup } = staticCurve(c)
|
|
133
|
-
|
|
134
|
-
// the curve sampled in dB of input, 0.05 dB a step, from -60 dB up to +24 dB
|
|
140
|
+
// the attenuation in dB of input, 0.05 dB a step, from -60 dB up to +24 dB
|
|
135
141
|
const lo = -60
|
|
136
142
|
const step = 0.05
|
|
137
143
|
const table = new Float32Array(Math.ceil((24 - lo) / step) + 2)
|
|
138
144
|
for (let i = 0; i < table.length; i++) {
|
|
139
145
|
const x = lin(lo + i * step)
|
|
140
|
-
table[i] = dbOf(saturate(x) / x)
|
|
146
|
+
table[i] = -dbOf(saturate(x) / x)
|
|
141
147
|
}
|
|
142
|
-
|
|
148
|
+
const [y1, y2, y3, y4] = RELEASE_ZONES.map((z) => z * rate * c.release) as [number, number, number, number]
|
|
149
|
+
// the 4th order polynomial through (0, y1), (1, y2), (2, y3), (3, y4), the browser's own coefficients
|
|
150
|
+
const poly: Poly = [
|
|
151
|
+
0.9999999999999998 * y1 + 1.8432219684323923e-16 * y2 - 1.9373394351676423e-16 * y3 + 8.824516011816245e-18 * y4,
|
|
152
|
+
-1.5788320352845888 * y1 + 2.3305837032074286 * y2 - 0.9141194204840429 * y3 + 0.1623677525612032 * y4,
|
|
153
|
+
0.5334142869106424 * y1 - 1.272736789213631 * y2 + 0.9258856042207512 * y3 - 0.18656310191776226 * y4,
|
|
154
|
+
0.08783463138207234 * y1 - 0.1694162967925622 * y2 + 0.08588057951595272 * y3 - 0.00429891410546283 * y4,
|
|
155
|
+
-0.042416883008123074 * y1 + 0.1115693827987602 * y2 - 0.09764676325265872 * y3 + 0.028494263462021576 * y4,
|
|
156
|
+
]
|
|
157
|
+
compressRun(l, r, { delay: Math.round(0.006 * rate), attack: Math.max(0.001, c.attack) * rate, detectorRelease: 0.0025 * rate, poly, table, lo, step, makeup })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type Poly = [number, number, number, number, number]
|
|
161
|
+
|
|
162
|
+
interface Kernel {
|
|
163
|
+
/** frames of lookahead */
|
|
164
|
+
delay: number
|
|
165
|
+
/** frames */
|
|
166
|
+
attack: number
|
|
167
|
+
detectorRelease: number
|
|
168
|
+
poly: Poly
|
|
169
|
+
/** attenuation in dB by input dB, from `lo` a `step` apart */
|
|
170
|
+
table: Float32Array
|
|
171
|
+
lo: number
|
|
172
|
+
step: number
|
|
173
|
+
makeup: number
|
|
143
174
|
}
|
|
144
175
|
|
|
145
|
-
function compressRun(l: Float32Array, r: Float32Array,
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
|
|
176
|
+
function compressRun(l: Float32Array, r: Float32Array, k: Kernel): void {
|
|
177
|
+
const n = l.length
|
|
178
|
+
const dl = new Float32Array(k.delay)
|
|
179
|
+
const dr = new Float32Array(k.delay)
|
|
180
|
+
const { table, lo, step } = k
|
|
149
181
|
const last = table.length - 1
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
182
|
+
const [ka, kb, kc, kd, ke] = k.poly
|
|
183
|
+
let detector = 0
|
|
184
|
+
let gain = 1
|
|
185
|
+
let maxAttackDb = -1
|
|
186
|
+
for (let d0 = 0; d0 < n; d0 += DIVISION) {
|
|
187
|
+
// pre warped so the sine warp below lands on the detector's gain
|
|
188
|
+
const desired = Math.asin(Math.min(1, detector)) / (Math.PI / 2)
|
|
189
|
+
let envRate: number
|
|
190
|
+
if (desired > gain) {
|
|
191
|
+
maxAttackDb = -1
|
|
192
|
+
let diff = dbOf(gain / desired)
|
|
193
|
+
if (!Number.isFinite(diff)) diff = -1
|
|
194
|
+
const x = 0.25 * (Math.min(0, Math.max(-12, diff)) + 12)
|
|
195
|
+
const frames = ka + kb * x + kc * x * x + kd * x * x * x + ke * x * x * x * x
|
|
196
|
+
envRate = lin(5 / frames)
|
|
197
|
+
} else {
|
|
198
|
+
let diff = desired > 0 ? dbOf(gain / desired) : 1
|
|
199
|
+
if (!Number.isFinite(diff)) diff = 1
|
|
200
|
+
if (maxAttackDb === -1 || maxAttackDb < diff) maxAttackDb = diff
|
|
201
|
+
envRate = 1 - (0.25 / Math.max(0.5, maxAttackDb)) ** (1 / k.attack)
|
|
202
|
+
}
|
|
203
|
+
const end = Math.min(n, d0 + DIVISION)
|
|
204
|
+
for (let i = d0; i < end; i++) {
|
|
205
|
+
const x = Math.max(Math.abs(l[i]!), Math.abs(r[i]!))
|
|
206
|
+
let att = 1
|
|
207
|
+
let attDb = 0
|
|
208
|
+
if (x > 1e-4) {
|
|
209
|
+
const p = (20 * Math.log10(x) - lo) / step
|
|
210
|
+
const j = Math.min(last - 1, Math.max(0, p | 0))
|
|
211
|
+
const f = Math.min(1, Math.max(0, p - j))
|
|
212
|
+
attDb = table[j]! + (table[j + 1]! - table[j]!) * f
|
|
213
|
+
att = lin(-attDb)
|
|
214
|
+
}
|
|
215
|
+
const release = lin(Math.max(2, attDb) / k.detectorRelease) - 1
|
|
216
|
+
detector = Math.min(1, detector + (att - detector) * (att > detector ? release : 1))
|
|
217
|
+
if (envRate < 1) gain += (desired - gain) * envRate
|
|
218
|
+
else gain = Math.min(1, gain * envRate)
|
|
219
|
+
const g = k.makeup * Math.sin((Math.PI / 2) * gain)
|
|
220
|
+
const at = i % k.delay
|
|
221
|
+
const yl = dl[at]!
|
|
222
|
+
const yr = dr[at]!
|
|
223
|
+
dl[at] = l[i]!
|
|
224
|
+
dr[at] = r[i]!
|
|
225
|
+
l[i] = yl * g
|
|
226
|
+
r[i] = yr * g
|
|
158
227
|
}
|
|
159
|
-
env = target < env ? target + (env - target) * att : target + (env - target) * rel
|
|
160
|
-
const g = makeup * 10 ** (env / 20)
|
|
161
|
-
const k = i % delay
|
|
162
|
-
const yl = dl[k]!
|
|
163
|
-
const yr = dr[k]!
|
|
164
|
-
dl[k] = l[i]!
|
|
165
|
-
dr[k] = r[i]!
|
|
166
|
-
l[i] = yl * g
|
|
167
|
-
r[i] = yr * g
|
|
168
228
|
}
|
|
169
229
|
}
|
|
170
230
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
/** oscillator families: chiptune roots (pulse, wave, noise) plus the smoother modern ones; `gm` a General MIDI
|
|
5
5
|
* program the soundfont plays (gm.ts) */
|
|
6
|
-
export type Osc = 'pulse' | 'saw' | 'triangle' | 'sine' | 'wave' | 'noise' | 'gm'
|
|
6
|
+
export type Osc = 'pulse' | 'saw' | 'triangle' | 'square' | 'sine' | 'wave' | 'noise' | 'gm'
|
|
7
7
|
|
|
8
8
|
export interface InstrumentDef {
|
|
9
9
|
osc: Osc
|
|
@@ -74,6 +74,14 @@ function sawCoefficients(): Coefficients {
|
|
|
74
74
|
return { real, imag }
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** the browser's own square wave, which a score names by its web audio type; never softened */
|
|
78
|
+
function squareCoefficients(): Coefficients {
|
|
79
|
+
const real = new Float64Array(HARMONICS + 1)
|
|
80
|
+
const imag = new Float64Array(HARMONICS + 1)
|
|
81
|
+
for (let k = 1; k <= HARMONICS; k += 2) imag[k] = 4 / (Math.PI * k)
|
|
82
|
+
return { real, imag }
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
function triangleCoefficients(): Coefficients {
|
|
78
86
|
const real = new Float64Array(HARMONICS + 1)
|
|
79
87
|
const imag = new Float64Array(HARMONICS + 1)
|
|
@@ -186,6 +194,7 @@ class Waves {
|
|
|
186
194
|
: osc === 'saw' ? sawCoefficients()
|
|
187
195
|
: osc === 'wave' ? waveCoefficients(inst.wave!)
|
|
188
196
|
: osc === 'triangle' ? triangleCoefficients()
|
|
197
|
+
: osc === 'square' ? squareCoefficients()
|
|
189
198
|
: { real: new Float64Array(2), imag: Float64Array.of(0, 1) }
|
|
190
199
|
t = tableOf(c, bandHz(band), tone, this.rate)
|
|
191
200
|
this.tables.set(key, t)
|
|
@@ -204,11 +213,21 @@ interface Pitch {
|
|
|
204
213
|
|
|
205
214
|
const pitchAt = (p: Pitch, t: number): number => (p.over <= 0 || t >= p.over ? p.to : p.from * (p.to / p.from) ** (Math.max(0, t) / p.over))
|
|
206
215
|
|
|
207
|
-
/**
|
|
208
|
-
|
|
216
|
+
/** a browser renders in blocks of this many frames */
|
|
217
|
+
const QUANTUM = 128
|
|
218
|
+
/** an oscillator's frequency before its first automation event: the browser's default */
|
|
219
|
+
const DEFAULT_HZ = 440
|
|
220
|
+
|
|
221
|
+
/** one oscillator voice added into `buf`: a table read at a moving pitch, its detune and vibrato in cents. `lead` is
|
|
222
|
+
* how far into its 128 frame block the note starts: in that first block a browser oscillator reads its frequency from
|
|
223
|
+
* the block's start, so it runs at the default 440 Hz for `lead` frames and its pitch curve lags by `lead` frames. That
|
|
224
|
+
* sets the phase every later frame carries, and two notes of one pitch that overlap add or cancel by it */
|
|
225
|
+
function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, table: Float32Array, p: Pitch, cents: number, vib: InstrumentDef['vibrato'] | null, gain: number, lead: number): void {
|
|
209
226
|
const LN = Math.LN2 / 1200
|
|
210
227
|
const steady = p.over <= 0 && !vib
|
|
211
228
|
const fixed = (p.to * Math.exp(cents * LN)) / rate
|
|
229
|
+
const early = (DEFAULT_HZ * Math.exp(cents * LN)) / rate
|
|
230
|
+
const first = lead > 0 ? QUANTUM - lead : 0
|
|
212
231
|
let phase = Math.max(0, t0) * (p.over <= 0 ? p.to : p.from) * Math.exp(cents * LN)
|
|
213
232
|
phase -= Math.floor(phase)
|
|
214
233
|
for (let i = 0; i < n; i++) {
|
|
@@ -216,9 +235,11 @@ function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, t
|
|
|
216
235
|
const j = x | 0
|
|
217
236
|
const v = table[j]!
|
|
218
237
|
buf[i] = buf[i]! + gain * (v + (table[j + 1]! - v) * (x - j))
|
|
238
|
+
const at = i < first ? i - lead : i
|
|
219
239
|
let inc = fixed
|
|
220
|
-
if (
|
|
221
|
-
|
|
240
|
+
if (at < 0) inc = early
|
|
241
|
+
else if (!steady) {
|
|
242
|
+
const t = t0 + at / rate
|
|
222
243
|
let c = cents
|
|
223
244
|
if (vib) {
|
|
224
245
|
const on = Math.max(0, vib.delay)
|
|
@@ -288,7 +309,7 @@ function playNote(bus: Bus, waves: Waves, inst: InstrumentDef, note: ScheduledNo
|
|
|
288
309
|
const table = waves.table(inst, hz)
|
|
289
310
|
for (let v = 0; v < voices; v++) {
|
|
290
311
|
const cents = voices === 1 ? spread : spread * ((2 * v) / (voices - 1) - 1)
|
|
291
|
-
oscillatorRun(buf, n, t0, rate, table, pitch, cents, vib, 1 / Math.sqrt(voices))
|
|
312
|
+
oscillatorRun(buf, n, t0, rate, table, pitch, cents, vib, 1 / Math.sqrt(voices), i0 % QUANTUM)
|
|
292
313
|
}
|
|
293
314
|
}
|
|
294
315
|
applyEnvelope(buf, n, t0, rate, pts)
|
package/src/trailer/titles.ts
CHANGED
|
@@ -58,14 +58,16 @@ export const face = (g: SKRSContext2D, look: TrailerLook, px: number, weight = l
|
|
|
58
58
|
axis(g, weight)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** how one run of words is painted: its face, its fill (a colour, or stops top to bottom), the
|
|
62
|
-
* and the passes under the fill: a glow,
|
|
61
|
+
/** how one run of words is painted: its face, its fill (a colour, or stops top to bottom, each `colour[@share]`), the
|
|
62
|
+
* accent of its `*word*`, and the passes under the fill: a glow, a drop (a solid copy below the words, the shadow under
|
|
63
|
+
* it), an outline (the shadow under it when there is no drop), an inner stroke */
|
|
63
64
|
export interface Paint {
|
|
64
65
|
family: string
|
|
65
66
|
weight: number
|
|
66
67
|
px: number
|
|
67
68
|
fill: string | string[]
|
|
68
69
|
key: string
|
|
70
|
+
drop?: { dy: number; color: string } | null
|
|
69
71
|
outline: { w: number; color: string; dy?: number } | null
|
|
70
72
|
inner: { w: number; color: string } | null
|
|
71
73
|
shadow: { blur: number; dy: number; color: string } | null
|
|
@@ -134,7 +136,10 @@ function ink(cx: Ctx, g: SKRSContext2D, p: Paint, text: string, x: number, y: nu
|
|
|
134
136
|
if (key) return p.key
|
|
135
137
|
if (typeof p.fill === 'string') return p.fill
|
|
136
138
|
const grad = g.createLinearGradient(0, top, 0, bottom)
|
|
137
|
-
p.fill.forEach((c, k) =>
|
|
139
|
+
p.fill.forEach((c, k) => {
|
|
140
|
+
const [colour = c, at] = c.split('@')
|
|
141
|
+
grad.addColorStop(at === undefined ? 0.1 + (0.9 * k) / Math.max(1, p.fill.length - 1) : Number(at), colour)
|
|
142
|
+
})
|
|
138
143
|
return grad as unknown as CanvasGradient
|
|
139
144
|
}
|
|
140
145
|
const pass = (draw: (u: (typeof units)[number]) => void): void => {
|
|
@@ -172,10 +177,18 @@ function ink(cx: Ctx, g: SKRSContext2D, p: Paint, text: string, x: number, y: nu
|
|
|
172
177
|
g.fillText(u.text, u.x, y)
|
|
173
178
|
})
|
|
174
179
|
}
|
|
180
|
+
if (p.drop) {
|
|
181
|
+
const o = p.drop
|
|
182
|
+
pass((u) => {
|
|
183
|
+
shadowed(p.shadow)
|
|
184
|
+
g.fillStyle = o.color
|
|
185
|
+
g.fillText(u.text, u.x, y + o.dy)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
175
188
|
if (p.outline) {
|
|
176
189
|
const o = p.outline
|
|
177
190
|
pass((u) => {
|
|
178
|
-
shadowed(p.shadow)
|
|
191
|
+
if (!p.drop) shadowed(p.shadow)
|
|
179
192
|
g.lineWidth = o.w
|
|
180
193
|
g.strokeStyle = o.color
|
|
181
194
|
g.strokeText(u.text, u.x, y + (o.dy ?? 0))
|
|
@@ -190,7 +203,7 @@ function ink(cx: Ctx, g: SKRSContext2D, p: Paint, text: string, x: number, y: nu
|
|
|
190
203
|
})
|
|
191
204
|
}
|
|
192
205
|
pass((u) => {
|
|
193
|
-
if (!p.outline) shadowed(p.shadow)
|
|
206
|
+
if (!p.outline && !p.drop) shadowed(p.shadow)
|
|
194
207
|
g.fillStyle = fill(u.key) as string
|
|
195
208
|
g.fillText(u.text, u.x, y)
|
|
196
209
|
})
|
|
@@ -208,11 +221,18 @@ export function paintWith(base: Paint, fx: TitleFx | undefined, look: TrailerLoo
|
|
|
208
221
|
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
222
|
if (fx.weight !== undefined) p.weight = fx.weight
|
|
210
223
|
if (fx.color !== undefined) p.fill = c(fx.color, look.light)
|
|
211
|
-
if (fx.gradient)
|
|
212
|
-
|
|
224
|
+
if (fx.gradient)
|
|
225
|
+
p.fill = fx.gradient.map((x) => {
|
|
226
|
+
const [colour, at] = x.split('@')
|
|
227
|
+
return at === undefined ? c(colour, look.light) : `${c(colour, look.light)}@${at}`
|
|
228
|
+
})
|
|
229
|
+
// an outline word keeps the style's offset under the words
|
|
230
|
+
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), ...(base.outline?.dy ? { dy: base.outline.dy } : {}) }
|
|
213
231
|
// an inner stroke keeps its share of the outline it sits in, so a thinner outline still shows its edge
|
|
214
232
|
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.
|
|
233
|
+
if (fx.inner) p.inner = fx.inner.px === 0 ? null : { w: fx.inner.px * k, color: c(fx.inner.color, base.inner?.color ?? look.accent) }
|
|
234
|
+
if (fx.drop) p.drop = fx.drop.px === 0 ? null : { dy: fx.drop.px * k, color: c(fx.drop.color, base.outline?.color ?? look.edge) }
|
|
235
|
+
if (fx.shadow) p.shadow = fx.shadow.px === 0 ? null : { blur: fx.shadow.px * k, dy: (fx.shadow.dy ?? fx.shadow.px / 3) * k, color: c(fx.shadow.color, alpha(look.edge, 0.6)) }
|
|
216
236
|
if (fx.glow) p.glow = fx.glow.px === 0 ? null : { blur: fx.glow.px * k, color: c(fx.glow.color, look.accent) }
|
|
217
237
|
return p
|
|
218
238
|
}
|
|
@@ -235,9 +255,9 @@ function wrap(g: SKRSContext2D, text: string, max: number): string[] {
|
|
|
235
255
|
}
|
|
236
256
|
|
|
237
257
|
/** a rounded plate with a soft drop under it */
|
|
238
|
-
function plate(g: SKRSContext2D, x: number, y: number, w: number, h: number, r: number, k: number, fill: string, line: string,
|
|
258
|
+
function plate(g: SKRSContext2D, x: number, y: number, w: number, h: number, r: number, k: number, fill: string, line: string, shadow: string): void {
|
|
239
259
|
g.save()
|
|
240
|
-
g.shadowColor =
|
|
260
|
+
g.shadowColor = shadow
|
|
241
261
|
g.shadowBlur = 18 * k
|
|
242
262
|
g.shadowOffsetY = 6 * k
|
|
243
263
|
g.fillStyle = fill
|
|
@@ -252,13 +272,13 @@ function plate(g: SKRSContext2D, x: number, y: number, w: number, h: number, r:
|
|
|
252
272
|
g.stroke()
|
|
253
273
|
}
|
|
254
274
|
|
|
255
|
-
/** a soft band of the
|
|
275
|
+
/** a soft band of the look's shade across the frame, behind words */
|
|
256
276
|
function band(cx: Ctx, g: SKRSContext2D, look: TrailerLook, y0: number, y1: number, W: number, a: number): void {
|
|
257
277
|
decor(cx, g, () => {
|
|
258
278
|
const b = g.createLinearGradient(0, y0, 0, y1)
|
|
259
|
-
b.addColorStop(0, alpha(look.
|
|
260
|
-
b.addColorStop(0.5, alpha(look.
|
|
261
|
-
b.addColorStop(1, alpha(look.
|
|
279
|
+
b.addColorStop(0, alpha(look.band, 0))
|
|
280
|
+
b.addColorStop(0.5, alpha(look.band, a))
|
|
281
|
+
b.addColorStop(1, alpha(look.band, 0))
|
|
262
282
|
g.fillStyle = b
|
|
263
283
|
g.fillRect(0, y0, W, y1 - y0)
|
|
264
284
|
})
|
|
@@ -315,7 +335,7 @@ function drawInto(g: SKRSContext2D, d: TitleDraw, [W, H]: readonly [number, numb
|
|
|
315
335
|
const nx = (W - width) / 2 + j * (nw + gap)
|
|
316
336
|
g.save()
|
|
317
337
|
if (j === i - 1) {
|
|
318
|
-
g.shadowColor = alpha(
|
|
338
|
+
g.shadowColor = alpha(look.short.lit, 0.9)
|
|
319
339
|
g.shadowBlur = 22 * ks
|
|
320
340
|
}
|
|
321
341
|
g.beginPath()
|
|
@@ -332,22 +352,29 @@ function drawInto(g: SKRSContext2D, d: TitleDraw, [W, H]: readonly [number, numb
|
|
|
332
352
|
})
|
|
333
353
|
}
|
|
334
354
|
} 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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
355
|
+
// the logo image over the word, the word in a light to accent ramp over a dropped edge, the subtitle spaced
|
|
356
|
+
// under; the block's middle at 0.48 of the height, or the word's at its `middle`, a soft shade round that anchor
|
|
357
|
+
const fx = d.fx
|
|
358
|
+
const S = fx?.size ?? 170
|
|
344
359
|
const markH = d.logo ? Math.min(H * 0.3, W * 0.3) : 0
|
|
345
360
|
setFace(g, own(base(S * k, 900)))
|
|
346
|
-
const kl = Math.min(1, (W * 0.78) / (g.measureText(first.replaceAll('*', '')).width + 16 * k))
|
|
361
|
+
const kl = Math.min(1, (W * (fx?.fit ?? 0.78)) / (g.measureText(first.replaceAll('*', '')).width + 16 * k))
|
|
347
362
|
const px = S * k * kl
|
|
348
|
-
const subPx = S * 0.2 * k * Math.max(kl, 0.7)
|
|
349
|
-
const
|
|
350
|
-
|
|
363
|
+
const subPx = S * (fx?.sub ?? 0.2) * k * Math.max(kl, 0.7)
|
|
364
|
+
const gap = fx?.gap !== undefined ? fx.gap * px : subPx / 2
|
|
365
|
+
const markGap = markH ? px * 0.25 : 0
|
|
366
|
+
const block = markH + markGap + (first ? px : 0) + (more.length ? gap + subPx * 1.5 : 0)
|
|
367
|
+
const anchor = fx?.middle !== undefined ? H * fx.middle : H * 0.48
|
|
368
|
+
let y = fx?.middle !== undefined ? anchor - px / 2 - markGap - markH : anchor - block / 2
|
|
369
|
+
const shade = fx?.shade ?? 0.45
|
|
370
|
+
if (shade > 0)
|
|
371
|
+
decor(cx, g, () => {
|
|
372
|
+
const glow = g.createRadialGradient(W / 2, anchor, 0, W / 2, anchor, Math.max(W, H) * shade)
|
|
373
|
+
glow.addColorStop(0, alpha(look.edge, 0.45))
|
|
374
|
+
glow.addColorStop(1, alpha(look.edge, 0))
|
|
375
|
+
g.fillStyle = glow
|
|
376
|
+
g.fillRect(0, 0, W, H)
|
|
377
|
+
})
|
|
351
378
|
const logo = d.logo
|
|
352
379
|
if (logo) {
|
|
353
380
|
const w = (logo.width / logo.height) * markH
|
|
@@ -357,16 +384,18 @@ function drawInto(g: SKRSContext2D, d: TitleDraw, [W, H]: readonly [number, numb
|
|
|
357
384
|
g.shadowBlur = 30 * k
|
|
358
385
|
g.drawImage(logo, W / 2 - w / 2, at, w, markH)
|
|
359
386
|
})
|
|
360
|
-
y += markH +
|
|
387
|
+
y += markH + markGap
|
|
361
388
|
}
|
|
362
389
|
if (first) {
|
|
363
|
-
|
|
390
|
+
// the word's own words scale with it as it shrinks to fit
|
|
391
|
+
const kw = k * kl
|
|
392
|
+
const p = paintWith({ ...base(px, 900), fill: [look.light, look.accent, darker(look.accent, 0.2)], outline: { w: 16 * kw, color: look.edge, dy: 3 * kw }, shadow: { blur: 30 * kw, dy: 12 * kw, color: alpha(look.edge, 0.6) } }, fx, look, kw, d.family)
|
|
364
393
|
ink(cx, g, p, first, W / 2, y + px / 2)
|
|
365
394
|
y += px
|
|
366
395
|
}
|
|
367
396
|
if (more.length) {
|
|
368
|
-
const sub: Paint = { ...base(subPx,
|
|
369
|
-
ink(cx, g, sub, more.join(' ').toUpperCase(), W / 2 + (subPx * 0.42) / 2, y + subPx)
|
|
397
|
+
const sub: Paint = { ...base(subPx, fx?.weight ?? (d.family ? 400 : 800)), ...(d.family ? { family: d.family } : {}), fill: look.subtitle.color, spacing: subPx * 0.42, shadow: { blur: subPx * 0.42, dy: 0, color: alpha(look.subtitle.glow, 0.9) } }
|
|
398
|
+
ink(cx, g, sub, more.join(' ').toUpperCase(), W / 2 + (subPx * 0.42) / 2, y + gap + subPx / 2)
|
|
370
399
|
}
|
|
371
400
|
} else if (style === 'big') {
|
|
372
401
|
// a strong display line in the upper third: light words in a thick edge outline, an accent stroke inside it
|
|
@@ -395,7 +424,7 @@ function drawInto(g: SKRSContext2D, d: TitleDraw, [W, H]: readonly [number, numb
|
|
|
395
424
|
const bh = lines.length * lh + 34 * k
|
|
396
425
|
const cy = top ? H * 0.12 : H * 0.83
|
|
397
426
|
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.
|
|
427
|
+
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.plate.shadow)
|
|
399
428
|
g.fillStyle = look.accent
|
|
400
429
|
g.beginPath()
|
|
401
430
|
g.arc(W / 2 - bw / 2 + 34 * k, cy, dot / 2 + 2 * k, 0, Math.PI * 2)
|
|
@@ -411,7 +440,7 @@ function drawInto(g: SKRSContext2D, d: TitleDraw, [W, H]: readonly [number, numb
|
|
|
411
440
|
const h = p.px * 1.5
|
|
412
441
|
const x = 72 * k
|
|
413
442
|
const y = top ? 80 * k : H - 80 * k - h
|
|
414
|
-
decor(cx, g, () => plate(g, x, y, w, h, h / 2, k, look.chip.fill,
|
|
443
|
+
decor(cx, g, () => plate(g, x, y, w, h, h / 2, k, look.chip.fill, look.chip.line, look.plate.shadow))
|
|
415
444
|
ink(cx, g, p, t, x + 36 * k, y + h / 2 + 2 * k, 'left')
|
|
416
445
|
}
|
|
417
446
|
}
|
package/src/trailer/vocab.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// the film words of a repo: its project CLI's film rows (the CLI kit's `film: 'still' | 'clip'` mark), read from
|
|
2
2
|
// `<cli> commands --json`, the `cli` script of package.json. Read only when a film line does not read as a core
|
|
3
|
-
// film (build.ts load), so a cut of core films never spawns anything
|
|
4
|
-
|
|
5
|
-
import {
|
|
3
|
+
// film (build.ts load), so a cut of core films never spawns anything; an answer that names its `sources` is kept in
|
|
4
|
+
// rows.json and asked again only when the content of one of them changes
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join, resolve } from 'node:path'
|
|
6
7
|
import type { FilmVerb } from './grammar.ts'
|
|
7
8
|
import { type Words, wordsProblem } from './words.ts'
|
|
8
9
|
|
|
@@ -47,22 +48,80 @@ export function filmRows(json: unknown): FilmVerb[] {
|
|
|
47
48
|
|
|
48
49
|
const asked = new Map<string, FilmVerb[]>()
|
|
49
50
|
|
|
51
|
+
/** the CLI's last answer, kept with the content of each file it named as its `sources` */
|
|
52
|
+
interface Kept {
|
|
53
|
+
cli: string
|
|
54
|
+
sources: Record<string, string>
|
|
55
|
+
answer: unknown
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const rowsFile = (root: string): string => join(root, '.shipkit', 'cache', 'trailer', 'rows.json')
|
|
59
|
+
|
|
60
|
+
const digest = (file: string): string | null => {
|
|
61
|
+
try {
|
|
62
|
+
return Bun.hash(readFileSync(file)).toString(36)
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** the kept answer while every source holds the content it had, else null */
|
|
69
|
+
function keptAnswer(root: string, cli: string): unknown {
|
|
70
|
+
let k: Kept
|
|
71
|
+
try {
|
|
72
|
+
k = JSON.parse(readFileSync(rowsFile(root), 'utf8')) as Kept
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
if (k.cli !== cli || typeof k.sources !== 'object' || k.sources === null) return null
|
|
77
|
+
for (const [p, h] of Object.entries(k.sources)) if (digest(resolve(root, p)) !== h) return null
|
|
78
|
+
return k.answer ?? null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** the answer kept when it names its sources and none of them was written while the CLI ran */
|
|
82
|
+
function keep(root: string, cli: string, answer: unknown, asked: number): void {
|
|
83
|
+
const named = (answer as { sources?: unknown } | null)?.sources
|
|
84
|
+
if (!Array.isArray(named) || named.length === 0) return
|
|
85
|
+
const sources: Record<string, string> = {}
|
|
86
|
+
for (const p of named.map(String)) {
|
|
87
|
+
const abs = resolve(root, p)
|
|
88
|
+
const h = digest(abs)
|
|
89
|
+
if (h === null || statSync(abs).mtimeMs >= asked) return
|
|
90
|
+
sources[p] = h
|
|
91
|
+
}
|
|
92
|
+
const file = rowsFile(root)
|
|
93
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
94
|
+
const part = `${file}.${process.pid}.part`
|
|
95
|
+
writeFileSync(part, JSON.stringify({ cli, sources, answer } satisfies Kept))
|
|
96
|
+
renameSync(part, file)
|
|
97
|
+
}
|
|
98
|
+
|
|
50
99
|
/** the rows asked again on the next read: a watch builds as a fresh process would */
|
|
51
100
|
export const forgetVocab = (): void => asked.clear()
|
|
52
101
|
|
|
53
102
|
/** the project CLI's film rows; null when the repo has no project CLI. A CLI that does not answer is an error. Asked
|
|
54
|
-
* once per process
|
|
103
|
+
* once per process (the prune and a watch read many cuts), and not at all while its last answer's sources hold */
|
|
55
104
|
export function readVocab(root: string, cli: readonly string[] | null): FilmVerb[] | null {
|
|
56
105
|
if (cli === null) return null
|
|
57
|
-
const
|
|
106
|
+
const line = cli.join(' ')
|
|
107
|
+
const k = `${root}\0${line}`
|
|
58
108
|
const had = asked.get(k)
|
|
59
109
|
if (had) return had
|
|
110
|
+
const kept = keptAnswer(root, line)
|
|
111
|
+
if (kept !== null) {
|
|
112
|
+
const rows = filmRows(kept)
|
|
113
|
+
asked.set(k, rows)
|
|
114
|
+
return rows
|
|
115
|
+
}
|
|
116
|
+
const at = Date.now()
|
|
60
117
|
const r = Bun.spawnSync([...cli, 'commands', '--json'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
61
118
|
const text = r.stdout.toString()
|
|
62
119
|
try {
|
|
63
120
|
if (r.exitCode !== 0) throw new Error(`exit ${r.exitCode}`)
|
|
64
|
-
const
|
|
121
|
+
const answer: unknown = JSON.parse(text)
|
|
122
|
+
const rows = filmRows(answer)
|
|
65
123
|
asked.set(k, rows)
|
|
124
|
+
keep(root, line, answer, at)
|
|
66
125
|
return rows
|
|
67
126
|
} catch (e) {
|
|
68
127
|
throw new Error(`the project CLI (${cli.join(' ')}) did not answer \`commands --json\` with its rows: ${(e as Error).message}${r.stderr.length ? `\n${r.stderr.toString().trim().split('\n').slice(-4).join('\n')}` : ''}`)
|