rvlib-shipkit 2.1.0 → 2.2.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 (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +2 -1
  3. package/src/assets/kits/identity-kit.ts +7 -3
  4. package/src/assets/run-assets.ts +18 -4
  5. package/src/checks/SK004-gitignore-policy.check.ts +11 -6
  6. package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
  7. package/src/cli/cmd_CacheAudit.ts +3 -2
  8. package/src/cli/cmd_Calls.ts +53 -0
  9. package/src/cli/cmd_Ci.ts +4 -3
  10. package/src/cli/cmd_Commands.ts +158 -43
  11. package/src/cli/cmd_Fleet.ts +3 -1
  12. package/src/cli/cmd_GlobalProcesses.ts +7 -2
  13. package/src/cli/cmd_Shot.ts +63 -0
  14. package/src/cli/cmd_Start.ts +8 -4
  15. package/src/cli/cmd_Trailer.ts +108 -0
  16. package/src/cli/kit/builtins.ts +40 -0
  17. package/src/cli/kit/calls.ts +177 -0
  18. package/src/cli/kit/families.ts +67 -0
  19. package/src/cli/kit/index.ts +8 -0
  20. package/src/cli/kit/pages-check.ts +36 -0
  21. package/src/cli/kit/project-cli.ts +73 -0
  22. package/src/cli/kit/verb.ts +157 -0
  23. package/src/cli/list-flag.ts +3 -0
  24. package/src/cli.ts +13 -2
  25. package/src/core/layout.ts +10 -2
  26. package/src/core/migrate-layout.ts +31 -8
  27. package/src/core/one-way-in-policy.ts +12 -4
  28. package/src/mv/mv-md.ts +2 -1
  29. package/src/reflect/prompt-corpus.ts +5 -0
  30. package/src/scaffold/cli-skeleton.ts +149 -0
  31. package/src/scaffold/files/agent-index.md +2 -2
  32. package/src/scaffold/files/tsconfig.json +3 -0
  33. package/src/scaffold/files/verify-skill.md +1 -1
  34. package/src/scaffold/project-scaffold.ts +90 -12
  35. package/src/shipkit-files.ts +8 -4
  36. package/src/shot/client.ts +67 -0
  37. package/src/shot/clip-encoder.ts +146 -0
  38. package/src/shot/dom-clip.ts +124 -0
  39. package/src/shot/serve-shot.ts +54 -0
  40. package/src/shot/shooter.ts +343 -0
  41. package/src/shot/shot-ask.ts +189 -0
  42. package/src/shot/shot.shipkit.ts +14 -0
  43. package/src/smoke/run-smoke.ts +2 -2
  44. package/src/start-repo.ts +29 -14
  45. package/src/suite/judge-suites.ts +56 -10
  46. package/src/suite/select.ts +2 -5
  47. package/src/suite/suite-cli.ts +3 -2
  48. package/src/trailer/adapter.ts +23 -0
  49. package/src/trailer/build.ts +344 -0
  50. package/src/trailer/ffmpeg.ts +50 -0
  51. package/src/trailer/footage.ts +104 -0
  52. package/src/trailer/grammar.ts +915 -0
  53. package/src/trailer/graph.ts +323 -0
  54. package/src/trailer/load-unit.ts +14 -0
  55. package/src/trailer/look.ts +110 -0
  56. package/src/trailer/plan.ts +133 -0
  57. package/src/trailer/stitch.ts +150 -0
  58. package/src/trailer/titles.ts +274 -0
  59. package/src/trailer/url.ts +108 -0
  60. package/src/trailer/vertical-draw.ts +286 -0
  61. package/src/trailer/vertical.ts +148 -0
  62. package/src/trailer/vocab.ts +40 -0
  63. package/src/ts/rename.ts +9 -0
@@ -0,0 +1,323 @@
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 { 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
+ /** 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 }
37
+
38
+ export interface StitchInput {
39
+ t: Trailer
40
+ sources: Source[]
41
+ /** each title's PNG, shot by shot, in the order of its titles */
42
+ titles: TitleImage[][]
43
+ /** each music cue's file, in the order of musicCues */
44
+ music: string[]
45
+ /** each sfx's file and length, shot by shot */
46
+ sfx: { file: string; seconds: number }[][]
47
+ /** each voice line's file and its length, shot by shot */
48
+ voices: { file: string; seconds: number }[][]
49
+ look: TrailerLook
50
+ }
51
+
52
+ const n3 = (x: number): string => (+x.toFixed(4)).toString()
53
+ /** ffmpeg's colour syntax for a hex colour */
54
+ const ff = (c: string): string => (c.startsWith('#') ? `0x${c.slice(1)}` : c)
55
+
56
+ const MAC = process.platform === 'darwin'
57
+ const X264 = ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-pix_fmt', 'yuv420p']
58
+ /** how a piece is encoded: `draft` the Mac's media engine, `final` x264 for a release, `lossless` for parity tests */
59
+ export const ENCODERS = {
60
+ draft: MAC ? ['-c:v', 'h264_videotoolbox', '-q:v', '70', '-pix_fmt', 'yuv420p'] : X264,
61
+ final: X264,
62
+ lossless: ['-c:v', 'libx264', '-preset', 'ultrafast', '-qp', '0', '-pix_fmt', 'yuv420p'],
63
+ } as const
64
+ export type Encoder = keyof typeof ENCODERS
65
+ /** the footage decoded by the media engine: the same frames, at a third of the CPU */
66
+ const DECODE = MAC ? ['-hwaccel', 'videotoolbox'] : []
67
+ /** every shot's frames tagged alike: no join converts one shot to match another, and the joined pieces share one header */
68
+ const TAGS = 'setparams=range=pc:color_primaries=unknown:color_trc=unknown:colorspace=unknown'
69
+ /** a footage's frames made full range BT.601, the matrix of the cards and stills */
70
+ const FULL_601 = 'out_range=pc:out_color_matrix=bt601:flags=bicubic+accurate_rnd+full_chroma_int'
71
+ /** the width of a shot's frame on the contact strip */
72
+ export const THUMB_W = 480
73
+
74
+ /** the frame of each shot the contact strip shows: its middle */
75
+ const middles = (t: Pick<Trailer, 'shots' | 'fps'>): number[] => {
76
+ const st = starts(t)
77
+ return t.shots.map((s, i) => frameAt(st[i]! + s.length / 2, t.fps))
78
+ }
79
+
80
+ /** each shake's swing at shot time t: its strength, easing out over its span */
81
+ const swing = (s: Shot, H: number): string =>
82
+ 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('+')
83
+
84
+ /** the shakes: the frame moved by a jitter of two sines a side, the edge it uncovers mirrored from the film's own */
85
+ function shakeF(s: Shot, W: number, H: number): string {
86
+ if (s.shakes.length === 0) return ''
87
+ const m = 2 * Math.ceil((Math.max(...s.shakes.map((x) => (H / 60) * x.strength)) + 1) / 2)
88
+ const A = `(${swing(s, H)})`
89
+ const jx = `(sin(t*113)+0.5*sin(t*71+1))/1.5`
90
+ const jy = `(cos(t*97)+0.5*sin(t*53+2))/1.5`
91
+ 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}'`
92
+ }
93
+
94
+ /** the flashes: every pixel eased toward white, full at the flash, gone at its end */
95
+ function flashF(s: Shot): string {
96
+ return s.flashes
97
+ .map((x) => {
98
+ const k = `pow(1-(T-${n3(x.at)})/${n3(x.for)},2)`
99
+ const en = `between(t,${n3(x.at)},${n3(x.at + x.for)})`
100
+ 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}'`
101
+ })
102
+ .join('')
103
+ }
104
+
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
108
+
109
+ /** the ffmpeg arguments that render a piece's frames, no sound, and the row of its shots' middle frames for the
110
+ * contact strip (`thumbs`, when a middle falls in it) */
111
+ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'look'>, p: Piece, out: string, encoder: Encoder = 'draft', thumbs?: string): string[] {
112
+ const { t } = a
113
+ const [W, H] = t.size
114
+ const fps = t.fps
115
+ const T = total(t)
116
+ const st = starts(t)
117
+ const spans = frameSpans(t)
118
+ // the graph starts at the piece's first frame: the shot before a section draws only what its cut overlaps
119
+ const base = p.from
120
+ const inputs: string[] = []
121
+ const graph: string[] = []
122
+ let n = 0
123
+ const input = (...args: string[]): number => {
124
+ inputs.push(...args)
125
+ return n++
126
+ }
127
+
128
+ // the shots, each exactly its frames in the piece, on a time base of one frame. A shot's own clock (its push, its
129
+ // shakes, its flashes) runs from its own first frame, whatever the piece leaves out of it
130
+ const count: number[] = []
131
+ for (let i = p.chain; i < p.end; i++) {
132
+ const s = t.shots[i]!
133
+ const src = a.sources[i]!
134
+ const len = s.length
135
+ const skip = Math.max(0, base - spans[i]!.a)
136
+ const frames = Math.min(spans[i]!.b, p.to) - spans[i]!.a - skip
137
+ count[i] = frames
138
+ const push = s.push && s.push > 1 ? s.push : null
139
+ const pushF = push
140
+ ? `,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}`
141
+ : ''
142
+ const [ax, ay] = s.frameAt ?? [0.5, 0.5]
143
+ 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
+ const frameF = cropF ? `,${cropF},scale=${W}:${H}` : ''
145
+ 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) {
147
+ // read at the cut's rate: a still read at the demuxer's 25 fps breaks the xfade of a cut into it
148
+ const k = input('-framerate', String(fps), '-i', src.image)
149
+ 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}`)
150
+ } else {
151
+ const avail = Math.max(0, src.seconds - s.from)
152
+ // seeking at the input decodes only what the shot shows of a long footage
153
+ const k = input(...DECODE, '-ss', n3(s.from), '-t', n3(Math.min(avail, len) + 0.1), '-i', src.footage)
154
+ graph.push(
155
+ `[${k}:v]trim=duration=${n3(Math.min(avail, len))},setpts=PTS-STARTPTS,fps=${fps}${skip > 0 ? `,trim=start_frame=${skip}` : ''},` +
156
+ `${cropF ? `${cropF},` : ''}scale=${W}:${H}:force_original_aspect_ratio=increase:${FULL_601},crop=${W}:${H},setsar=1,` +
157
+ `tpad=stop_mode=clone:stop_duration=${n3(len)}${pushF}${tail}`,
158
+ )
159
+ }
160
+ }
161
+
162
+ // the cuts: only the frames both shots hold go through xfade, then every part joins in one concat, so a piece draws
163
+ // its frames as the whole cut would
164
+ 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)
165
+ const parts: string[] = []
166
+ for (let i = p.chain; i < p.end; i++) {
167
+ const [head, tail] = [both(i), i + 1 < p.end ? both(i + 1) : 0]
168
+ const cuts = (
169
+ [
170
+ ['h', 0, head],
171
+ ['b', head, count[i]! - tail],
172
+ ['e', count[i]! - tail, count[i]!],
173
+ ] as const
174
+ ).filter(([, from, to]) => to > from)
175
+ 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(';')}`)
176
+ if (head > 0) {
177
+ const kind = t.shots[i - 1]!.cut.kind as Exclude<CutKind, 'hard'>
178
+ const d = head / fps
179
+ 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}]`)
181
+ else {
182
+ // a wipe's seam rides its edge: a soft shadow of the look's edge, then a line of its light
183
+ const across = edge.axis === 'x'
184
+ const pos = `${across ? W : H}*${edge.from ? `(1-t/${n3(d)})` : `t/${n3(d)}`}`
185
+ 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}]`
186
+ const at = (w: number): string => (across ? `x='${pos}-${w / 2}':y=0` : `x=0:y='${pos}-${w / 2}'`)
187
+ const [sw, lw] = [Math.round(H / 64), Math.max(4, Math.round(H / 300))]
188
+ graph.push(
189
+ `[e${i - 1}][h${i}]xfade=transition=${XFADE[kind]}:duration=${n3(d)}:offset=0[jw${i}]`,
190
+ bar(`${ff(a.look.edge)}@0.35`, sw, 'ws'),
191
+ bar(ff(a.look.light), lw, 'wl'),
192
+ `[jw${i}][ws${i}]overlay=${at(sw)}:eval=frame:shortest=1[jv${i}]`,
193
+ `[jv${i}][wl${i}]overlay=${at(lw)}:eval=frame:shortest=1,format=yuv420p[j${i}]`,
194
+ )
195
+ }
196
+ parts.push(`[j${i}]`)
197
+ }
198
+ if (cuts.some(([k]) => k === 'b')) parts.push(`[b${i}]`)
199
+ }
200
+ let cur = 'chain'
201
+ graph.push(`${parts.join('')}concat=n=${parts.length}:v=1:a=0,settb=1/${fps},setpts=N[chain]`)
202
+
203
+ // the opening and the ending
204
+ const last = t.shots.at(-1)!.cut
205
+ const ends: string[] = []
206
+ if (p.chain === 0 && t.fadeIn > 0) ends.push(`fade=t=in:st=0:d=${n3(t.fadeIn)}`)
207
+ 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' : ''}`)
208
+ if (ends.length > 0) {
209
+ graph.push(`[${cur}]${ends.join(',')}[ends]`)
210
+ cur = 'ends'
211
+ }
212
+
213
+ // the titles that show in the piece, wherever their shot is: each faded in and out over its own span
214
+ 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)]
219
+ 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
+ const img = a.titles[i]![j]!
223
+ const k = input('-i', img.file)
224
+ 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)`
234
+ 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}]`)
239
+ cur = `v${i}_${j}`
240
+ })
241
+ })
242
+ graph.push(`[${cur}]trim=end_frame=${p.to - base},format=yuv420p,${TAGS}[vout]`)
243
+
244
+ // the contact strip's row: the middle frame of each shot whose middle is in the piece, side by side
245
+ const mids = middles(t).filter((m) => m >= p.from && m < p.to)
246
+ const row = thumbs !== undefined && mids.length > 0
247
+ if (row) {
248
+ graph[graph.length - 1] = graph.at(-1)!.replace('[vout]', ',split[vout][all]')
249
+ graph.push(`[all]select='${mids.map((m) => `eq(n,${m - base})`).join('+')}',scale=${THUMB_W}:-2,tile=${mids.length}x1[row]`)
250
+ }
251
+ return [
252
+ '-hide_banner', '-loglevel', 'error', '-y',
253
+ ...inputs,
254
+ '-filter_complex', graph.join(';\n'),
255
+ '-map', '[vout]', '-r', String(fps),
256
+ ...ENCODERS[encoder],
257
+ out,
258
+ ...(row ? ['-map', '[row]', '-frames:v', '1', '-update', '1', thumbs] : []),
259
+ ]
260
+ }
261
+
262
+ /** 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[] {
265
+ const { t } = a
266
+ const T = total(t)
267
+ const st = starts(t)
268
+ const inputs: string[] = []
269
+ const graph: string[] = []
270
+ let n = 0
271
+ const input = (...args: string[]): number => {
272
+ inputs.push(...args)
273
+ return n++
274
+ }
275
+ const mix: string[] = []
276
+ const windows: [number, number][] = []
277
+ const hits: [number, number][] = []
278
+ const voiceLabels: string[] = []
279
+ t.shots.forEach((s, i) => {
280
+ s.sfx.forEach((x, j) => {
281
+ const src = a.sfx[i]![j]!
282
+ const k = input('-i', src.file)
283
+ const ms = Math.round((st[i]! + x.at) * 1000)
284
+ if (t.duckSfx !== null) hits.push([st[i]! + x.at, st[i]! + x.at + src.seconds])
285
+ graph.push(`[${k}:a]aresample=48000,aformat=channel_layouts=stereo,adelay=${ms}:all=1,volume=${n3(x.volume)}[x${i}_${j}]`)
286
+ mix.push(`[x${i}_${j}]`)
287
+ })
288
+ s.voices.forEach((v, j) => {
289
+ const src = a.voices[i]![j]!
290
+ const k = input('-i', src.file)
291
+ const at = st[i]! + v.at
292
+ windows.push([at, at + src.seconds])
293
+ graph.push(`[${k}:a]aresample=48000,aformat=channel_layouts=stereo,adelay=${Math.round(at * 1000)}:all=1,volume=${n3(v.volume)}[o${i}_${j}]`)
294
+ voiceLabels.push(`[o${i}_${j}]`)
295
+ })
296
+ })
297
+ mix.push(...voiceLabels)
298
+ // the duck: the gain eases to `duck` dB 0.15 s before each voice line and back 0.15 s after it
299
+ const r = 0.15
300
+ const g = 10 ** (t.duck / 20)
301
+ const env = windows.map(([s, e]) => `clip((t-${n3(s - r)})/${r},0,1)*clip((${n3(e + r)}-t)/${r},0,1)`)
302
+ // and, with `duck … sfx`, to that drop under each sfx, eased in and out over 0.06 s
303
+ const rs = 0.06
304
+ const envS = hits.map(([s, e]) => `clip((t-${n3(s - rs)})/${rs},0,1)*clip((${n3(e + rs)}-t)/${rs},0,1)`)
305
+ const gains = [
306
+ ...(env.length > 0 ? [`(1-${n3(1 - g)}*min(1,${env.join('+')}))`] : []),
307
+ ...(envS.length > 0 ? [`(1-${n3(1 - 10 ** ((t.duckSfx ?? 0) / 20))}*min(1,${envS.join('+')}))`] : []),
308
+ ]
309
+ const duck = gains.length > 0 ? `,volume='${gains.join('*')}':eval=frame` : ''
310
+ musicCues(t).forEach(({ cue: m, at, end }, i, cues) => {
311
+ const dur = end - at
312
+ const k = input('-ss', n3(m.from), '-t', n3(dur), '-i', a.music[i]!)
313
+ const last = i === cues.length - 1
314
+ const fadeOut = last ? m.fadeOut : (cues[i + 1]?.cue.fadeIn ?? 0) || 0.05
315
+ 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)
316
+ 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}]`)
317
+ mix.unshift(`[mus${i}]`)
318
+ })
319
+ const silence = input('-f', 'lavfi', '-t', n3(T), '-i', 'anullsrc=r=48000:cl=stereo')
320
+ 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]`)
322
+ 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
+ }
@@ -0,0 +1,14 @@
1
+ // the repo's trailer unit, found by the walk every unit file takes (core/discover-checks.ts)
2
+ import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
3
+ import type { TrailerUnit } from './adapter.ts'
4
+
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 found: { file: string; unit: TrailerUnit }[] = []
8
+ for (const f of repoUnitFiles(root)) {
9
+ const d = (await importFresh(f)) as { kind?: unknown } | undefined
10
+ if (d?.kind === 'trailer') found.push({ file: f, unit: d as TrailerUnit })
11
+ }
12
+ if (found.length > 1) throw new Error(`two trailer units: ${found.map((x) => x.file).join(' and ')}; keep one`)
13
+ return found[0]?.unit ?? null
14
+ }
@@ -0,0 +1,110 @@
1
+ // the look every trailer wears, drawn from the repo's brand (`.shipkit/identity/brand.ts`): the card's ground, the
2
+ // title colours, the face and the logo. A repo changes its look in that data, never in code; a value it leaves out is
3
+ // derived from its palette, and a repo with no brand gets the house default. Pure
4
+ export interface TrailerLookInput {
5
+ /** the card's ground, middle then edge (default: the palette's bg, then a darker bg) */
6
+ ground?: [string, string]
7
+ /** words over footage (default: the palette's ink when it is light, else white) */
8
+ light?: string
9
+ /** the dark edge under words over footage (default: the ground's edge) */
10
+ edge?: string
11
+ /** the key colour: the caption's dot, a `line` title's `*word*`, the corner chip (default: the palette's accent) */
12
+ accent?: string
13
+ /** the caption's plate (default: the palette's bg, an accent outline, the palette's ink) */
14
+ plate?: { fill?: string; line?: string; ink?: string }
15
+ /** the corner chip (default: the accent, ink of the palette's bg) */
16
+ chip?: { fill?: string; ink?: string }
17
+ /** the face: a font file (repo relative, .ttf .otf .woff2) or a family the machine has, and its heavy weight */
18
+ font?: { file?: string; family?: string; weight?: number }
19
+ /** the logo the `logo` title draws over its words, repo relative (default: logo.png at the web root, when present) */
20
+ logo?: string
21
+ /** the vertical short's look (a `vertical` cut), each part `false` to leave it out (vertical.ts) */
22
+ vertical?: VerticalLookInput
23
+ }
24
+
25
+ /** what a vertical short wears, image paths repo relative; its colours and face are the look's */
26
+ export interface VerticalLookInput {
27
+ /** over every frame of the short, under the platforms' top tabs: a wordmark image, else the logo and the name; a
28
+ * tag pill under it */
29
+ lockup?: { image?: string; name?: string; tag?: string } | false
30
+ /** a bottom corner of the short and its outro: an image (default: the logo) and a word beside it (default: the
31
+ * name, when there is no image) */
32
+ watermark?: { image?: string; text?: string; corner?: 'left' | 'right' } | false
33
+ /** the card the short ends on: an image that hops in (default: the logo), the name, lines with `*word*` in the
34
+ * accent (default: the tagline), its length in seconds */
35
+ end?: { image?: string; name?: string; lines?: string[]; seconds?: number } | false
36
+ }
37
+
38
+ export interface TrailerLook {
39
+ ground: [string, string]
40
+ light: string
41
+ edge: string
42
+ accent: string
43
+ plate: { fill: string; line: string; ink: string }
44
+ chip: { fill: string; ink: string }
45
+ font: { file: string | null; family: string; weight: number }
46
+ logo: string | null
47
+ }
48
+
49
+ export interface BrandLike {
50
+ name: string
51
+ tagline?: string
52
+ palette: { bg: string; accent: string; ink: string }
53
+ trailer?: TrailerLookInput
54
+ }
55
+
56
+ /** named faces first: a generic sans-serif can resolve to one with no Latin-1 accents (the og card uses it too) */
57
+ export const HOUSE_FAMILIES = 'Helvetica, Arial, "DejaVu Sans", "Liberation Sans", "Noto Sans", sans-serif'
58
+
59
+ /** the palette a repo with no brand wears: a deep ink ground, warm accent, cream words */
60
+ export const HOUSE_PALETTE = { bg: '#1d1b2e', accent: '#ffb547', ink: '#fbf5ea' } as const
61
+
62
+ /** `#rgb` or `#rrggbb` as [r, g, b], 0 to 255; null for any other colour text */
63
+ export function rgbOf(c: string): [number, number, number] | null {
64
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim())
65
+ if (!m) return null
66
+ const h = m[1]!.length === 3 ? [...m[1]!].map((x) => x + x).join('') : m[1]!
67
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number]
68
+ }
69
+
70
+ const hex = (rgb: readonly number[]): string => `#${rgb.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('')}`
71
+
72
+ /** the colour mixed toward black by `k`, 0 to 1; a colour that is not hex stays as it is */
73
+ export function darker(c: string, k: number): string {
74
+ const rgb = rgbOf(c)
75
+ return rgb ? hex(rgb.map((v) => v * (1 - k))) : c
76
+ }
77
+
78
+ /** relative luminance, 0 dark to 1 light; 0.5 for a colour that is not hex */
79
+ export function luminance(c: string): number {
80
+ const rgb = rgbOf(c)
81
+ if (!rgb) return 0.5
82
+ const [r, g, b] = rgb.map((v) => {
83
+ const s = v / 255
84
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
85
+ }) as [number, number, number]
86
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
87
+ }
88
+
89
+ /** the colour with an alpha, as canvas reads it; a colour that is not hex stays as it is */
90
+ export function alpha(c: string, a: number): string {
91
+ const rgb = rgbOf(c)
92
+ return rgb ? `rgba(${rgb.join(',')},${a})` : c
93
+ }
94
+
95
+ /** the look of a brand, every value its own or derived from its palette; `webLogo` the web root's logo.png when present */
96
+ export function lookOf(brand: BrandLike | null, webLogo: string | null = null): TrailerLook {
97
+ const p = brand?.palette ?? HOUSE_PALETTE
98
+ const i = brand?.trailer ?? {}
99
+ const ground: [string, string] = i.ground ?? [p.bg, darker(p.bg, luminance(p.bg) > 0.5 ? 0.25 : 0.45)]
100
+ return {
101
+ 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,
105
+ plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink },
106
+ 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
+ font: { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 },
108
+ logo: i.logo ?? webLogo,
109
+ }
110
+ }
@@ -0,0 +1,133 @@
1
+ // what a build does, as data: each shot's take and its key, which takes film, the pieces the stitch renders. Pure: the
2
+ // io (stamps on disk, the world's git stamp, ffmpeg) lives in footage.ts and stitch.ts, which read these answers
3
+ import type { Film, Shot, Trailer } from './grammar.ts'
4
+ import { starts } from './grammar.ts'
5
+
6
+ /** bump when the core films differently from the same line: every core take films again */
7
+ export const FILM_VERSION = 1
8
+
9
+ /**
10
+ * the key of a take: what films, never how the cut uses it. The formula of pkm-multi's `footageKey`, verbatim, so a
11
+ * take filmed there under the same version, recipe, size, fps and seconds keeps its file
12
+ */
13
+ export function footageKey(version: number | readonly number[], recipe: unknown, size: readonly [number, number], fps: number, seconds: number | null): string {
14
+ return new Bun.CryptoHasher('sha1').update(JSON.stringify([version, recipe, size, fps, seconds])).digest('hex').slice(0, 12)
15
+ }
16
+
17
+ /** a film that writes a take of its own: the others are read where they lie (a clip, an image) or drawn (a card) */
18
+ export type TakeFilm = Extract<Film, { kind: 'run' | 'cli' | 'url' }>
19
+ export const isTake = (f: Film | null): f is TakeFilm => f?.kind === 'run' || f?.kind === 'cli' || f?.kind === 'url'
20
+
21
+ /** the take is one picture, held for the shot */
22
+ export const takeIsStill = (f: TakeFilm): boolean => (f.kind === 'cli' ? f.output === 'still' : f.still === true)
23
+
24
+ /** the seconds a clip take films: what the shot shows, rounded up to 2 s, so a small edit never films again; a still: none */
25
+ export function takeSeconds(s: Pick<Shot, 'from' | 'length'>, f: TakeFilm): number | null {
26
+ return takeIsStill(f) ? null : Math.ceil((s.from + s.length) / 2) * 2
27
+ }
28
+
29
+ /** the film line as its key reads it: the line it sits on is never part of what films */
30
+ export function recipeOf(f: TakeFilm): Omit<TakeFilm, 'line'> {
31
+ const { line: _line, ...recipe } = f
32
+ return recipe
33
+ }
34
+
35
+ /** the take's file name, by its key alone: shots on one film line, in one cut or in several, share one file */
36
+ export function takeName(s: Pick<Shot, 'from' | 'length'>, f: TakeFilm, t: Pick<Trailer, 'size' | 'fps'>): string {
37
+ return `${footageKey(FILM_VERSION, recipeOf(f), t.size, t.fps, takeSeconds(s, f))}.${takeIsStill(f) ? 'png' : 'mp4'}`
38
+ }
39
+
40
+ /** the world a take was filmed on, beside it */
41
+ export interface Stamp {
42
+ /** the repo's state when it filmed (footage.ts worldStamp) */
43
+ world: string
44
+ filmedAt: string
45
+ /** the take's own length, seconds; 0 for a still */
46
+ seconds: number
47
+ /** what the film said of it, its first line */
48
+ note: string
49
+ }
50
+
51
+ export const fresh = (stamp: Stamp, world: string): boolean => stamp.world === world
52
+
53
+ /** a take as a build sees it: its shot, its file, its stamp when it was filmed */
54
+ export interface Take {
55
+ shot: Shot
56
+ film: TakeFilm
57
+ file: string
58
+ stamp: Stamp | null
59
+ }
60
+
61
+ /** `--refilm`: every stale take, or the takes of the named shots */
62
+ export type Refilm = 'stale' | ReadonlySet<string> | null
63
+
64
+ /** the takes to film, each group sharing one file and filmed once by its first shot: a missing take, a stale one under
65
+ * `--refilm`, or one whose shot `--refilm` names */
66
+ export function takesToFilm(takes: readonly Take[], world: string, refilm: Refilm, only?: ReadonlySet<string>): Take[][] {
67
+ const groups = new Map<string, Take[]>()
68
+ for (const tk of takes) {
69
+ if (only !== undefined && !only.has(tk.shot.name)) continue
70
+ const g = groups.get(tk.file)
71
+ if (g) g.push(tk)
72
+ else groups.set(tk.file, [tk])
73
+ }
74
+ return [...groups.values()].filter((g) => {
75
+ const first = g[0]!
76
+ if (first.stamp === null) return true
77
+ if (refilm === 'stale') return !fresh(first.stamp, world)
78
+ return refilm instanceof Set && g.some((tk) => refilm.has(tk.shot.name))
79
+ })
80
+ }
81
+
82
+ /** the argv a project CLI film runs: its words, then the file and, for a clip, its seconds */
83
+ export function cliArgv(cli: readonly string[], f: Extract<Film, { kind: 'cli' }>, out: string, seconds: number | null): string[] {
84
+ return [...cli, ...f.argv, '--out', out, ...(seconds === null ? [] : ['--seconds', String(seconds)])]
85
+ }
86
+
87
+ /** a `film run` command with its marks filled: `{out}`, `{seconds}`, `{w}`, `{h}`, `{fps}` */
88
+ export function runCommand(f: Extract<Film, { kind: 'run' }>, o: { out: string; seconds: number | null; size: readonly [number, number]; fps: number }): string {
89
+ const q = (s: string): string => `'${s.replaceAll("'", "'\\''")}'`
90
+ return f.cmd
91
+ .replaceAll('{out}', q(o.out))
92
+ .replaceAll('{seconds}', String(o.seconds ?? 0))
93
+ .replaceAll('{w}', String(o.size[0]))
94
+ .replaceAll('{h}', String(o.size[1]))
95
+ .replaceAll('{fps}', String(o.fps))
96
+ }
97
+
98
+ /** the first frame at or after a time: a shot, a cut or a title starts on it */
99
+ export const frameAt = (seconds: number, fps: number): number => Math.ceil(seconds * fps - 1e-6) || 0
100
+
101
+ /** each shot's frames on the cut, [a, b): a cut's frames are those both shots hold; a hard cut's shots share none */
102
+ export function frameSpans(t: Pick<Trailer, 'shots' | 'fps'>): { a: number; b: number }[] {
103
+ const st = starts(t)
104
+ return t.shots.map((s, i) => ({ a: frameAt(st[i]!, t.fps), b: frameAt(st[i]! + s.length, t.fps) }))
105
+ }
106
+
107
+ /** a piece of the cut rendered alone: a section, or the shots before the first section (`start`) */
108
+ export interface Piece {
109
+ name: string
110
+ /** its shots, [first, end) */
111
+ first: number
112
+ end: number
113
+ /** the first shot its graph draws: the shot before `first` when the cut into the section overlaps it */
114
+ chain: number
115
+ /** its frames on the cut, [from, to) */
116
+ from: number
117
+ to: number
118
+ }
119
+
120
+ export const START_PIECE = 'start'
121
+
122
+ /** the cut split at its sections; the pieces' frames tile the cut */
123
+ export function pieces(t: Pick<Trailer, 'shots' | 'fps'>): Piece[] {
124
+ const spans = frameSpans(t)
125
+ const opens = t.shots.flatMap((s, i) => (i > 0 && s.section ? [i] : []))
126
+ const firsts = [0, ...opens]
127
+ return firsts.map((first, k) => {
128
+ const end = firsts[k + 1] ?? t.shots.length
129
+ const from = spans[first]!.a
130
+ const chain = first > 0 && spans[first - 1]!.b > from ? first - 1 : first
131
+ return { name: k === 0 ? (t.shots[0]?.section?.name ?? START_PIECE) : t.shots[first]!.section!.name, first, end, chain, from, to: end < t.shots.length ? spans[end]!.a : spans.at(-1)!.b }
132
+ })
133
+ }