rvlib-shipkit 2.4.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +2 -2
  3. package/src/cli/cmd_Commands.ts +26 -5
  4. package/src/cli/cmd_FleetPush.ts +47 -0
  5. package/src/cli/cmd_GlobalProcesses.ts +5 -5
  6. package/src/cli/cmd_Review.ts +16 -1
  7. package/src/cli/cmd_Trailer.ts +4 -1
  8. package/src/cli/family-pages.ts +49 -0
  9. package/src/cli/kit/builtins.ts +1 -0
  10. package/src/cli/registry.ts +137 -0
  11. package/src/cli.ts +47 -135
  12. package/src/core/last-good.ts +217 -0
  13. package/src/core/load-probe.ts +71 -0
  14. package/src/core/reexec.ts +5 -3
  15. package/src/fleet/push-fleet.ts +80 -0
  16. package/src/home/home-routes.ts +12 -2
  17. package/src/home/lib-watch.ts +110 -20
  18. package/src/home/process-rows.ts +4 -2
  19. package/src/home/run-daemon.ts +1 -1
  20. package/src/review/model.ts +19 -0
  21. package/src/review/paste.ts +30 -0
  22. package/src/review/routes.ts +10 -3
  23. package/src/scaffold/files/agent-index.md +2 -2
  24. package/src/scaffold/project-scaffold.ts +2 -2
  25. package/src/serve/hub-app/activity-st.ts +31 -0
  26. package/src/serve/hub-app/activity-view.tsx +9 -0
  27. package/src/serve/hub-app/activity.css +6 -0
  28. package/src/serve/hub-app/home-strip.tsx +3 -1
  29. package/src/serve/hub-app/machine-st.ts +1 -1
  30. package/src/serve/hub-app/review/review-answer.tsx +16 -37
  31. package/src/serve/hub-app/review/review-page.tsx +8 -1
  32. package/src/serve/hub-app/review/review-st.ts +51 -3
  33. package/src/serve/hub-app/review/review-views.tsx +1 -1
  34. package/src/serve/hub-app/review.css +4 -4
  35. package/src/serve/run-serve.ts +25 -0
  36. package/src/serve/serve-hub.ts +8 -1
  37. package/src/serve/stale-restart.ts +19 -2
  38. package/src/trailer/build.ts +16 -7
  39. package/src/trailer/ffmpeg.ts +21 -1
  40. package/src/trailer/grammar.ts +56 -11
  41. package/src/trailer/look.ts +31 -16
  42. package/src/trailer/prune.ts +59 -12
  43. package/src/trailer/score/dsp.ts +87 -27
  44. package/src/trailer/score/pattern.ts +1 -1
  45. package/src/trailer/score/synth.ts +26 -5
  46. package/src/trailer/titles.ts +63 -34
  47. package/src/trailer/vocab.ts +65 -6
@@ -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 stereo compressor in place, 6 ms of lookahead: the gain follows the louder channel, falling at `attack` and
130
- * rising at `release`, in dB */
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
- const n = l.length
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
- compressRun(l, r, n, Math.round(0.006 * rate), Math.exp(-1 / (Math.max(1e-4, c.attack) * rate)), Math.exp(-1 / (Math.max(1e-4, c.release) * rate)), table, lo, step, makeup)
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, n: number, delay: number, att: number, rel: number, table: Float32Array, lo: number, step: number, makeup: number): void {
146
- const dl = new Float32Array(delay)
147
- const dr = new Float32Array(delay)
148
- let env = 0
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
- for (let i = 0; i < n; i++) {
151
- const x = Math.max(Math.abs(l[i]!), Math.abs(r[i]!))
152
- let target = 0
153
- if (x > 1e-3) {
154
- const p = (20 * Math.log10(x) - lo) / step
155
- const j = Math.min(last - 1, Math.max(0, p | 0))
156
- const f = Math.min(1, Math.max(0, p - j))
157
- target = table[j]! + (table[j + 1]! - table[j]!) * f
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
- /** one oscillator voice added into `buf`: a table read at a moving pitch, its detune and vibrato in cents */
208
- function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, table: Float32Array, p: Pitch, cents: number, vib: InstrumentDef['vibrato'] | null, gain: number): void {
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 (!steady) {
221
- const t = t0 + i / rate
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)
@@ -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 accent of its `*word*`,
62
- * and the passes under the fill: a glow, an outline (its shadow under it), an inner stroke */
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) => grad.addColorStop(0.1 + (0.9 * k) / Math.max(1, p.fill.length - 1), c))
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) 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) }
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.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)) }
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, edge: string): void {
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 = alpha(edge, 0.4)
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 edge's shade across the frame, behind words */
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.edge, 0))
260
- b.addColorStop(0.5, alpha(look.edge, a))
261
- b.addColorStop(1, alpha(look.edge, 0))
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(sl.accent, 0.9)
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 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
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 block = markH + (markH ? px * 0.25 : 0) + (first ? px : 0) + (more.length ? subPx * 2 : 0)
350
- let y = H * 0.48 - block / 2
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 + px * 0.25
387
+ y += markH + markGap
361
388
  }
362
389
  if (first) {
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) } })
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, 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)
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.edge)
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, darker(look.chip.fill, 0.15), look.edge))
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
  }
@@ -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
- import { existsSync, readFileSync } from 'node:fs'
5
- import { join } from 'node:path'
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: the prune and a watch read many cuts */
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 k = `${root}\0${cli.join(' ')}`
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 rows = filmRows(JSON.parse(text))
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')}` : ''}`)