rvlib-shipkit 2.1.1 → 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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -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 +7 -3
  12. package/src/assets/run-assets.ts +18 -4
  13. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  14. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  15. package/src/cli/cmd_Calls.ts +53 -0
  16. package/src/cli/cmd_Commands.ts +163 -43
  17. package/src/cli/cmd_Review.ts +948 -0
  18. package/src/cli/cmd_Shot.ts +63 -0
  19. package/src/cli/cmd_Start.ts +8 -4
  20. package/src/cli/cmd_Time.ts +112 -0
  21. package/src/cli/cmd_Trailer.ts +361 -0
  22. package/src/cli/kit/builtins.ts +40 -0
  23. package/src/cli/kit/calls.ts +177 -0
  24. package/src/cli/kit/families.ts +67 -0
  25. package/src/cli/kit/index.ts +8 -0
  26. package/src/cli/kit/pages-check.ts +36 -0
  27. package/src/cli/kit/project-cli.ts +73 -0
  28. package/src/cli/kit/verb.ts +167 -0
  29. package/src/cli.ts +15 -2
  30. package/src/collections/collection-types.ts +6 -1
  31. package/src/collections/providers.ts +3 -0
  32. package/src/collections/review.ts +70 -0
  33. package/src/core/layout.ts +12 -2
  34. package/src/core/migrate-layout.ts +31 -8
  35. package/src/core/one-way-in-policy.ts +12 -4
  36. package/src/reflect/prompt-corpus.ts +15 -114
  37. package/src/reflect/transcript.ts +254 -0
  38. package/src/review/anim.ts +228 -0
  39. package/src/review/auto.ts +141 -0
  40. package/src/review/format.ts +150 -0
  41. package/src/review/http.ts +23 -0
  42. package/src/review/marks.ts +43 -0
  43. package/src/review/media.ts +45 -0
  44. package/src/review/model.ts +854 -0
  45. package/src/review/project.ts +110 -0
  46. package/src/review/role.ts +48 -0
  47. package/src/review/routes.ts +228 -0
  48. package/src/review/store.ts +1002 -0
  49. package/src/review/threads.ts +237 -0
  50. package/src/review/trailer-desk.ts +385 -0
  51. package/src/review/trailer-model.ts +183 -0
  52. package/src/review/trailer-routes.ts +136 -0
  53. package/src/review/unit.ts +165 -0
  54. package/src/review/watchers.ts +54 -0
  55. package/src/scaffold/cli-skeleton.ts +149 -0
  56. package/src/scaffold/files/agent-index.md +2 -2
  57. package/src/scaffold/files/tsconfig.json +3 -0
  58. package/src/scaffold/files/verify-skill.md +1 -1
  59. package/src/scaffold/project-scaffold.ts +90 -12
  60. package/src/serve/embed.ts +35 -0
  61. package/src/serve/hub-app/activity-view.tsx +1 -1
  62. package/src/serve/hub-app/activity.css +0 -4
  63. package/src/serve/hub-app/dock-view.tsx +5 -4
  64. package/src/serve/hub-app/hash-route.ts +28 -1
  65. package/src/serve/hub-app/header-bar.tsx +3 -1
  66. package/src/serve/hub-app/home-st.ts +10 -2
  67. package/src/serve/hub-app/home-strip.tsx +3 -2
  68. package/src/serve/hub-app/home.html +1 -0
  69. package/src/serve/hub-app/hub-app.tsx +80 -11
  70. package/src/serve/hub-app/hub.css +45 -13
  71. package/src/serve/hub-app/icons.tsx +9 -0
  72. package/src/serve/hub-app/index.html +1 -0
  73. package/src/serve/hub-app/insights-view.tsx +4 -3
  74. package/src/serve/hub-app/keys.ts +14 -10
  75. package/src/serve/hub-app/layout-st.ts +63 -2
  76. package/src/serve/hub-app/planned-view.tsx +3 -2
  77. package/src/serve/hub-app/planned.css +0 -1
  78. package/src/serve/hub-app/project-list.css +22 -4
  79. package/src/serve/hub-app/project-list.tsx +41 -8
  80. package/src/serve/hub-app/projects-svc.ts +4 -0
  81. package/src/serve/hub-app/repo-sections.ts +15 -3
  82. package/src/serve/hub-app/repo-st.ts +45 -3
  83. package/src/serve/hub-app/review/clip-st.ts +208 -0
  84. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  85. package/src/serve/hub-app/review/kit.tsx +113 -0
  86. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  87. package/src/serve/hub-app/review/review-board.tsx +46 -0
  88. package/src/serve/hub-app/review/review-list.tsx +156 -0
  89. package/src/serve/hub-app/review/review-page.tsx +101 -0
  90. package/src/serve/hub-app/review/review-st.ts +779 -0
  91. package/src/serve/hub-app/review/review-views.tsx +503 -0
  92. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  93. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  94. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  95. package/src/serve/hub-app/review.css +329 -0
  96. package/src/serve/hub-app/theme-st.ts +56 -0
  97. package/src/serve/hub-app/tree-build.ts +4 -0
  98. package/src/serve/hub-app/tree-row.tsx +1 -1
  99. package/src/serve/hub-app/tree-svc.ts +4 -0
  100. package/src/serve/hub-runtime.ts +5 -3
  101. package/src/serve/same-origin.ts +5 -1
  102. package/src/serve/serve-hub.ts +8 -0
  103. package/src/shipkit-files.ts +12 -4
  104. package/src/shot/client.ts +67 -0
  105. package/src/shot/clip-encoder.ts +146 -0
  106. package/src/shot/dom-clip.ts +124 -0
  107. package/src/shot/serve-shot.ts +54 -0
  108. package/src/shot/shooter.ts +343 -0
  109. package/src/shot/shot-ask.ts +189 -0
  110. package/src/shot/shot.shipkit.ts +14 -0
  111. package/src/smoke/run-smoke.ts +2 -2
  112. package/src/start-repo.ts +29 -14
  113. package/src/suite/judge-suites.ts +56 -10
  114. package/src/suite/select.ts +2 -5
  115. package/src/suite/suite-cli.ts +3 -2
  116. package/src/trailer/adapter.ts +78 -0
  117. package/src/trailer/build.ts +603 -0
  118. package/src/trailer/ffmpeg.ts +72 -0
  119. package/src/trailer/footage.ts +173 -0
  120. package/src/trailer/grammar.ts +1290 -0
  121. package/src/trailer/graph.ts +508 -0
  122. package/src/trailer/holes.ts +72 -0
  123. package/src/trailer/load-unit.ts +31 -0
  124. package/src/trailer/look.ts +124 -0
  125. package/src/trailer/passage.ts +129 -0
  126. package/src/trailer/plan.ts +192 -0
  127. package/src/trailer/prune.ts +193 -0
  128. package/src/trailer/score/compose.ts +240 -0
  129. package/src/trailer/score/dsp.ts +272 -0
  130. package/src/trailer/score/palette.ts +188 -0
  131. package/src/trailer/score/pattern.ts +91 -0
  132. package/src/trailer/score/score.ts +214 -0
  133. package/src/trailer/score/sound.ts +112 -0
  134. package/src/trailer/score/synth.ts +361 -0
  135. package/src/trailer/states.ts +76 -0
  136. package/src/trailer/stitch.ts +233 -0
  137. package/src/trailer/titles.ts +379 -0
  138. package/src/trailer/url.ts +108 -0
  139. package/src/trailer/vertical-draw.ts +286 -0
  140. package/src/trailer/vertical.ts +148 -0
  141. package/src/trailer/vocab.ts +59 -0
  142. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,508 @@
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
+ // titles laid over) and the mix of the sound. Pure: stitch.ts runs them, keyed on these very arguments
3
+ import type { CutKind, Shot, Trailer } from './grammar.ts'
4
+ import { GROW_S, musicCues, starts, total } from './grammar.ts'
5
+ import type { TrailerLook } from './look.ts'
6
+ import { frameAt, frameSpans, type Piece } from './plan.ts'
7
+ import type { TitleImage } from './titles.ts'
8
+
9
+ /** a wipe's edge, as xfade draws it: along x or y, starting from the far side (`from`) or the near one */
10
+ const SEAM_EDGE: Partial<Record<CutKind, { axis: 'x' | 'y'; from: boolean }>> = {
11
+ 'wipe-left': { axis: 'x', from: true },
12
+ 'wipe-right': { axis: 'x', from: false },
13
+ 'wipe-up': { axis: 'y', from: true },
14
+ 'wipe-down': { axis: 'y', from: false },
15
+ }
16
+
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',
33
+ }
34
+
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 }
58
+
59
+ export interface StitchInput {
60
+ t: Trailer
61
+ sources: Source[]
62
+ /** each title's PNG, shot by shot, in the order of its titles */
63
+ titles: TitleImage[][]
64
+ /** each music cue's file, in the order of musicCues */
65
+ music: string[]
66
+ /** each sfx's file and length, shot by shot */
67
+ sfx: { file: string; seconds: number }[][]
68
+ /** each voice line's file and its length, shot by shot */
69
+ voices: { file: string; seconds: number }[][]
70
+ look: TrailerLook
71
+ }
72
+
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
+ }
149
+ /** ffmpeg's colour syntax for a hex colour */
150
+ const ff = (c: string): string => (c.startsWith('#') ? `0x${c.slice(1)}` : c)
151
+
152
+ const MAC = process.platform === 'darwin'
153
+ const X264 = ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-pix_fmt', 'yuv420p']
154
+ /** how a piece is encoded: `draft` the Mac's media engine, `final` x264 for a release, `lossless` for parity tests */
155
+ export const ENCODERS = {
156
+ draft: MAC ? ['-c:v', 'h264_videotoolbox', '-q:v', '70', '-pix_fmt', 'yuv420p'] : X264,
157
+ final: X264,
158
+ lossless: ['-c:v', 'libx264', '-preset', 'ultrafast', '-qp', '0', '-pix_fmt', 'yuv420p'],
159
+ } as const
160
+ export type Encoder = keyof typeof ENCODERS
161
+ /** the footage decoded by the media engine: the same frames, at a third of the CPU */
162
+ const DECODE = MAC ? ['-hwaccel', 'videotoolbox'] : []
163
+ /** every shot's frames tagged alike: no join converts one shot to match another, and the joined pieces share one header */
164
+ const TAGS = 'setparams=range=pc:color_primaries=unknown:color_trc=unknown:colorspace=unknown'
165
+ /** a footage's frames made full range BT.601, the matrix of the cards and stills */
166
+ const FULL_601 = 'out_range=pc:out_color_matrix=bt601:flags=bicubic+accurate_rnd+full_chroma_int'
167
+ /** the width of a shot's frame on the contact strip */
168
+ export const THUMB_W = 480
169
+
170
+ /** the frame of each shot the contact strip shows: its middle */
171
+ const middles = (t: Pick<Trailer, 'shots' | 'fps'>): number[] => {
172
+ const st = starts(t)
173
+ return t.shots.map((s, i) => frameAt(st[i]! + s.length / 2, t.fps))
174
+ }
175
+
176
+ /** each shake's swing at shot time t: its strength, easing out over its span */
177
+ const swing = (s: Shot, H: number): string =>
178
+ s.shakes.map((x) => `${n3((H / 60) * x.strength)}*if(between(t,${n3(x.at)},${n3(x.at + x.for)}),pow(1-(t-${n3(x.at)})/${n3(x.for)},2),0)`).join('+')
179
+
180
+ /** the shakes: the frame moved by a jitter of two sines a side, the edge it uncovers mirrored from the film's own */
181
+ function shakeF(s: Shot, W: number, H: number): string {
182
+ if (s.shakes.length === 0) return ''
183
+ const m = 2 * Math.ceil((Math.max(...s.shakes.map((x) => (H / 60) * x.strength)) + 1) / 2)
184
+ const A = `(${swing(s, H)})`
185
+ const jx = `(sin(t*113)+0.5*sin(t*71+1))/1.5`
186
+ const jy = `(cos(t*97)+0.5*sin(t*53+2))/1.5`
187
+ return `,pad=${W + 2 * m}:${H + 2 * m}:${m}:${m},fillborders=left=${m}:right=${m}:top=${m}:bottom=${m}:mode=mirror,crop=${W}:${H}:x='${m}+${A}*${jx}':y='${m}+${A}*${jy}'`
188
+ }
189
+
190
+ /** the flashes: every pixel eased toward white, full at the flash, gone at its end */
191
+ function flashF(s: Shot): string {
192
+ return s.flashes
193
+ .map((x) => {
194
+ const k = `pow(1-(T-${n3(x.at)})/${n3(x.for)},2)`
195
+ const en = `between(t,${n3(x.at)},${n3(x.at + x.for)})`
196
+ return `,geq=lum='lum(X,Y)+(255-lum(X,Y))*${k}':cb='cb(X,Y)+(128-cb(X,Y))*${k}':cr='cr(X,Y)+(128-cr(X,Y))*${k}':enable='${en}'`
197
+ })
198
+ .join('')
199
+ }
200
+
201
+ /** a knocked title flies out of the frame in this long, turning this many radians */
202
+ const KNOCK_S = 0.4
203
+ const KNOCK_TURN = 1.4
204
+
205
+ /** the ffmpeg arguments that render a piece's frames, no sound, and the row of its shots' middle frames for the
206
+ * contact strip (`thumbs`, when a middle falls in it) */
207
+ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'look'>, p: Piece, out: string, encoder: Encoder = 'draft', thumbs?: string): string[] {
208
+ const { t } = a
209
+ const [W, H] = t.size
210
+ const fps = t.fps
211
+ const T = total(t)
212
+ const st = starts(t)
213
+ const spans = frameSpans(t)
214
+ // the graph starts at the piece's first frame: the shot before a section draws only what its cut overlaps
215
+ const base = p.from
216
+ const inputs: string[] = []
217
+ const graph: string[] = []
218
+ let n = 0
219
+ const input = (...args: string[]): number => {
220
+ inputs.push(...args)
221
+ return n++
222
+ }
223
+
224
+ // the shots, each exactly its frames in the piece, on a time base of one frame. A shot's own clock (its push, its
225
+ // shakes, its flashes) runs from its own first frame, whatever the piece leaves out of it
226
+ const count: number[] = []
227
+ for (let i = p.chain; i < p.end; i++) {
228
+ const s = t.shots[i]!
229
+ const src = a.sources[i]!
230
+ const len = s.length
231
+ const skip = Math.max(0, base - spans[i]!.a)
232
+ const frames = Math.min(spans[i]!.b, p.to) - spans[i]!.a - skip
233
+ count[i] = frames
234
+ const push = s.push && s.push > 1 ? s.push : null
235
+ const pushF = push
236
+ ? `,scale=${W * 2}:${H * 2},zoompan=z='1+${n3(push - 1)}*(on+${skip})/${Math.round(len * fps)}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s=${W}x${H}:fps=${fps}`
237
+ : ''
238
+ const [ax, ay] = s.frameAt ?? [0.5, 0.5]
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
240
+ const frameF = cropF ? `,${cropF},scale=${W}:${H}` : ''
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}]`
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) {
310
+ // read at the cut's rate: a still read at the demuxer's 25 fps breaks the xfade of a cut into it
311
+ const k = input('-framerate', String(fps), '-i', src.image)
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}`)
313
+ } else {
314
+ const avail = Math.max(0, src.seconds - s.from)
315
+ // seeking at the input decodes only what the shot shows of a long footage
316
+ const k = input(...DECODE, '-ss', n3(s.from), '-t', n3(Math.min(avail, len) + 0.1), '-i', src.footage)
317
+ graph.push(
318
+ `[${k}:v]trim=duration=${n3(Math.min(avail, len))},setpts=PTS-STARTPTS,fps=${fps}${skip > 0 ? `,trim=start_frame=${skip}` : ''},` +
319
+ `${cropF ? `${cropF},` : ''}scale=${W}:${H}:force_original_aspect_ratio=increase:${FULL_601},crop=${W}:${H},setsar=1,` +
320
+ `tpad=stop_mode=clone:stop_duration=${n3(len)}${pushF}${tail}`,
321
+ )
322
+ }
323
+ }
324
+
325
+ // the cuts: only the frames both shots hold go through xfade, then every part joins in one concat, so a piece draws
326
+ // its frames as the whole cut would
327
+ const both = (i: number): number => (i > p.chain && t.shots[i - 1]!.cut.kind !== 'hard' ? Math.max(0, spans[i - 1]!.b - spans[i]!.a) : 0)
328
+ const parts: string[] = []
329
+ for (let i = p.chain; i < p.end; i++) {
330
+ const [head, tail] = [both(i), i + 1 < p.end ? both(i + 1) : 0]
331
+ const cuts = (
332
+ [
333
+ ['h', 0, head],
334
+ ['b', head, count[i]! - tail],
335
+ ['e', count[i]! - tail, count[i]!],
336
+ ] as const
337
+ ).filter(([, from, to]) => to > from)
338
+ graph.push(`[s${i}]split=${cuts.length}${cuts.map(([k]) => `[${k}${i}x]`).join('')};${cuts.map(([k, from, to]) => `[${k}${i}x]trim=start_frame=${from}:end_frame=${to},setpts=N[${k}${i}]`).join(';')}`)
339
+ if (head > 0) {
340
+ const kind = t.shots[i - 1]!.cut.kind as Exclude<CutKind, 'hard'>
341
+ const d = head / fps
342
+ const edge = SEAM_EDGE[kind]
343
+ if (edge === undefined) graph.push(`[e${i - 1}][h${i}]xfade=transition=${XFADE[kind]}:duration=${n3(d)}:offset=0,format=yuv420p[j${i}]`)
344
+ else {
345
+ // a wipe's seam rides its edge: a soft shadow of the look's edge, then a line of its light
346
+ const across = edge.axis === 'x'
347
+ const pos = `${across ? W : H}*${edge.from ? `(1-t/${n3(d)})` : `t/${n3(d)}`}`
348
+ const bar = (c: string, w: number, k: string): string => `color=c=${c}:s=${across ? `${w}x${H}` : `${W}x${w}`}:r=${fps}:d=${n3(d + 1 / fps)},format=rgba[${k}${i}]`
349
+ const at = (w: number): string => (across ? `x='${pos}-${w / 2}':y=0` : `x=0:y='${pos}-${w / 2}'`)
350
+ const [sw, lw] = [Math.round(H / 64), Math.max(4, Math.round(H / 300))]
351
+ graph.push(
352
+ `[e${i - 1}][h${i}]xfade=transition=${XFADE[kind]}:duration=${n3(d)}:offset=0[jw${i}]`,
353
+ bar(`${ff(a.look.edge)}@0.35`, sw, 'ws'),
354
+ bar(ff(a.look.light), lw, 'wl'),
355
+ `[jw${i}][ws${i}]overlay=${at(sw)}:eval=frame:shortest=1[jv${i}]`,
356
+ `[jv${i}][wl${i}]overlay=${at(lw)}:eval=frame:shortest=1,format=yuv420p[j${i}]`,
357
+ )
358
+ }
359
+ parts.push(`[j${i}]`)
360
+ }
361
+ if (cuts.some(([k]) => k === 'b')) parts.push(`[b${i}]`)
362
+ }
363
+ let cur = 'chain'
364
+ graph.push(`${parts.join('')}concat=n=${parts.length}:v=1:a=0,settb=1/${fps},setpts=N[chain]`)
365
+
366
+ // the opening and the ending
367
+ const last = t.shots.at(-1)!.cut
368
+ const ends: string[] = []
369
+ if (p.chain === 0 && t.fadeIn > 0) ends.push(`fade=t=in:st=0:d=${n3(t.fadeIn)}`)
370
+ if (p.end === t.shots.length && last.seconds > 0) ends.push(`fade=t=out:st=${n3(T - last.seconds - base / fps)}:d=${n3(last.seconds)}${last.kind === 'dip-white' ? ':color=white' : ''}`)
371
+ if (ends.length > 0) {
372
+ graph.push(`[${cur}]${ends.join(',')}[ends]`)
373
+ cur = 'ends'
374
+ }
375
+
376
+ // the titles that show in the piece, wherever their shot is: each faded in and out over its own span
377
+ t.shots.forEach((s, i) => {
378
+ s.titles.forEach((ti, j) => {
379
+ const at = st[i]! + ti.at
380
+ const dur = Math.min(ti.for ?? s.length - ti.at, ti.knock === undefined ? Infinity : ti.knock - ti.at + KNOCK_S)
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]
384
+ if (te < p.from || ta >= p.to || te < ta) return
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
394
+ const img = a.titles[i]![j]!
395
+ const k = input('-i', img.file)
396
+ const [from, to] = [Math.max(ta, base), Math.min(te, p.to - 1)]
397
+ const kn = ti.knock === undefined ? null : ti.knock - ti.at
398
+ const hyp = kn === null ? 0 : Math.ceil(Math.hypot(img.w, img.h))
399
+ const tip = kn === null ? '' : `rotate=a='-${KNOCK_TURN}*pow(max(0,t-${n3(kn)})/${KNOCK_S},2)':ow=${hyp}:oh=${hyp}:c=none,`
400
+ graph.push(
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,` : ''}` +
402
+ `trim=start_frame=${from - ta},settb=1/${fps},setpts=N+${from - base}[t${i}_${j}]`,
403
+ )
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)`
408
+ const on = `between(t,${n3((ta - base - 0.5) / fps)},${n3((te - base + 0.5) / fps)})`
409
+ const flown = kn === null ? '' : `pow(max(0,n-${n3((at + kn) * fps - base)})/${n3(KNOCK_S * fps)},2)`
410
+ const x = kn === null ? `${img.x}${slide}` : `${img.x - (hyp - img.w) / 2}-${n3(W * 1.1)}*${flown}`
411
+ const y = kn === null ? `${img.y}+${rise}` : `${img.y - (hyp - img.h) / 2}+${rise}-${n3(H * 0.45)}*${flown}`
412
+ graph.push(`[${cur}][t${i}_${j}]overlay=x='${x}':y='${y}':eof_action=pass:enable='${on}'[v${i}_${j}]`)
413
+ cur = `v${i}_${j}`
414
+ })
415
+ })
416
+ graph.push(`[${cur}]trim=end_frame=${p.to - base},format=yuv420p,${TAGS}[vout]`)
417
+
418
+ // the contact strip's row: the middle frame of each shot whose middle is in the piece, side by side
419
+ const mids = middles(t).filter((m) => m >= p.from && m < p.to)
420
+ const row = thumbs !== undefined && mids.length > 0
421
+ if (row) {
422
+ graph[graph.length - 1] = graph.at(-1)!.replace('[vout]', ',split[vout][all]')
423
+ graph.push(`[all]select='${mids.map((m) => `eq(n,${m - base})`).join('+')}',scale=${THUMB_W}:-2,tile=${mids.length}x1[row]`)
424
+ }
425
+ return [
426
+ '-hide_banner', '-loglevel', 'error', '-y',
427
+ ...inputs,
428
+ '-filter_complex', graph.join(';\n'),
429
+ '-map', '[vout]', '-r', String(fps),
430
+ ...ENCODERS[encoder],
431
+ out,
432
+ ...(row ? ['-map', '[row]', '-frames:v', '1', '-update', '1', thumbs] : []),
433
+ ]
434
+ }
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
+
443
+ /** the ffmpeg arguments that mix the cut's sound: the music trimmed, faded and ducked under each voice line, the sfx
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[] {
447
+ const { t } = a
448
+ const T = total(t)
449
+ const st = starts(t)
450
+ const inputs: string[] = []
451
+ const graph: string[] = []
452
+ let n = 0
453
+ const input = (...args: string[]): number => {
454
+ inputs.push(...args)
455
+ return n++
456
+ }
457
+ const mix: string[] = []
458
+ const windows: [number, number][] = []
459
+ const hits: [number, number][] = []
460
+ const voiceLabels: string[] = []
461
+ t.shots.forEach((s, i) => {
462
+ s.sfx.forEach((x, j) => {
463
+ const src = a.sfx[i]![j]!
464
+ const k = input('-i', src.file)
465
+ const ms = Math.round((st[i]! + x.at) * 1000)
466
+ if (t.duckSfx !== null) hits.push([st[i]! + x.at, st[i]! + x.at + src.seconds])
467
+ graph.push(`[${k}:a]aresample=48000,aformat=channel_layouts=stereo,adelay=${ms}:all=1,volume=${n3(x.volume)}[x${i}_${j}]`)
468
+ mix.push(`[x${i}_${j}]`)
469
+ })
470
+ s.voices.forEach((v, j) => {
471
+ const src = a.voices[i]![j]!
472
+ const k = input('-i', src.file)
473
+ const at = st[i]! + v.at
474
+ windows.push([at, at + src.seconds])
475
+ graph.push(`[${k}:a]aresample=48000,aformat=channel_layouts=stereo,adelay=${Math.round(at * 1000)}:all=1,volume=${n3(v.volume)}[o${i}_${j}]`)
476
+ voiceLabels.push(`[o${i}_${j}]`)
477
+ })
478
+ })
479
+ mix.push(...voiceLabels)
480
+ // the duck: the gain eases to `duck` dB 0.15 s before each voice line and back 0.15 s after it
481
+ const r = 0.15
482
+ const g = 10 ** (t.duck / 20)
483
+ const env = windows.map(([s, e]) => `clip((t-${n3(s - r)})/${r},0,1)*clip((${n3(e + r)}-t)/${r},0,1)`)
484
+ // and, with `duck … sfx`, to that drop under each sfx, eased in and out over 0.06 s
485
+ const rs = 0.06
486
+ const envS = hits.map(([s, e]) => `clip((t-${n3(s - rs)})/${rs},0,1)*clip((${n3(e + rs)}-t)/${rs},0,1)`)
487
+ const gains = [
488
+ ...(env.length > 0 ? [`(1-${n3(1 - g)}*min(1,${env.join('+')}))`] : []),
489
+ ...(envS.length > 0 ? [`(1-${n3(1 - 10 ** ((t.duckSfx ?? 0) / 20))}*min(1,${envS.join('+')}))`] : []),
490
+ ]
491
+ const duck = gains.length > 0 ? `,volume='${gains.join('*')}':eval=frame` : ''
492
+ musicCues(t).forEach(({ cue: m, at, end }, i, cues) => {
493
+ const dur = end - at
494
+ const k = input('-ss', n3(m.from), '-t', n3(dur), '-i', a.music[i]!)
495
+ const last = i === cues.length - 1
496
+ const fadeOut = last ? m.fadeOut : (cues[i + 1]?.cue.fadeIn ?? 0) || 0.05
497
+ const fades = [m.fadeIn > 0 ? `afade=t=in:st=0:d=${n3(m.fadeIn)}` : '', fadeOut > 0 ? `afade=t=out:st=${n3(Math.max(0, dur - fadeOut))}:d=${n3(Math.min(fadeOut, dur))}` : ''].filter(Boolean)
498
+ graph.push(`[${k}:a]asetpts=PTS-STARTPTS,aresample=48000,aformat=channel_layouts=stereo${fades.length ? `,${fades.join(',')}` : ''},volume=${n3(m.volume)},adelay=${Math.round(at * 1000)}:all=1${duck}[mus${i}]`)
499
+ mix.unshift(`[mus${i}]`)
500
+ })
501
+ const silence = input('-f', 'lavfi', '-t', n3(T), '-i', 'anullsrc=r=48000:cl=stereo')
502
+ mix.push(`[${silence}:a]`)
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]`)
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]
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
+ }
@@ -0,0 +1,31 @@
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'
4
+ import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
5
+ import type { TrailerUnit } from './adapter.ts'
6
+
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> {
23
+ const found: { file: string; unit: TrailerUnit }[] = []
24
+ for (const f of repoUnitFiles(root)) {
25
+ if (!/\btrailer\s*\(/.test(readFileSync(f, 'utf8'))) continue
26
+ const d = (await importFresh(f)) as { kind?: unknown } | undefined
27
+ if (d?.kind === 'trailer') found.push({ file: f, unit: d as TrailerUnit })
28
+ }
29
+ if (found.length > 1) throw new Error(`two trailer units: ${found.map((x) => x.file).join(' and ')}; keep one`)
30
+ return found[0]?.unit ?? null
31
+ }