rvlib-shipkit 2.1.1 → 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.
- package/CHANGELOG.md +12 -0
- package/package.json +2 -1
- package/src/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +158 -43
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Trailer.ts +108 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +157 -0
- package/src/cli.ts +13 -2
- package/src/core/layout.ts +10 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +5 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/shipkit-files.ts +8 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +23 -0
- package/src/trailer/build.ts +344 -0
- package/src/trailer/ffmpeg.ts +50 -0
- package/src/trailer/footage.ts +104 -0
- package/src/trailer/grammar.ts +915 -0
- package/src/trailer/graph.ts +323 -0
- package/src/trailer/load-unit.ts +14 -0
- package/src/trailer/look.ts +110 -0
- package/src/trailer/plan.ts +133 -0
- package/src/trailer/stitch.ts +150 -0
- package/src/trailer/titles.ts +274 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +40 -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
|
+
}
|