rvlib-shipkit 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
@@ -1,9 +1,10 @@
1
1
  // the stitch's ffmpeg arguments, as data: a piece of the cut (its shots cut to their frames, joined by their cuts, the
2
2
  // titles laid over) and the mix of the sound. Pure: stitch.ts runs them, keyed on these very arguments
3
3
  import type { CutKind, Shot, Trailer } from './grammar.ts'
4
- import { musicCues, starts, total } from './grammar.ts'
4
+ import { CUTS, GROW_S, musicCues, starts, total } from './grammar.ts'
5
5
  import type { TrailerLook } from './look.ts'
6
6
  import { frameAt, frameSpans, type Piece } from './plan.ts'
7
+ import { inFrames, KNOCK_S, outFrames, type TitleMotion, titleMotion } from './motion.ts'
7
8
  import type { TitleImage } from './titles.ts'
8
9
 
9
10
  /** a wipe's edge, as xfade draws it: along x or y, starting from the far side (`from`) or the near one */
@@ -14,26 +15,29 @@ const SEAM_EDGE: Partial<Record<CutKind, { axis: 'x' | 'y'; from: boolean }>> =
14
15
  'wipe-down': { axis: 'y', from: false },
15
16
  }
16
17
 
17
- const XFADE: Record<Exclude<CutKind, 'hard'>, string> = {
18
- fade: 'fade',
19
- 'dip-black': 'fadeblack',
20
- 'dip-white': 'fadewhite',
21
- 'slide-left': 'slideleft',
22
- 'slide-right': 'slideright',
23
- 'slide-up': 'slideup',
24
- 'slide-down': 'slidedown',
25
- 'whip-left': 'smoothleft',
26
- 'whip-right': 'smoothright',
27
- 'wipe-left': 'wipeleft',
28
- 'wipe-right': 'wiperight',
29
- 'wipe-up': 'wipeup',
30
- 'wipe-down': 'wipedown',
31
- circle: 'circleopen',
32
- pixel: 'pixelize',
18
+ /** a grid's or a split's cell: a shot's or a footage block's take, from its `from`, cropped by its `frame` */
19
+ export interface GridCell {
20
+ footage: string
21
+ seconds: number
22
+ from: number
23
+ frame: number | null
24
+ /** where a grid's cell crops, each 0 to 1 across and down its take; absent, its middle */
25
+ at?: readonly [number, number]
33
26
  }
34
27
 
35
- /** what each shot is drawn from: a video and its length, or a still image held for the shot */
36
- export type Source = { footage: string; seconds: number } | { image: string }
28
+ /** a split's drawn pieces: each pane's shape (white on black) and the seams laid over them (stitch.ts splitMasks) */
29
+ export interface SplitMasks {
30
+ panes: string[]
31
+ seams: string
32
+ }
33
+
34
+ /** what each shot is drawn from: a video and its length, a still image held for the shot, or a grid or a split of
35
+ * other takes laid under its frame (stitch.ts gridMask, splitMasks) */
36
+ export type Source =
37
+ | { footage: string; seconds: number }
38
+ | { image: string }
39
+ | { grid: GridCell[]; pop: number; grow?: [number, number]; mask: string }
40
+ | { split: GridCell[]; angle: number; slide: number; masks: SplitMasks }
37
41
 
38
42
  export interface StitchInput {
39
43
  t: Trailer
@@ -50,6 +54,81 @@ export interface StitchInput {
50
54
  }
51
55
 
52
56
  const n3 = (x: number): string => (+x.toFixed(4)).toString()
57
+ const even = (x: number): number => 2 * Math.round(x / 2)
58
+
59
+ /** the grid's margin round the cells and the gap between them, and the corners' radius, in frame heights */
60
+ export const GRID_MARGIN = 0.03
61
+ export const GRID_GAP = 0.014
62
+ export const GRID_RADIUS = 0.022
63
+ /** a cell's pop: its length, seconds, and the overshoot of its ease */
64
+ const POP_S = 0.22
65
+ const POP_BACK = 1.70158
66
+ /** a split's pane j slides in this long after pane j - 1 */
67
+ const SPLIT_STAGGER = 0.08
68
+ /** how much bigger a pane's take is drawn than the frame, so its middle can move to the pane's middle */
69
+ export const SPLIT_ZOOM = 1.3
70
+ /** a growing grid is laid this many times the frame's size, so the one cell it opens on fills the frame sharp */
71
+ export const GROW_RES = 3
72
+
73
+ /** where each of `n` cells lies on the frame: 2 by 2 up to four, else 3 by 3, row by row; even pixels for the encoder */
74
+ export function gridLayout(n: number, W: number, H: number): { x: number; y: number; w: number; h: number }[] {
75
+ const cols = n <= 1 ? 1 : n <= 4 ? 2 : 3
76
+ const rows = Math.max(1, Math.ceil(n / cols))
77
+ const m = H * GRID_MARGIN
78
+ const g = H * GRID_GAP
79
+ const cw = (W - 2 * m - (cols - 1) * g) / cols
80
+ const ch = (H - 2 * m - (rows - 1) * g) / rows
81
+ return Array.from({ length: n }, (_, i) => {
82
+ const [c, r] = [i % cols, Math.floor(i / cols)]
83
+ const x = even(m + c * (cw + g))
84
+ const y = even(m + r * (ch + g))
85
+ return { x, y, w: even(m + c * (cw + g) + cw) - x, h: even(m + r * (ch + g) + ch) - y }
86
+ })
87
+ }
88
+
89
+ /** a split's pane j of `n`: the frame cut by seams through (W i / n, H / 2), each leaning `angle` degrees from
90
+ * upright, its top toward the right (two panes at 56: top left and bottom right). Its outline (it may run past the
91
+ * frame, which clips it), and its middle, where its take centres */
92
+ export function splitPane(n: number, j: number, angle: number, W: number, H: number): { points: [number, number][]; cx: number; cy: number } {
93
+ const t = Math.tan((angle * Math.PI) / 180)
94
+ const far = W + H * (1 + t)
95
+ const seam = (i: number, y: number): number => (i <= 0 ? -far : i >= n ? W + far : (W * i) / n + (H / 2 - y) * t)
96
+ const points: [number, number][] = [
97
+ [seam(j, 0), 0],
98
+ [seam(j + 1, 0), 0],
99
+ [seam(j + 1, H), H],
100
+ [seam(j, H), H],
101
+ ]
102
+ // its middle: the mean of a fine grid's points inside it
103
+ let [sx, sy, k] = [0, 0, 0]
104
+ for (let gy = 0.5; gy < 36; gy++)
105
+ for (let gx = 0.5; gx < 64; gx++) {
106
+ const [x, y] = [(gx * W) / 64, (gy * H) / 36]
107
+ if (x >= seam(j, y) && x < seam(j + 1, y)) [sx, sy, k] = [sx + x, sy + y, k + 1]
108
+ }
109
+ return { points, cx: k ? sx / k : W / 2, cy: k ? sy / k : H / 2 }
110
+ }
111
+
112
+ /** the grow's look rectangle at time t, in the big grid's pixels: cell 0, then the 2 by 2 block, then the whole */
113
+ function growZoom(cells: { x: number; y: number; w: number; h: number }[], grow: [number, number], W: number, H: number): { z: string; x: string; y: string } {
114
+ const c0 = cells[0]!
115
+ const c4 = cells[4]!
116
+ const pad = cells[1]!.x - (c0.x + c0.w)
117
+ const fit = (x0: number, y0: number, x1: number, y1: number): [number, number, number] => {
118
+ // the frame's aspect round the rectangle, a little margin, centred on it
119
+ const w = Math.max(x1 - x0 + pad, ((y1 - y0 + pad) * W) / H)
120
+ return [(x0 + x1) / 2, (y0 + y1) / 2, w]
121
+ }
122
+ const one = fit(c0.x, c0.y, c0.x + c0.w, c0.y + c0.h)
123
+ const four = fit(c0.x, c0.y, c4.x + c4.w, c4.y + c4.h)
124
+ const all: [number, number, number] = [W / 2, H / 2, W]
125
+ // each move eases in and out over GROW_S, ending on its settle
126
+ const ease = (end: number): string => `(1-cos(PI*clip((it-${n3(end - GROW_S)})/${GROW_S},0,1)))/2`
127
+ const [a, b] = [ease(grow[0]), ease(grow[1])]
128
+ const lerp = (k: 0 | 1 | 2): string => `(${n3(one[k])}+(${n3(four[k] - one[k])})*${a}+(${n3(all[k] - four[k])})*${b})`
129
+ const w = lerp(2)
130
+ return { z: `${W}/${w}`, x: `${lerp(0)}-${w}/2`, y: `${lerp(1)}-${w}*${n3(H / W)}/2` }
131
+ }
53
132
  /** ffmpeg's colour syntax for a hex colour */
54
133
  const ff = (c: string): string => (c.startsWith('#') ? `0x${c.slice(1)}` : c)
55
134
 
@@ -102,9 +181,107 @@ function flashF(s: Shot): string {
102
181
  .join('')
103
182
  }
104
183
 
105
- /** a knocked title flies out of the frame in this long, turning this many radians */
106
- const KNOCK_S = 0.4
107
- const KNOCK_TURN = 1.4
184
+ /** a frame's own pseudo random number, 0 to 1: the same cut draws the same frames */
185
+ const rnd = (...xs: number[]): number => {
186
+ const v = Math.sin(xs.reduce((n, x, j) => n + x * [12.9898, 78.233, 37.719, 4.581][j % 4]!, 0)) * 43758.5453
187
+ return v - Math.floor(v)
188
+ }
189
+
190
+ /** the cuts the core draws, frame by frame over the frames both shots hold: `a` the shot leaving, `b` the one coming,
191
+ * `n` frames, the result labelled `out`. A whip pans both shots past at speed, blurred along the way they fly; a
192
+ * glitch tears the picture (colours split, bands torn sideways, grain) round a flicker from one shot to the next; a
193
+ * leak burns two soft lights of the look across a dissolve */
194
+ export function drawnCut(kind: CutKind, a: string, b: string, out: string, n: number, i: number, [W, H]: readonly [number, number], fps: number, look: Pick<TrailerLook, 'accent' | 'light'>): string[] {
195
+ const d = n / fps
196
+ if (kind === 'leak') {
197
+ const big = `s=${2 * W}x${H}:r=${fps}:d=${n3(d + 0.2)}:type=radial:speed=0`
198
+ const [y1, y2, r1, r2] = [Math.round(H * 0.4), Math.round(H * 0.85), Math.round(W * 0.45), Math.round(W * 0.3)]
199
+ return [
200
+ `[${a}][${b}]xfade=transition=fade:duration=${n3(d)}:offset=0,format=gbrp[lx${i}]`,
201
+ `gradients=${big}:n=3:c0=0xffdcaa:c1=${ff(look.accent)}:c2=0x000000:x0=${W}:y0=${y1}:x1=${W + r1}:y1=${y1},format=gbrp,crop=w=${W}:h=${H}:x='${W}*(1.1-t/${n3(d)})':y=0[la${i}]`,
202
+ `gradients=${big}:n=2:c0=0xff5a46:c1=0x000000:x0=${W}:y0=${y2}:x1=${W + r2}:y1=${y2},format=gbrp,crop=w=${W}:h=${H}:x='${W}*(0.25+0.6*t/${n3(d)})':y=0[lb${i}]`,
203
+ `[la${i}][lb${i}]blend=all_mode=screen,colorchannelmixer=rr=0.8:gg=0.8:bb=0.8,fade=t=in:st=0:d=${n3(d / 2)},fade=t=out:st=${n3(d / 2)}:d=${n3(d / 2)}[ll${i}]`,
204
+ `[lx${i}][ll${i}]blend=all_mode=screen:shortest=1,format=yuv420p[${out}]`,
205
+ ]
206
+ }
207
+ const ks = Array.from({ length: n }, (_, k) => k)
208
+ const g = [
209
+ `[${a}]split=${n}${ks.map((k) => `[ca${i}_${k}x]`).join('')}`,
210
+ `[${b}]split=${n}${ks.map((k) => `[cb${i}_${k}x]`).join('')}`,
211
+ ...ks.flatMap((k) => [`[ca${i}_${k}x]trim=start_frame=${k}:end_frame=${k + 1},setpts=PTS-STARTPTS[ca${i}_${k}]`, `[cb${i}_${k}x]trim=start_frame=${k}:end_frame=${k + 1},setpts=PTS-STARTPTS[cb${i}_${k}]`]),
212
+ ]
213
+ for (const k of ks) {
214
+ const [fa, fb, fo] = [`ca${i}_${k}`, `cb${i}_${k}`, `co${i}_${k}`]
215
+ const p = (k + 0.5) / n
216
+ if (kind.startsWith('whip-')) {
217
+ // eased in and out: the speed peaks mid way, and the blur with it (a box as wide as a frame's travel)
218
+ const u = p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2
219
+ const across = kind === 'whip-left' || kind === 'whip-right'
220
+ const span = across ? W : H
221
+ const sigma = Math.min(600, (12 * Math.min(p, 1 - p) ** 2 * span * 0.35) / n)
222
+ const forward = kind === 'whip-left' || kind === 'whip-up'
223
+ const off = Math.round((forward ? u : 1 - u) * span)
224
+ const pair = forward ? `[${fa}][${fb}]` : `[${fb}][${fa}]`
225
+ g.push(`${pair}${across ? 'hstack' : 'vstack'}=inputs=2,crop=${W}:${H}:${across ? off : 0}:${across ? 0 : off},gblur=sigma=${n3(across ? sigma : 0)}:sigmaV=${n3(across ? 0 : sigma)}[${fo}]`)
226
+ continue
227
+ }
228
+ // glitch: the picture flickers from one shot to the other round the middle, torn hardest there
229
+ const env = Math.sin(Math.PI * p) ** 0.7
230
+ const flick = p > 0.3 && p < 0.7 && rnd(i, k, 9) < 0.45
231
+ const src = (p < 0.5) !== flick ? fa : fb
232
+ g.push(`[${src === fa ? fb : fa}]nullsink`)
233
+ const dx = Math.round(W * 0.016 * env * (0.4 + rnd(i, k, 1)))
234
+ const bands = 1 + Math.floor(rnd(i, k, 2) * 3)
235
+ let cur = `[${src}]format=gbrp,rgbashift=rh=${dx}:bh=${-dx}:gv=${Math.round(dx / 3)}:edge=smear`
236
+ const parts: string[] = []
237
+ for (let j = 0; j < bands; j++) {
238
+ const bh = 2 * Math.round((H * (0.03 + 0.1 * rnd(i, k, j, 3))) / 2)
239
+ const by = Math.round((H - bh) * rnd(i, k, j, 4))
240
+ const shift = Math.round((rnd(i, k, j, 5) - 0.5) * W * 0.16 * env)
241
+ parts.push(`${cur},split[gb${i}_${k}_${j}][gs${i}_${k}_${j}]`, `[gs${i}_${k}_${j}]crop=${W}:${bh}:0:${by}[gt${i}_${k}_${j}]`)
242
+ cur = `[gb${i}_${k}_${j}][gt${i}_${k}_${j}]overlay=x=${shift}:y=${by}`
243
+ }
244
+ g.push(...parts, `${cur},noise=alls=${Math.round(28 * env)}:allf=t+u:all_seed=${i * 977 + k},format=yuv420p[${fo}]`)
245
+ }
246
+ g.push(`${ks.map((k) => `[co${i}_${k}]`).join('')}concat=n=${n}:v=1:a=0,settb=1/${fps},setpts=N,format=yuv420p[${out}]`)
247
+ return g
248
+ }
249
+
250
+ /** each title's span on the cut, shot by shot: its first and last frame (inclusive) and its seconds, a knock's flight
251
+ * in them. A progress step ends the frame before the next one starts: two steps never stand on one frame */
252
+ export function titleSpans(t: Pick<Trailer, 'shots' | 'fps'> & Parameters<typeof starts>[0]): { ta: number; te: number; seconds: number }[][] {
253
+ const st = starts(t)
254
+ return t.shots.map((s, i) =>
255
+ s.titles.map((ti) => {
256
+ const at = st[i]! + ti.at
257
+ const seconds = Math.min(ti.for ?? s.length - ti.at, ti.knock === undefined ? Infinity : ti.knock - ti.at + KNOCK_S)
258
+ const handOff = ti.style === 'progress' && ti.step !== undefined && ti.step.i < ti.step.n ? 1 : 0
259
+ return { ta: frameAt(at, t.fps), te: Math.floor((at + seconds) * t.fps + 1e-6) - handOff, seconds }
260
+ }),
261
+ )
262
+ }
263
+
264
+ /** each title's entrance and exit in frames (motion.ts), shot by shot: what the build draws and the stitch lays */
265
+ export function titleMotions(t: Pick<Trailer, 'shots' | 'fps'> & Parameters<typeof starts>[0]): TitleMotion[][] {
266
+ const spans = titleSpans(t)
267
+ return t.shots.map((s, i) =>
268
+ s.titles.map((ti, j) => {
269
+ const sp = spans[i]![j]!
270
+ return titleMotion({
271
+ style: ti.style,
272
+ text: ti.text,
273
+ ...(ti.in ? { in: ti.in } : {}),
274
+ ...(ti.out ? { out: ti.out } : {}),
275
+ ...(ti.knock !== undefined ? { knock: ti.knock - ti.at } : {}),
276
+ ...(ti.step ? { step: ti.step } : {}),
277
+ seconds: sp.seconds,
278
+ frames: Math.max(0, sp.te - sp.ta + 1),
279
+ fps: t.fps,
280
+ cover: ti.style === 'short' && sp.ta === 0,
281
+ })
282
+ }),
283
+ )
284
+ }
108
285
 
109
286
  /** the ffmpeg arguments that render a piece's frames, no sound, and the row of its shots' middle frames for the
110
287
  * contact strip (`thumbs`, when a middle falls in it) */
@@ -143,7 +320,74 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
143
320
  const cropF = s.frame && s.frame > 1 ? `crop=w='2*trunc(iw/${n3(s.frame)}/2)':h='2*trunc(ih/${n3(s.frame)}/2)':x='min(max(iw*${n3(ax)}-ow/2,0),iw-ow)':y='min(max(ih*${n3(ay)}-oh/2,0),ih-oh)'` : null
144
321
  const frameF = cropF ? `,${cropF},scale=${W}:${H}` : ''
145
322
  const tail = `,trim=end_frame=${frames},settb=1/${fps},setpts=N+${skip}${shakeF(s, W, H)},setsar=1,format=yuv420p${s.lift ? `,eq=gamma=${n3(s.lift)}` : ''}${flashF(s)},${TAGS},setpts=N[s${i}]`
146
- if ('image' in src) {
323
+ if ('grid' in src) {
324
+ // the cells, each its take from its own `from`, cropped to fill its place and held on its last frame, laid on
325
+ // the slots' ground under the grid's frame; cell j pops in at j times `pop`, its take starting then. Then the
326
+ // shot's own frame, push and shakes as any footage
327
+ const G = src.grow ? GROW_RES : 1
328
+ const cells = gridLayout(src.grid.length, W * G, H * G)
329
+ const ground = input('-f', 'lavfi', '-i', `color=c=${ff(a.look.edge)}:s=${W * G}x${H * G}:r=${fps}:d=${n3(len + 0.1)}`)
330
+ graph.push(`[${ground}:v]format=yuv420p[g${i}_0]`)
331
+ src.grid.forEach((c, j) => {
332
+ const at = cells[j]!
333
+ const T0 = Math.min(len, j * src.pop)
334
+ const run = len - T0
335
+ const avail = Math.max(0, c.seconds - c.from)
336
+ const k = input(...DECODE, '-ss', n3(c.from), '-t', n3(Math.min(avail, run) + 0.1), '-i', c.footage)
337
+ const crop = !c.frame || c.frame <= 1 ? '' : c.at
338
+ ? `crop=w='2*trunc(iw/${n3(c.frame)}/2)':h='2*trunc(ih/${n3(c.frame)}/2)':x='min(max(iw*${n3(c.at[0])}-ow/2,0),iw-ow)':y='min(max(ih*${n3(c.at[1])}-oh/2,0),ih-oh)',`
339
+ : `crop=iw/${n3(c.frame)}:ih/${n3(c.frame)},`
340
+ const fit = `${crop}scale=${at.w}:${at.h}:force_original_aspect_ratio=increase:${FULL_601},crop=${at.w}:${at.h},setsar=1`
341
+ if (src.pop <= 0) {
342
+ graph.push(`[${k}:v]trim=duration=${n3(Math.min(avail, run))},setpts=PTS-STARTPTS,fps=${fps},${fit},format=yuv420p,tpad=stop_mode=clone:stop_duration=${n3(len)}[c${i}_${j}]`)
343
+ graph.push(`[g${i}_${j}][c${i}_${j}]overlay=x=${at.x}:y=${at.y}:eof_action=repeat[g${i}_${j + 1}]`)
344
+ return
345
+ }
346
+ // the pop: grown from 55 % to a hair past whole and back (an ease out with overshoot), faded in
347
+ const x = `clip((t-${n3(T0)})/${POP_S},0,1)`
348
+ const K = `(0.55+0.45*(1+${n3(POP_BACK + 1)}*pow(${x}-1,3)+${POP_BACK}*pow(${x}-1,2)))`
349
+ graph.push(
350
+ `[${k}:v]trim=duration=${n3(Math.min(avail, run))},setpts=PTS-STARTPTS,fps=${fps},${fit},format=yuva420p,tpad=stop_mode=clone:stop_duration=${n3(run)},setpts=PTS+${n3(T0)}/TB,` +
351
+ `fade=t=in:st=${n3(T0)}:d=0.08:alpha=1,scale=w='trunc(${at.w}*${K}/2)*2':h='trunc(${at.h}*${K}/2)*2':eval=frame[c${i}_${j}]`,
352
+ )
353
+ graph.push(`[g${i}_${j}][c${i}_${j}]overlay=x='${at.x}+(${at.w}-w)/2':y='${at.y}+(${at.h}-h)/2':eval=frame:format=auto:eof_action=repeat[g${i}_${j + 1}]`)
354
+ })
355
+ const m = input('-loop', '1', '-framerate', String(fps), '-t', n3(len + 0.1), '-i', src.mask)
356
+ // a growing grid: the frame looks at cell 0, eases out to the 2 by 2 block, then to the whole, scaled to the frame
357
+ const zoom = src.grow ? growZoom(cells, src.grow, W * G, H * G) : null
358
+ const grow = zoom ? `,zoompan=z='${zoom.z}':x='${zoom.x}':y='${zoom.y}':d=1:s=${W}x${H}:fps=${fps}` : ''
359
+ graph.push(`[g${i}_${src.grid.length}][${m}:v]overlay=x=0:y=0:format=auto,trim=duration=${n3(len)},setpts=PTS-STARTPTS,fps=${fps}${grow}${skip > 0 ? `,trim=start_frame=${skip}` : ''},setsar=1,format=yuv420p${frameF}${pushF}${tail}`)
360
+ } else if ('split' in src) {
361
+ // each pane its take drawn a little bigger and centred on the pane's middle, cut to its shape, sliding in from
362
+ // its side with pane j a beat after pane j - 1; the seams laid over all; the ground where no pane shows yet
363
+ const ground = input('-f', 'lavfi', '-i', `color=c=${ff(a.look.edge)}:s=${W}x${H}:r=${fps}:d=${n3(len + 0.1)}`)
364
+ graph.push(`[${ground}:v]format=yuv420p[p${i}_0]`)
365
+ const [BW, BH] = [even(W * SPLIT_ZOOM), even(H * SPLIT_ZOOM)]
366
+ src.split.forEach((c, j) => {
367
+ const pane = splitPane(src.split.length, j, src.angle, W, H)
368
+ const ox = Math.round(Math.min(BW - W, Math.max(0, BW / 2 - pane.cx)))
369
+ const oy = Math.round(Math.min(BH - H, Math.max(0, BH / 2 - pane.cy)))
370
+ const avail = Math.max(0, c.seconds - c.from)
371
+ const k = input(...DECODE, '-ss', n3(c.from), '-t', n3(Math.min(avail, len) + 0.1), '-i', c.footage)
372
+ const m = input('-loop', '1', '-framerate', String(fps), '-t', n3(len + 0.1), '-i', src.masks.panes[j]!)
373
+ const crop = c.frame && c.frame > 1 ? `crop=iw/${n3(c.frame)}:ih/${n3(c.frame)},` : ''
374
+ graph.push(
375
+ `[${k}:v]trim=duration=${n3(Math.min(avail, len))},setpts=PTS-STARTPTS,fps=${fps},${crop}scale=${BW}:${BH}:force_original_aspect_ratio=increase:${FULL_601},crop=${BW}:${BH},crop=${W}:${H}:${ox}:${oy},setsar=1,format=yuva420p,tpad=stop_mode=clone:stop_duration=${n3(len)}[pf${i}_${j}]`,
376
+ `[${m}:v]format=gray,scale=${W}:${H}[pm${i}_${j}]`,
377
+ `[pf${i}_${j}][pm${i}_${j}]alphamerge[pa${i}_${j}]`,
378
+ )
379
+ // it slides in from the side its middle is on, eased out, from a pane's width away
380
+ const T0 = j * SPLIT_STAGGER
381
+ const side = pane.cx < W / 2 - 1 ? -1 : pane.cx > W / 2 + 1 ? 1 : 0
382
+ const x = src.slide > 0 ? `clip((t-${n3(T0)})/${n3(src.slide)},0,1)` : '1'
383
+ const ease = `(1-pow(1-${x},3))`
384
+ const dx = side !== 0 ? `${n3(side * W * 0.7)}*(1-${ease})` : '0'
385
+ const dy = side === 0 ? `${n3(H * 0.8)}*(1-${ease})` : '0'
386
+ graph.push(`[p${i}_${j}][pa${i}_${j}]overlay=x='${dx}':y='${dy}':eval=frame:format=auto:eof_action=repeat[p${i}_${j + 1}]`)
387
+ })
388
+ const sm = input('-loop', '1', '-framerate', String(fps), '-t', n3(len + 0.1), '-i', src.masks.seams)
389
+ graph.push(`[p${i}_${src.split.length}][${sm}:v]overlay=x=0:y=0:format=auto,trim=duration=${n3(len)},setpts=PTS-STARTPTS,fps=${fps}${skip > 0 ? `,trim=start_frame=${skip}` : ''},setsar=1,format=yuv420p${frameF}${pushF}${tail}`)
390
+ } else if ('image' in src) {
147
391
  // read at the cut's rate: a still read at the demuxer's 25 fps breaks the xfade of a cut into it
148
392
  const k = input('-framerate', String(fps), '-i', src.image)
149
393
  graph.push(`[${k}:v]scale=${W}:${H}:force_original_aspect_ratio=increase:out_range=pc,crop=${W}:${H},setsar=1${frameF},loop=loop=${frames - 1}:size=1:start=0,setpts=N/${fps}/TB${pushF}${tail}`)
@@ -177,7 +421,9 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
177
421
  const kind = t.shots[i - 1]!.cut.kind as Exclude<CutKind, 'hard'>
178
422
  const d = head / fps
179
423
  const edge = SEAM_EDGE[kind]
180
- if (edge === undefined) graph.push(`[e${i - 1}][h${i}]xfade=transition=${XFADE[kind]}:duration=${n3(d)}:offset=0,format=yuv420p[j${i}]`)
424
+ const how = CUTS[kind] as string
425
+ if (how === 'drawn') graph.push(...drawnCut(kind, `e${i - 1}`, `h${i}`, `j${i}`, head, i, [W, H], fps, a.look))
426
+ else if (edge === undefined) graph.push(`[e${i - 1}][h${i}]xfade=transition=${how}:duration=${n3(d)}:offset=0,format=yuv420p[j${i}]`)
181
427
  else {
182
428
  // a wipe's seam rides its edge: a soft shadow of the look's edge, then a line of its light
183
429
  const across = edge.axis === 'x'
@@ -186,7 +432,7 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
186
432
  const at = (w: number): string => (across ? `x='${pos}-${w / 2}':y=0` : `x=0:y='${pos}-${w / 2}'`)
187
433
  const [sw, lw] = [Math.round(H / 64), Math.max(4, Math.round(H / 300))]
188
434
  graph.push(
189
- `[e${i - 1}][h${i}]xfade=transition=${XFADE[kind]}:duration=${n3(d)}:offset=0[jw${i}]`,
435
+ `[e${i - 1}][h${i}]xfade=transition=${how}:duration=${n3(d)}:offset=0[jw${i}]`,
190
436
  bar(`${ff(a.look.edge)}@0.35`, sw, 'ws'),
191
437
  bar(ff(a.look.light), lw, 'wl'),
192
438
  `[jw${i}][ws${i}]overlay=${at(sw)}:eval=frame:shortest=1[jv${i}]`,
@@ -210,32 +456,34 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
210
456
  cur = 'ends'
211
457
  }
212
458
 
213
- // the titles that show in the piece, wherever their shot is: each faded in and out over its own span
459
+ // the titles that show in the piece, wherever their shot is: each its entrance's frames, its still held, its exit's
460
+ // frames (titles.ts drew them all in one box), laid at that box
461
+ const tspans = titleSpans(t)
214
462
  t.shots.forEach((s, i) => {
215
- s.titles.forEach((ti, j) => {
216
- const at = st[i]! + ti.at
217
- const dur = Math.min(ti.for ?? s.length - ti.at, ti.knock === undefined ? Infinity : ti.knock - ti.at + KNOCK_S)
218
- const [ta, te] = [frameAt(at, fps), Math.floor((at + dur) * fps + 1e-6)]
463
+ s.titles.forEach((_, j) => {
464
+ const { ta, te } = tspans[i]![j]!
219
465
  if (te < p.from || ta >= p.to || te < ta) return
220
- // a tagline and the logo breathe in slowly; the plates pop
221
- const f = Math.min(ti.style === 'line' || ti.style === 'logo' ? 0.7 : 0.25, dur / 3)
222
466
  const img = a.titles[i]![j]!
223
- const k = input('-i', img.file)
467
+ const [nIn, nOut] = [img.in ?? 0, img.out ?? 0]
468
+ const hold = te - ta + 1 - nIn - nOut
224
469
  const [from, to] = [Math.max(ta, base), Math.min(te, p.to - 1)]
225
- const kn = ti.knock === undefined ? null : ti.knock - ti.at
226
- const hyp = kn === null ? 0 : Math.ceil(Math.hypot(img.w, img.h))
227
- const tip = kn === null ? '' : `rotate=a='-${KNOCK_TURN}*pow(max(0,t-${n3(kn)})/${KNOCK_S},2)':ow=${hyp}:oh=${hyp}:c=none,`
228
- graph.push(
229
- `[${k}:v]format=rgba,loop=loop=${to - ta}:size=1:start=0,setpts=N/${fps}/TB,${tip}fade=t=in:st=0:d=${n3(f)}:alpha=1,${kn === null ? `fade=t=out:st=${n3(dur - f)}:d=${n3(f)}:alpha=1,` : ''}` +
230
- `trim=start_frame=${from - ta},settb=1/${fps},setpts=N+${from - base}[t${i}_${j}]`,
231
- )
232
- // it rises into place over its fade in, eased, by a fortieth of the frame; counted in frames
233
- const rise = `${n3(H / 40)}*pow(max(0,1-(n-${n3(at * fps - base)})/${n3(Math.max(f, 0.05) * fps)}),2)`
470
+ const segs: string[] = []
471
+ const seq = (pattern: string, n: number, tag: string): void => {
472
+ const k = input('-framerate', String(fps), '-start_number', '0', '-i', pattern)
473
+ graph.push(`[${k}:v]format=rgba,trim=end_frame=${n},settb=1/${fps},setpts=N[t${i}_${j}${tag}]`)
474
+ segs.push(`[t${i}_${j}${tag}]`)
475
+ }
476
+ if (nIn > 0) seq(inFrames(img.file), nIn, 'i')
477
+ if (hold > 0) {
478
+ const k = input('-i', img.file)
479
+ graph.push(`[${k}:v]format=rgba,loop=loop=${hold - 1}:size=1:start=0,settb=1/${fps},setpts=N[t${i}_${j}h]`)
480
+ segs.push(`[t${i}_${j}h]`)
481
+ }
482
+ if (nOut > 0) seq(outFrames(img.file), nOut, 'o')
483
+ const join = segs.length > 1 ? `${segs.join('')}concat=n=${segs.length}:v=1:a=0,` : `${segs[0]}`
484
+ graph.push(`${join}${segs.length > 1 ? '' : 'null,'}trim=start_frame=${from - ta}:end_frame=${to - ta + 1},settb=1/${fps},setpts=N+${from - base}[t${i}_${j}]`)
234
485
  const on = `between(t,${n3((ta - base - 0.5) / fps)},${n3((te - base + 0.5) / fps)})`
235
- const flown = kn === null ? '' : `pow(max(0,n-${n3((at + kn) * fps - base)})/${n3(KNOCK_S * fps)},2)`
236
- const x = kn === null ? `${img.x}` : `${img.x - (hyp - img.w) / 2}-${n3(W * 1.1)}*${flown}`
237
- const y = kn === null ? `${img.y}+${rise}` : `${img.y - (hyp - img.h) / 2}+${rise}-${n3(H * 0.45)}*${flown}`
238
- graph.push(`[${cur}][t${i}_${j}]overlay=x='${x}':y='${y}':eof_action=pass:enable='${on}'[v${i}_${j}]`)
486
+ graph.push(`[${cur}][t${i}_${j}]overlay=x=${img.x}:y=${img.y}:eof_action=pass:enable='${on}'[v${i}_${j}]`)
239
487
  cur = `v${i}_${j}`
240
488
  })
241
489
  })
@@ -259,9 +507,17 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
259
507
  ]
260
508
  }
261
509
 
510
+ /** the true peak the mix is held under, dBTP */
511
+ export const MIX_TRUE_PEAK = -1
512
+ /** what the limiter aims at below MIX_TRUE_PEAK: the aac encode after it overshoots by a few tenths */
513
+ const LIMIT_MARGIN_DB = 0.5
514
+ /** the limiter runs 4 times oversampled, so it sees the peaks between samples a player's converter makes */
515
+ const OVERSAMPLE = 4
516
+
262
517
  /** the ffmpeg arguments that mix the cut's sound: the music trimmed, faded and ducked under each voice line, the sfx
263
- * and the voices on their times, the mix through a limiter */
264
- export function mixArgs(a: Pick<StitchInput, 't' | 'music' | 'sfx' | 'voices'>, out: string): string[] {
518
+ * and the voices on their times, the sum through a true peak limiter held under MIX_TRUE_PEAK. `trimDb` lowers the
519
+ * limited sum before the encode, for a mix whose aac encode still overshoots (stitch.ts) */
520
+ export function mixArgs(a: Pick<StitchInput, 't' | 'music' | 'sfx' | 'voices'>, out: string, trimDb = 0): string[] {
265
521
  const { t } = a
266
522
  const T = total(t)
267
523
  const st = starts(t)
@@ -318,6 +574,9 @@ export function mixArgs(a: Pick<StitchInput, 't' | 'music' | 'sfx' | 'voices'>,
318
574
  })
319
575
  const silence = input('-f', 'lavfi', '-t', n3(T), '-i', 'anullsrc=r=48000:cl=stereo')
320
576
  mix.push(`[${silence}:a]`)
321
- graph.push(`${mix.join('')}amix=inputs=${mix.length}:normalize=0:duration=longest,atrim=duration=${n3(T)},alimiter=limit=0.95[aout]`)
577
+ // level=disabled: alimiter's default auto level lifts the output back to 0 dBFS, past the limit
578
+ const limit = n3(10 ** ((MIX_TRUE_PEAK - LIMIT_MARGIN_DB) / 20))
579
+ const limiter = `aresample=${48000 * OVERSAMPLE},alimiter=limit=${limit}:attack=1:release=60:level=disabled,aresample=48000${trimDb < 0 ? `,volume=${n3(trimDb)}dB` : ''}`
580
+ graph.push(`${mix.join('')}amix=inputs=${mix.length}:normalize=0:duration=longest,atrim=duration=${n3(T)},${limiter}[aout]`)
322
581
  return ['-hide_banner', '-loglevel', 'error', '-y', ...inputs, '-filter_complex', graph.join(';\n'), '-map', '[aout]', '-c:a', 'aac', '-b:a', '192k', '-t', n3(T), out]
323
582
  }
@@ -0,0 +1,72 @@
1
+ // the frames of a take where the picture dropped out (wiki build/trailer.md, A take and its world): most of the frame
2
+ // one flat colour while the take's other frames are not. A take that shows it is filmed again once, then refused.
3
+ // Turned on by the trailer unit's `holes`
4
+ import { readFileSync, rmSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { ffmpeg } from './ffmpeg.ts'
8
+
9
+ /** the size the frames are read at: a flat sky is flat at any size */
10
+ export const HOLE_W = 64
11
+ export const HOLE_H = 36
12
+ /** a frame with a hole is mostly one colour bucket... */
13
+ const FLAT = 0.4
14
+ /** ...well past what the take's own frames hold (snow, a dark room, open water are flat in every frame) */
15
+ const OVER_MEDIAN = 0.25
16
+ /** ...and well past the frames either side of its run: the picture drops out and comes back, a flat ground stays */
17
+ const SIDE_DROP = 0.2
18
+
19
+ /** a pixel no channel lifts over this is dark, one no channel drops under that is bright: neither is a hole */
20
+ const DARK = 60
21
+ const BRIGHT = 225
22
+
23
+ /** the share of a frame's pixels in its most common colour bucket (5 bits a channel); dark and bright count in no
24
+ * bucket: a dark room, a fade or a flash is the film's or the cut's own */
25
+ export function flatShare(rgb: Uint8Array, frame: number): number {
26
+ const n = HOLE_W * HOLE_H
27
+ const counts = new Map<number, number>()
28
+ let best = 0
29
+ for (let i = 0; i < n; i++) {
30
+ const o = (frame * n + i) * 3
31
+ const r = rgb[o]!
32
+ const g = rgb[o + 1]!
33
+ const b = rgb[o + 2]!
34
+ if (Math.max(r, g, b) < DARK || Math.min(r, g, b) > BRIGHT) continue
35
+ const k = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)
36
+ const c = (counts.get(k) ?? 0) + 1
37
+ counts.set(k, c)
38
+ if (c > best) best = c
39
+ }
40
+ return best / n
41
+ }
42
+
43
+ /** the frames flat far past the take's median, in runs the frames on either side of stand well under: the holes */
44
+ export function holeFrames(shares: readonly number[]): number[] {
45
+ const sorted = [...shares].sort((a, b) => a - b)
46
+ const median = sorted[Math.floor(sorted.length / 2)] ?? 0
47
+ const flat = (i: number): boolean => shares[i]! > FLAT && shares[i]! > median + OVER_MEDIAN
48
+ const out: number[] = []
49
+ for (let a = 0; a < shares.length; a++) {
50
+ if (!flat(a)) continue
51
+ let b = a
52
+ while (b + 1 < shares.length && flat(b + 1)) b++
53
+ const top = Math.max(...shares.slice(a, b + 1))
54
+ const sides = [shares[a - 1], shares[b + 1]].filter((s) => s !== undefined)
55
+ if (sides.every((s) => s < top - SIDE_DROP)) for (let i = a; i <= b; i++) out.push(i)
56
+ a = b
57
+ }
58
+ return out
59
+ }
60
+
61
+ /** the hole frames of a take file */
62
+ export async function takeHoles(file: string): Promise<number[]> {
63
+ const raw = join(tmpdir(), `holes-${process.pid}-${Math.random().toString(36).slice(2)}.rgb`)
64
+ try {
65
+ await ffmpeg(['-i', file, '-vf', `scale=${HOLE_W}:${HOLE_H}`, '-pix_fmt', 'rgb24', '-f', 'rawvideo', raw])
66
+ const rgb = new Uint8Array(readFileSync(raw))
67
+ const frames = Math.floor(rgb.length / (HOLE_W * HOLE_H * 3))
68
+ return holeFrames(Array.from({ length: frames }, (_, f) => flatShare(rgb, f)))
69
+ } finally {
70
+ rmSync(raw, { force: true })
71
+ }
72
+ }
@@ -1,11 +1,28 @@
1
- // the repo's trailer unit, found by the walk every unit file takes (core/discover-checks.ts)
1
+ // the repo's trailer unit, found by the walk every unit file takes (core/discover-checks.ts). Only a file whose text
2
+ // calls `trailer(` is imported: `sk trailer check` stays fast in a repo of many units
3
+ import { readFileSync } from 'node:fs'
2
4
  import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
3
5
  import type { TrailerUnit } from './adapter.ts'
4
6
 
5
- /** the repo's trailer unit, or null; two of them is an error naming both files */
6
- export async function loadTrailerUnit(root: string): Promise<TrailerUnit | null> {
7
+ const cache = new Map<string, Promise<TrailerUnit | null>>()
8
+
9
+ /** the unit found and imported again on the next read (a changed file is imported anew, keyed on its mtime) */
10
+ export const forgetTrailerUnit = (root: string): void => void cache.delete(root)
11
+
12
+ /** the repo's trailer unit, or null; two of them is an error naming both files. Read once per process */
13
+ export function loadTrailerUnit(root: string): Promise<TrailerUnit | null> {
14
+ let got = cache.get(root)
15
+ if (!got) {
16
+ got = find(root)
17
+ cache.set(root, got)
18
+ }
19
+ return got
20
+ }
21
+
22
+ async function find(root: string): Promise<TrailerUnit | null> {
7
23
  const found: { file: string; unit: TrailerUnit }[] = []
8
24
  for (const f of repoUnitFiles(root)) {
25
+ if (!/\btrailer\s*\(/.test(readFileSync(f, 'utf8'))) continue
9
26
  const d = (await importFresh(f)) as { kind?: unknown } | undefined
10
27
  if (d?.kind === 'trailer') found.push({ file: f, unit: d as TrailerUnit })
11
28
  }
@@ -18,8 +18,13 @@ export interface TrailerLookInput {
18
18
  font?: { file?: string; family?: string; weight?: number }
19
19
  /** the logo the `logo` title draws over its words, repo relative (default: logo.png at the web root, when present) */
20
20
  logo?: string
21
+ /** the words of a short: the `short` and `progress` titles and the vertical's words, each value the look's when left out */
22
+ short?: { font?: { file?: string; family?: string; weight?: number }; light?: string; edge?: string; accent?: string }
21
23
  /** the vertical short's look (a `vertical` cut), each part `false` to leave it out (vertical.ts) */
22
24
  vertical?: VerticalLookInput
25
+ /** text styles by name, each a title line's words (`style big font "Bebas Neue" in letters`): a title's `style`
26
+ * names one; one named like a built in style gives that style its defaults (grammar.ts resolveStyles) */
27
+ styles?: Record<string, string>
23
28
  }
24
29
 
25
30
  /** what a vertical short wears, image paths repo relative; its colours and face are the look's */
@@ -44,6 +49,8 @@ export interface TrailerLook {
44
49
  chip: { fill: string; ink: string }
45
50
  font: { file: string | null; family: string; weight: number }
46
51
  logo: string | null
52
+ /** the short's words: their face and colours */
53
+ short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string }
47
54
  }
48
55
 
49
56
  export interface BrandLike {
@@ -97,14 +104,24 @@ export function lookOf(brand: BrandLike | null, webLogo: string | null = null):
97
104
  const p = brand?.palette ?? HOUSE_PALETTE
98
105
  const i = brand?.trailer ?? {}
99
106
  const ground: [string, string] = i.ground ?? [p.bg, darker(p.bg, luminance(p.bg) > 0.5 ? 0.25 : 0.45)]
107
+ const font = { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 }
108
+ const light = i.light ?? (luminance(p.ink) > 0.45 ? p.ink : '#ffffff')
109
+ const edge = i.edge ?? (luminance(ground[1]) < 0.2 ? ground[1] : darker(p.bg, 0.8))
110
+ const accent = i.accent ?? p.accent
111
+ const sh = i.short ?? {}
112
+ 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
100
113
  return {
101
114
  ground,
102
- light: i.light ?? (luminance(p.ink) > 0.45 ? p.ink : '#ffffff'),
103
- edge: i.edge ?? (luminance(ground[1]) < 0.2 ? ground[1] : darker(p.bg, 0.8)),
104
- accent: i.accent ?? p.accent,
115
+ light,
116
+ edge,
117
+ accent,
105
118
  plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink },
106
119
  chip: { fill: i.chip?.fill ?? p.accent, ink: i.chip?.ink ?? (luminance(p.accent) > 0.4 ? darker(p.bg, luminance(p.bg) > 0.5 ? 0.85 : 0.2) : '#ffffff') },
107
120
  font: { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 },
108
121
  logo: i.logo ?? webLogo,
122
+ short: { font: shortFont, light: sh.light ?? light, edge: sh.edge ?? edge, accent: sh.accent ?? accent },
109
123
  }
110
124
  }
125
+
126
+ /** 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 })