rvlib-shipkit 2.2.0 → 2.3.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 (107) hide show
  1. package/CHANGELOG.md +38 -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/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -1,7 +1,7 @@
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 { 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
7
  import type { TitleImage } from './titles.ts'
@@ -32,8 +32,29 @@ const XFADE: Record<Exclude<CutKind, 'hard'>, string> = {
32
32
  pixel: 'pixelize',
33
33
  }
34
34
 
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 }
35
+ /** a grid's or a split's cell: a shot's or a footage block's take, from its `from`, cropped by its `frame` */
36
+ export interface GridCell {
37
+ footage: string
38
+ seconds: number
39
+ from: number
40
+ frame: number | null
41
+ /** where a grid's cell crops, each 0 to 1 across and down its take; absent, its middle */
42
+ at?: readonly [number, number]
43
+ }
44
+
45
+ /** a split's drawn pieces: each pane's shape (white on black) and the seams laid over them (stitch.ts splitMasks) */
46
+ export interface SplitMasks {
47
+ panes: string[]
48
+ seams: string
49
+ }
50
+
51
+ /** 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
52
+ * other takes laid under its frame (stitch.ts gridMask, splitMasks) */
53
+ export type Source =
54
+ | { footage: string; seconds: number }
55
+ | { image: string }
56
+ | { grid: GridCell[]; pop: number; grow?: [number, number]; mask: string }
57
+ | { split: GridCell[]; angle: number; slide: number; masks: SplitMasks }
37
58
 
38
59
  export interface StitchInput {
39
60
  t: Trailer
@@ -50,6 +71,81 @@ export interface StitchInput {
50
71
  }
51
72
 
52
73
  const n3 = (x: number): string => (+x.toFixed(4)).toString()
74
+ const even = (x: number): number => 2 * Math.round(x / 2)
75
+
76
+ /** the grid's margin round the cells and the gap between them, and the corners' radius, in frame heights */
77
+ export const GRID_MARGIN = 0.03
78
+ export const GRID_GAP = 0.014
79
+ export const GRID_RADIUS = 0.022
80
+ /** a cell's pop: its length, seconds, and the overshoot of its ease */
81
+ const POP_S = 0.22
82
+ const POP_BACK = 1.70158
83
+ /** a split's pane j slides in this long after pane j - 1 */
84
+ const SPLIT_STAGGER = 0.08
85
+ /** how much bigger a pane's take is drawn than the frame, so its middle can move to the pane's middle */
86
+ export const SPLIT_ZOOM = 1.3
87
+ /** a growing grid is laid this many times the frame's size, so the one cell it opens on fills the frame sharp */
88
+ export const GROW_RES = 3
89
+
90
+ /** 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 */
91
+ export function gridLayout(n: number, W: number, H: number): { x: number; y: number; w: number; h: number }[] {
92
+ const cols = n <= 1 ? 1 : n <= 4 ? 2 : 3
93
+ const rows = Math.max(1, Math.ceil(n / cols))
94
+ const m = H * GRID_MARGIN
95
+ const g = H * GRID_GAP
96
+ const cw = (W - 2 * m - (cols - 1) * g) / cols
97
+ const ch = (H - 2 * m - (rows - 1) * g) / rows
98
+ return Array.from({ length: n }, (_, i) => {
99
+ const [c, r] = [i % cols, Math.floor(i / cols)]
100
+ const x = even(m + c * (cw + g))
101
+ const y = even(m + r * (ch + g))
102
+ return { x, y, w: even(m + c * (cw + g) + cw) - x, h: even(m + r * (ch + g) + ch) - y }
103
+ })
104
+ }
105
+
106
+ /** a split's pane j of `n`: the frame cut by seams through (W i / n, H / 2), each leaning `angle` degrees from
107
+ * upright, its top toward the right (two panes at 56: top left and bottom right). Its outline (it may run past the
108
+ * frame, which clips it), and its middle, where its take centres */
109
+ export function splitPane(n: number, j: number, angle: number, W: number, H: number): { points: [number, number][]; cx: number; cy: number } {
110
+ const t = Math.tan((angle * Math.PI) / 180)
111
+ const far = W + H * (1 + t)
112
+ const seam = (i: number, y: number): number => (i <= 0 ? -far : i >= n ? W + far : (W * i) / n + (H / 2 - y) * t)
113
+ const points: [number, number][] = [
114
+ [seam(j, 0), 0],
115
+ [seam(j + 1, 0), 0],
116
+ [seam(j + 1, H), H],
117
+ [seam(j, H), H],
118
+ ]
119
+ // its middle: the mean of a fine grid's points inside it
120
+ let [sx, sy, k] = [0, 0, 0]
121
+ for (let gy = 0.5; gy < 36; gy++)
122
+ for (let gx = 0.5; gx < 64; gx++) {
123
+ const [x, y] = [(gx * W) / 64, (gy * H) / 36]
124
+ if (x >= seam(j, y) && x < seam(j + 1, y)) [sx, sy, k] = [sx + x, sy + y, k + 1]
125
+ }
126
+ return { points, cx: k ? sx / k : W / 2, cy: k ? sy / k : H / 2 }
127
+ }
128
+
129
+ /** 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 */
130
+ function growZoom(cells: { x: number; y: number; w: number; h: number }[], grow: [number, number], W: number, H: number): { z: string; x: string; y: string } {
131
+ const c0 = cells[0]!
132
+ const c4 = cells[4]!
133
+ const pad = cells[1]!.x - (c0.x + c0.w)
134
+ const fit = (x0: number, y0: number, x1: number, y1: number): [number, number, number] => {
135
+ // the frame's aspect round the rectangle, a little margin, centred on it
136
+ const w = Math.max(x1 - x0 + pad, ((y1 - y0 + pad) * W) / H)
137
+ return [(x0 + x1) / 2, (y0 + y1) / 2, w]
138
+ }
139
+ const one = fit(c0.x, c0.y, c0.x + c0.w, c0.y + c0.h)
140
+ const four = fit(c0.x, c0.y, c4.x + c4.w, c4.y + c4.h)
141
+ const all: [number, number, number] = [W / 2, H / 2, W]
142
+ // each move eases in and out over GROW_S, ending on its settle
143
+ const ease = (end: number): string => `(1-cos(PI*clip((it-${n3(end - GROW_S)})/${GROW_S},0,1)))/2`
144
+ const [a, b] = [ease(grow[0]), ease(grow[1])]
145
+ const lerp = (k: 0 | 1 | 2): string => `(${n3(one[k])}+(${n3(four[k] - one[k])})*${a}+(${n3(all[k] - four[k])})*${b})`
146
+ const w = lerp(2)
147
+ return { z: `${W}/${w}`, x: `${lerp(0)}-${w}/2`, y: `${lerp(1)}-${w}*${n3(H / W)}/2` }
148
+ }
53
149
  /** ffmpeg's colour syntax for a hex colour */
54
150
  const ff = (c: string): string => (c.startsWith('#') ? `0x${c.slice(1)}` : c)
55
151
 
@@ -143,7 +239,74 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
143
239
  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
240
  const frameF = cropF ? `,${cropF},scale=${W}:${H}` : ''
145
241
  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) {
242
+ if ('grid' in src) {
243
+ // the cells, each its take from its own `from`, cropped to fill its place and held on its last frame, laid on
244
+ // the slots' ground under the grid's frame; cell j pops in at j times `pop`, its take starting then. Then the
245
+ // shot's own frame, push and shakes as any footage
246
+ const G = src.grow ? GROW_RES : 1
247
+ const cells = gridLayout(src.grid.length, W * G, H * G)
248
+ 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)}`)
249
+ graph.push(`[${ground}:v]format=yuv420p[g${i}_0]`)
250
+ src.grid.forEach((c, j) => {
251
+ const at = cells[j]!
252
+ const T0 = Math.min(len, j * src.pop)
253
+ const run = len - T0
254
+ const avail = Math.max(0, c.seconds - c.from)
255
+ const k = input(...DECODE, '-ss', n3(c.from), '-t', n3(Math.min(avail, run) + 0.1), '-i', c.footage)
256
+ const crop = !c.frame || c.frame <= 1 ? '' : c.at
257
+ ? `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)',`
258
+ : `crop=iw/${n3(c.frame)}:ih/${n3(c.frame)},`
259
+ const fit = `${crop}scale=${at.w}:${at.h}:force_original_aspect_ratio=increase:${FULL_601},crop=${at.w}:${at.h},setsar=1`
260
+ if (src.pop <= 0) {
261
+ 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}]`)
262
+ graph.push(`[g${i}_${j}][c${i}_${j}]overlay=x=${at.x}:y=${at.y}:eof_action=repeat[g${i}_${j + 1}]`)
263
+ return
264
+ }
265
+ // the pop: grown from 55 % to a hair past whole and back (an ease out with overshoot), faded in
266
+ const x = `clip((t-${n3(T0)})/${POP_S},0,1)`
267
+ const K = `(0.55+0.45*(1+${n3(POP_BACK + 1)}*pow(${x}-1,3)+${POP_BACK}*pow(${x}-1,2)))`
268
+ graph.push(
269
+ `[${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,` +
270
+ `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}]`,
271
+ )
272
+ 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}]`)
273
+ })
274
+ const m = input('-loop', '1', '-framerate', String(fps), '-t', n3(len + 0.1), '-i', src.mask)
275
+ // 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
276
+ const zoom = src.grow ? growZoom(cells, src.grow, W * G, H * G) : null
277
+ const grow = zoom ? `,zoompan=z='${zoom.z}':x='${zoom.x}':y='${zoom.y}':d=1:s=${W}x${H}:fps=${fps}` : ''
278
+ 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}`)
279
+ } else if ('split' in src) {
280
+ // each pane its take drawn a little bigger and centred on the pane's middle, cut to its shape, sliding in from
281
+ // its side with pane j a beat after pane j - 1; the seams laid over all; the ground where no pane shows yet
282
+ const ground = input('-f', 'lavfi', '-i', `color=c=${ff(a.look.edge)}:s=${W}x${H}:r=${fps}:d=${n3(len + 0.1)}`)
283
+ graph.push(`[${ground}:v]format=yuv420p[p${i}_0]`)
284
+ const [BW, BH] = [even(W * SPLIT_ZOOM), even(H * SPLIT_ZOOM)]
285
+ src.split.forEach((c, j) => {
286
+ const pane = splitPane(src.split.length, j, src.angle, W, H)
287
+ const ox = Math.round(Math.min(BW - W, Math.max(0, BW / 2 - pane.cx)))
288
+ const oy = Math.round(Math.min(BH - H, Math.max(0, BH / 2 - pane.cy)))
289
+ const avail = Math.max(0, c.seconds - c.from)
290
+ const k = input(...DECODE, '-ss', n3(c.from), '-t', n3(Math.min(avail, len) + 0.1), '-i', c.footage)
291
+ const m = input('-loop', '1', '-framerate', String(fps), '-t', n3(len + 0.1), '-i', src.masks.panes[j]!)
292
+ const crop = c.frame && c.frame > 1 ? `crop=iw/${n3(c.frame)}:ih/${n3(c.frame)},` : ''
293
+ graph.push(
294
+ `[${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}]`,
295
+ `[${m}:v]format=gray,scale=${W}:${H}[pm${i}_${j}]`,
296
+ `[pf${i}_${j}][pm${i}_${j}]alphamerge[pa${i}_${j}]`,
297
+ )
298
+ // it slides in from the side its middle is on, eased out, from a pane's width away
299
+ const T0 = j * SPLIT_STAGGER
300
+ const side = pane.cx < W / 2 - 1 ? -1 : pane.cx > W / 2 + 1 ? 1 : 0
301
+ const x = src.slide > 0 ? `clip((t-${n3(T0)})/${n3(src.slide)},0,1)` : '1'
302
+ const ease = `(1-pow(1-${x},3))`
303
+ const dx = side !== 0 ? `${n3(side * W * 0.7)}*(1-${ease})` : '0'
304
+ const dy = side === 0 ? `${n3(H * 0.8)}*(1-${ease})` : '0'
305
+ graph.push(`[p${i}_${j}][pa${i}_${j}]overlay=x='${dx}':y='${dy}':eval=frame:format=auto:eof_action=repeat[p${i}_${j + 1}]`)
306
+ })
307
+ const sm = input('-loop', '1', '-framerate', String(fps), '-t', n3(len + 0.1), '-i', src.masks.seams)
308
+ 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}`)
309
+ } else if ('image' in src) {
147
310
  // read at the cut's rate: a still read at the demuxer's 25 fps breaks the xfade of a cut into it
148
311
  const k = input('-framerate', String(fps), '-i', src.image)
149
312
  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}`)
@@ -215,10 +378,19 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
215
378
  s.titles.forEach((ti, j) => {
216
379
  const at = st[i]! + ti.at
217
380
  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)]
381
+ // a progress step ends the frame before the next one starts: two steps never stand on one frame
382
+ const handOff = ti.style === 'progress' && ti.step !== undefined && ti.step.i < ti.step.n ? 1 : 0
383
+ const [ta, te] = [frameAt(at, fps), Math.floor((at + dur) * fps + 1e-6) - handOff]
219
384
  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)
385
+ // a tagline and the logo breathe in slowly; the plates pop; a short's words come quick
386
+ const short = ti.style === 'short' || ti.style === 'progress'
387
+ const f = Math.min(ti.style === 'line' || ti.style === 'logo' ? 0.7 : short ? 0.15 : 0.25, dur / 3)
388
+ // the steps of a progress swap on their beat: the first alone fades in, the last alone fades out
389
+ const step = ti.style === 'progress' ? ti.step : undefined
390
+ const fadeIn = step === undefined || step.i === 1
391
+ const fadeOut = step === undefined || step.i === step.n
392
+ // a short's caption on the cut's first frame is there whole: frame 0 is the cover the platforms take
393
+ const cover = ti.style === 'short' && ta === 0
222
394
  const img = a.titles[i]![j]!
223
395
  const k = input('-i', img.file)
224
396
  const [from, to] = [Math.max(ta, base), Math.min(te, p.to - 1)]
@@ -226,14 +398,16 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
226
398
  const hyp = kn === null ? 0 : Math.ceil(Math.hypot(img.w, img.h))
227
399
  const tip = kn === null ? '' : `rotate=a='-${KNOCK_TURN}*pow(max(0,t-${n3(kn)})/${KNOCK_S},2)':ow=${hyp}:oh=${hyp}:c=none,`
228
400
  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,` : ''}` +
401
+ `[${k}:v]format=rgba,loop=loop=${to - ta}:size=1:start=0,setpts=N/${fps}/TB,${tip}${cover || !fadeIn ? '' : `fade=t=in:st=0:d=${n3(f)}:alpha=1,`}${fadeOut && kn === null ? `fade=t=out:st=${n3(dur - f)}:d=${n3(f)}:alpha=1,` : ''}` +
230
402
  `trim=start_frame=${from - ta},settb=1/${fps},setpts=N+${from - base}[t${i}_${j}]`,
231
403
  )
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)`
404
+ // it rises into place over its fade in, eased, by a fortieth of the frame; counted in frames. A short's
405
+ // caption slides in from the left instead, eased out over 0.15 s
406
+ const rise = cover || short ? '0' : `${n3(H / 40)}*pow(max(0,1-(n-${n3(at * fps - base)})/${n3(Math.max(f, 0.05) * fps)}),2)`
407
+ const slide = cover || ti.style !== 'short' ? '' : `-${n3(H / 32)}*pow(max(0,1-(n-${n3(at * fps - base)})/${n3(0.15 * fps)}),3)`
234
408
  const on = `between(t,${n3((ta - base - 0.5) / fps)},${n3((te - base + 0.5) / fps)})`
235
409
  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}`
410
+ const x = kn === null ? `${img.x}${slide}` : `${img.x - (hyp - img.w) / 2}-${n3(W * 1.1)}*${flown}`
237
411
  const y = kn === null ? `${img.y}+${rise}` : `${img.y - (hyp - img.h) / 2}+${rise}-${n3(H * 0.45)}*${flown}`
238
412
  graph.push(`[${cur}][t${i}_${j}]overlay=x='${x}':y='${y}':eof_action=pass:enable='${on}'[v${i}_${j}]`)
239
413
  cur = `v${i}_${j}`
@@ -259,9 +433,17 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
259
433
  ]
260
434
  }
261
435
 
436
+ /** the true peak the mix is held under, dBTP */
437
+ export const MIX_TRUE_PEAK = -1
438
+ /** what the limiter aims at below MIX_TRUE_PEAK: the aac encode after it overshoots by a few tenths */
439
+ const LIMIT_MARGIN_DB = 0.5
440
+ /** the limiter runs 4 times oversampled, so it sees the peaks between samples a player's converter makes */
441
+ const OVERSAMPLE = 4
442
+
262
443
  /** 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[] {
444
+ * and the voices on their times, the sum through a true peak limiter held under MIX_TRUE_PEAK. `trimDb` lowers the
445
+ * limited sum before the encode, for a mix whose aac encode still overshoots (stitch.ts) */
446
+ export function mixArgs(a: Pick<StitchInput, 't' | 'music' | 'sfx' | 'voices'>, out: string, trimDb = 0): string[] {
265
447
  const { t } = a
266
448
  const T = total(t)
267
449
  const st = starts(t)
@@ -318,6 +500,9 @@ export function mixArgs(a: Pick<StitchInput, 't' | 'music' | 'sfx' | 'voices'>,
318
500
  })
319
501
  const silence = input('-f', 'lavfi', '-t', n3(T), '-i', 'anullsrc=r=48000:cl=stereo')
320
502
  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]`)
503
+ // level=disabled: alimiter's default auto level lifts the output back to 0 dBFS, past the limit
504
+ const limit = n3(10 ** ((MIX_TRUE_PEAK - LIMIT_MARGIN_DB) / 20))
505
+ const limiter = `aresample=${48000 * OVERSAMPLE},alimiter=limit=${limit}:attack=1:release=60:level=disabled,aresample=48000${trimDb < 0 ? `,volume=${n3(trimDb)}dB` : ''}`
506
+ graph.push(`${mix.join('')}amix=inputs=${mix.length}:normalize=0:duration=longest,atrim=duration=${n3(T)},${limiter}[aout]`)
322
507
  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
508
  }
@@ -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,6 +18,8 @@ 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
23
25
  }
@@ -44,6 +46,8 @@ export interface TrailerLook {
44
46
  chip: { fill: string; ink: string }
45
47
  font: { file: string | null; family: string; weight: number }
46
48
  logo: string | null
49
+ /** the short's words: their face and colours */
50
+ short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string }
47
51
  }
48
52
 
49
53
  export interface BrandLike {
@@ -97,14 +101,24 @@ export function lookOf(brand: BrandLike | null, webLogo: string | null = null):
97
101
  const p = brand?.palette ?? HOUSE_PALETTE
98
102
  const i = brand?.trailer ?? {}
99
103
  const ground: [string, string] = i.ground ?? [p.bg, darker(p.bg, luminance(p.bg) > 0.5 ? 0.25 : 0.45)]
104
+ const font = { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 }
105
+ const light = i.light ?? (luminance(p.ink) > 0.45 ? p.ink : '#ffffff')
106
+ const edge = i.edge ?? (luminance(ground[1]) < 0.2 ? ground[1] : darker(p.bg, 0.8))
107
+ const accent = i.accent ?? p.accent
108
+ const sh = i.short ?? {}
109
+ 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
110
  return {
101
111
  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,
112
+ light,
113
+ edge,
114
+ accent,
105
115
  plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink },
106
116
  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
117
  font: { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 },
108
118
  logo: i.logo ?? webLogo,
119
+ short: { font: shortFont, light: sh.light ?? light, edge: sh.edge ?? edge, accent: sh.accent ?? accent },
109
120
  }
110
121
  }
122
+
123
+ /** the look a short's words wear: the look with the short's face and colours in place */
124
+ export const shortLook = (look: TrailerLook): TrailerLook => ({ ...look, font: look.short.font, light: look.short.light, edge: look.short.edge, accent: look.short.accent })
@@ -0,0 +1,129 @@
1
+ // the verbs over a built cut (wiki build/trailer.md, The verbs and Versions): each section's levels, a passage clipped
2
+ // with its sound (and the same passage of a kept version), a version kept, the take file of a shot
3
+ import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import { basename, join, relative } from 'node:path'
5
+ import { type Loaded, load, Refused, type Say, takesOfLoaded } from './build.ts'
6
+ import { ffmpeg } from './ffmpeg.ts'
7
+ import { starts, type Trailer } from './grammar.ts'
8
+
9
+ const outOf = (l: Pick<Loaded, 'dir'>): string => join(l.dir, 'out')
10
+
11
+ /** the built video of a cut, refused when the build never ran */
12
+ function builtOf(l: Pick<Loaded, 'dir' | 'file' | 't'>, cwd: string): string {
13
+ const f = join(outOf(l), `${l.t.name}.mp4`)
14
+ if (!existsSync(f)) throw new Refused(`no ${relative(cwd, f)}: sk trailer ${relative(cwd, l.file)} first`)
15
+ return f
16
+ }
17
+
18
+ /** a moment of the cut on the video clock: a section or a mark, a shot, or seconds; `end` takes its end */
19
+ export function momentOf(t: Pick<Trailer, 'sections' | 'timelineAt' | 'shots'>, name: string, end: boolean): number {
20
+ if (/^\d+(\.\d+)?$/.test(name)) return Number(name)
21
+ if (t.timelineAt !== null) {
22
+ const sec = t.sections.find((s) => s.name === name)
23
+ if (sec) return t.timelineAt + sec.at + (end ? sec.seconds : 0)
24
+ for (const s of t.sections) {
25
+ const m = s.marks.find((x) => x.name === name)
26
+ if (m) return t.timelineAt + s.at + m.at
27
+ }
28
+ }
29
+ const i = t.shots.findIndex((s) => s.name === name)
30
+ if (i >= 0) return starts(t)[i]! + (end ? t.shots[i]!.length : 0)
31
+ throw new Refused(`${name}: no section, mark or shot of that name (or write seconds)`)
32
+ }
33
+
34
+ /** each section's loudness in the built cut, and its step from the section before */
35
+ export async function levels(l: Loaded, say: Say, cwd: string): Promise<void> {
36
+ const video = builtOf(l, cwd)
37
+ const at0 = l.t.timelineAt
38
+ if (l.t.sections.length === 0 || at0 === null) throw new Refused('levels: this cut has no section')
39
+ const rows = await Promise.all(
40
+ l.t.sections.map(async (s) => {
41
+ const err = await ffmpeg(['-loglevel', 'info', '-ss', String(at0 + s.at), '-t', String(s.seconds), '-i', video, '-vn', '-af', 'volumedetect', '-f', 'null', '-'])
42
+ const mean = Number(/mean_volume: (-?[\d.]+)/.exec(err)?.[1] ?? Number.NaN)
43
+ const max = Number(/max_volume: (-?[\d.]+)/.exec(err)?.[1] ?? Number.NaN)
44
+ return { s, mean, max }
45
+ }),
46
+ )
47
+ say(`${relative(cwd, video)}: each section's mean and peak, dB (video clock)`)
48
+ rows.forEach((r, i) => {
49
+ const prev = rows[i - 1]
50
+ const step = prev ? `${r.mean - prev.mean >= 0 ? '+' : ''}${(r.mean - prev.mean).toFixed(1)}` : ''
51
+ say(` ${r.s.name.padEnd(12)} ${(at0 + r.s.at).toFixed(1).padStart(6)} s ${r.s.seconds.toFixed(1).padStart(5)} s mean ${r.mean.toFixed(1).padStart(6)} peak ${r.max.toFixed(1).padStart(5)} ${step.padStart(5)}`)
52
+ })
53
+ }
54
+
55
+ /** a kept version's video: the one mp4 of `versions/<v>/` */
56
+ export function versionVideo(dir: string, v: string): string | null {
57
+ const d = join(dir, 'versions', v)
58
+ if (!existsSync(d)) return null
59
+ const f = readdirSync(d).find((x) => x.endsWith('.mp4'))
60
+ return f ? join(d, f) : null
61
+ }
62
+
63
+ /** a version's cut text read as today's tool reads it, with the cut's own recipes, and its video */
64
+ async function versionCut(l: Loaded, v: string, cwd: string): Promise<{ t: Trailer; video: string }> {
65
+ const text = join(l.dir, 'versions', `${v}.trailer`)
66
+ if (!existsSync(text)) throw new Refused(`no ${relative(cwd, text)}`)
67
+ const kept = await load(text, l.root, { dir: l.dir, check: false })
68
+ if (kept.errors.length > 0) throw new Refused(`${v} does not read with today's tool (an older version builds only in a worktree at its tag): ${kept.errors[0]!.message}`)
69
+ const video = versionVideo(l.dir, v) ?? join(l.dir, 'versions', v, `${basename(l.dir)}-${v}.mp4`)
70
+ if (!existsSync(video)) throw new Refused(`no ${relative(cwd, video)}`)
71
+ return { t: kept.t, video }
72
+ }
73
+
74
+ /** the passage from `from` to the end of `to` as its own mp4, sound included; with a version, the same passage of it too */
75
+ export async function clip(l: Loaded, from: string, to: string, pad: number, version: string | undefined, say: Say, cwd: string): Promise<void> {
76
+ const cut = async (t: Trailer, video: string, out: string): Promise<void> => {
77
+ const a = Math.max(0, momentOf(t, from, false) - pad)
78
+ const b = momentOf(t, to, true) + pad
79
+ if (b <= a) throw new Refused(`clip: ${to} ends before ${from} starts`)
80
+ await ffmpeg(['-ss', String(a), '-to', String(b), '-i', video, '-c:v', 'libx264', '-crf', '18', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', out], { nice: 10 })
81
+ say(`ok: ${relative(cwd, out)} (${a.toFixed(2)} to ${b.toFixed(2)} s)`)
82
+ }
83
+ const tag = from === to ? from : `${from}-${to}`
84
+ await cut(l.t, builtOf(l, cwd), join(outOf(l), `clip-${tag}.mp4`))
85
+ if (version) {
86
+ const v = await versionCut(l, version, cwd)
87
+ await cut(v.t, v.video, join(outOf(l), `clip-${tag}-${version}.mp4`))
88
+ }
89
+ }
90
+
91
+ /** keep the built cut as a version: its text, its video, its strip and each shot's take, cloned so they cost nothing */
92
+ export function keep(l: Loaded, v: string, say: Say, cwd: string): void {
93
+ if (!/^v\d+$/.test(v)) throw new Refused(`keep: a version is v<n>, not ${v}`)
94
+ const text = join(l.dir, 'versions', `${v}.trailer`)
95
+ const dir = join(l.dir, 'versions', v)
96
+ if (existsSync(text) || existsSync(dir)) throw new Refused(`${v} is kept already: versions are never overwritten`)
97
+ const video = builtOf(l, cwd)
98
+ mkdirSync(join(dir, 'shots'), { recursive: true })
99
+ writeFileSync(text, readFileSync(l.file, 'utf8'))
100
+ // a build rewrites its files in place: a link would turn the version into the next build, a clone costs nothing on APFS
101
+ const clone = (a: string, b: string): void => copyFileSync(a, b, constants.COPYFILE_FICLONE)
102
+ clone(video, join(dir, `${basename(l.dir)}-${v}.mp4`))
103
+ const strip = join(outOf(l), `${l.t.name}.strip.png`)
104
+ if (existsSync(strip)) clone(strip, join(dir, `${l.t.name}.strip.png`))
105
+ const shots = join(outOf(l), `${l.t.name}-shots`)
106
+ const files = existsSync(shots) ? readdirSync(shots) : []
107
+ for (const f of files) clone(join(shots, f), join(dir, 'shots', f))
108
+ // the clones are the machine's: git keeps the text and the index, never a version's folder
109
+ const ignored = Bun.spawnSync(['git', 'check-ignore', '-q', dir], { cwd: l.root }).exitCode === 0
110
+ const ignore = join(l.dir, 'versions', '.gitignore')
111
+ if (!ignored && !existsSync(ignore)) writeFileSync(ignore, '*/\n')
112
+ say(`ok: kept ${v}: ${relative(cwd, text)}, ${relative(cwd, dir)}/ (the video, the strip, ${files.length} shots)`)
113
+ say(`then: its row in ${relative(cwd, join(l.dir, 'versions', 'index.md'))}, commit ${relative(cwd, text)} and that index, git tag trailer-${basename(l.dir)}-${v}, push the commit and the tag`)
114
+ }
115
+
116
+ /** the take file of each named shot or footage block */
117
+ export function footage(l: Loaded, names: string[], say: Say, cwd: string): void {
118
+ const takes = takesOfLoaded(l)
119
+ for (const name of names) {
120
+ const s = [...l.t.shots, ...l.t.footage].find((x) => x.name === name)
121
+ if (!s) throw new Refused(`${name}: no shot or footage block of that name`)
122
+ const tk = takes.find((x) => x.shot === s)
123
+ if (!tk) {
124
+ say(`${name} no take: its film is ${s.film?.kind ?? 'nothing'}`)
125
+ continue
126
+ }
127
+ say(`${name} ${relative(cwd, tk.file)}${existsSync(tk.file) ? '' : ' (not filmed)'}`)
128
+ }
129
+ }