rvlib-shipkit 2.5.0 → 2.7.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 -0
- package/package.json +1 -1
- package/src/cli/cmd_Review.ts +8 -0
- package/src/collections/review.ts +20 -5
- package/src/fleet/scan-fleet.ts +4 -1
- package/src/review/model.ts +7 -4
- package/src/review/threads.ts +4 -3
- package/src/serve/hub-app/home-st.ts +17 -2
- package/src/serve/hub-app/hub-app.tsx +1 -1
- package/src/serve/hub-app/layout-st.ts +17 -2
- package/src/serve/hub-app/project-list.css +8 -3
- package/src/serve/hub-app/project-list.tsx +17 -6
- package/src/serve/hub-app/projects-svc.ts +21 -8
- package/src/serve/hub-app/review/review-st.ts +5 -4
- package/src/serve/project-gutter-route.ts +18 -1
- package/src/shot/clip-encoder.ts +29 -5
- 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 +41 -12
- package/src/trailer/titles.ts +63 -34
- package/src/trailer/vocab.ts +65 -6
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')}` : ''}`)
|