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.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- 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 +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -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 +167 -0
- package/src/cli.ts +15 -2
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +12 -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 +15 -114
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -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/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +12 -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 +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +233 -0
- package/src/trailer/titles.ts +379 -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 +59 -0
- 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
|
+
}
|