rvlib-shipkit 2.2.0 → 2.4.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 +47 -0
- package/package.json +1 -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 +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -4
- 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/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -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 +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -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 +183 -0
- package/src/review/watchers.ts +54 -0
- 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/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- 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 +83 -14
- package/src/serve/hub-app/icons.tsx +15 -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/md.ts +32 -0
- 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 +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -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 +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -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 +389 -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-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
package/src/trailer/graph.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// the stitch's ffmpeg arguments, as data: a piece of the cut (its shots cut to their frames, joined by their cuts, the
|
|
2
2
|
// titles laid over) and the mix of the sound. Pure: stitch.ts runs them, keyed on these very arguments
|
|
3
3
|
import type { CutKind, Shot, Trailer } from './grammar.ts'
|
|
4
|
-
import { musicCues, starts, total } from './grammar.ts'
|
|
4
|
+
import { CUTS, GROW_S, musicCues, starts, total } from './grammar.ts'
|
|
5
5
|
import type { TrailerLook } from './look.ts'
|
|
6
6
|
import { frameAt, frameSpans, type Piece } from './plan.ts'
|
|
7
|
+
import { inFrames, KNOCK_S, outFrames, type TitleMotion, titleMotion } from './motion.ts'
|
|
7
8
|
import type { TitleImage } from './titles.ts'
|
|
8
9
|
|
|
9
10
|
/** a wipe's edge, as xfade draws it: along x or y, starting from the far side (`from`) or the near one */
|
|
@@ -14,26 +15,29 @@ const SEAM_EDGE: Partial<Record<CutKind, { axis: 'x' | 'y'; from: boolean }>> =
|
|
|
14
15
|
'wipe-down': { axis: 'y', from: false },
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
'
|
|
24
|
-
|
|
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',
|
|
18
|
+
/** a grid's or a split's cell: a shot's or a footage block's take, from its `from`, cropped by its `frame` */
|
|
19
|
+
export interface GridCell {
|
|
20
|
+
footage: string
|
|
21
|
+
seconds: number
|
|
22
|
+
from: number
|
|
23
|
+
frame: number | null
|
|
24
|
+
/** where a grid's cell crops, each 0 to 1 across and down its take; absent, its middle */
|
|
25
|
+
at?: readonly [number, number]
|
|
33
26
|
}
|
|
34
27
|
|
|
35
|
-
/**
|
|
36
|
-
export
|
|
28
|
+
/** a split's drawn pieces: each pane's shape (white on black) and the seams laid over them (stitch.ts splitMasks) */
|
|
29
|
+
export interface SplitMasks {
|
|
30
|
+
panes: string[]
|
|
31
|
+
seams: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 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
|
|
35
|
+
* other takes laid under its frame (stitch.ts gridMask, splitMasks) */
|
|
36
|
+
export type Source =
|
|
37
|
+
| { footage: string; seconds: number }
|
|
38
|
+
| { image: string }
|
|
39
|
+
| { grid: GridCell[]; pop: number; grow?: [number, number]; mask: string }
|
|
40
|
+
| { split: GridCell[]; angle: number; slide: number; masks: SplitMasks }
|
|
37
41
|
|
|
38
42
|
export interface StitchInput {
|
|
39
43
|
t: Trailer
|
|
@@ -50,6 +54,81 @@ export interface StitchInput {
|
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
const n3 = (x: number): string => (+x.toFixed(4)).toString()
|
|
57
|
+
const even = (x: number): number => 2 * Math.round(x / 2)
|
|
58
|
+
|
|
59
|
+
/** the grid's margin round the cells and the gap between them, and the corners' radius, in frame heights */
|
|
60
|
+
export const GRID_MARGIN = 0.03
|
|
61
|
+
export const GRID_GAP = 0.014
|
|
62
|
+
export const GRID_RADIUS = 0.022
|
|
63
|
+
/** a cell's pop: its length, seconds, and the overshoot of its ease */
|
|
64
|
+
const POP_S = 0.22
|
|
65
|
+
const POP_BACK = 1.70158
|
|
66
|
+
/** a split's pane j slides in this long after pane j - 1 */
|
|
67
|
+
const SPLIT_STAGGER = 0.08
|
|
68
|
+
/** how much bigger a pane's take is drawn than the frame, so its middle can move to the pane's middle */
|
|
69
|
+
export const SPLIT_ZOOM = 1.3
|
|
70
|
+
/** a growing grid is laid this many times the frame's size, so the one cell it opens on fills the frame sharp */
|
|
71
|
+
export const GROW_RES = 3
|
|
72
|
+
|
|
73
|
+
/** 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 */
|
|
74
|
+
export function gridLayout(n: number, W: number, H: number): { x: number; y: number; w: number; h: number }[] {
|
|
75
|
+
const cols = n <= 1 ? 1 : n <= 4 ? 2 : 3
|
|
76
|
+
const rows = Math.max(1, Math.ceil(n / cols))
|
|
77
|
+
const m = H * GRID_MARGIN
|
|
78
|
+
const g = H * GRID_GAP
|
|
79
|
+
const cw = (W - 2 * m - (cols - 1) * g) / cols
|
|
80
|
+
const ch = (H - 2 * m - (rows - 1) * g) / rows
|
|
81
|
+
return Array.from({ length: n }, (_, i) => {
|
|
82
|
+
const [c, r] = [i % cols, Math.floor(i / cols)]
|
|
83
|
+
const x = even(m + c * (cw + g))
|
|
84
|
+
const y = even(m + r * (ch + g))
|
|
85
|
+
return { x, y, w: even(m + c * (cw + g) + cw) - x, h: even(m + r * (ch + g) + ch) - y }
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** a split's pane j of `n`: the frame cut by seams through (W i / n, H / 2), each leaning `angle` degrees from
|
|
90
|
+
* upright, its top toward the right (two panes at 56: top left and bottom right). Its outline (it may run past the
|
|
91
|
+
* frame, which clips it), and its middle, where its take centres */
|
|
92
|
+
export function splitPane(n: number, j: number, angle: number, W: number, H: number): { points: [number, number][]; cx: number; cy: number } {
|
|
93
|
+
const t = Math.tan((angle * Math.PI) / 180)
|
|
94
|
+
const far = W + H * (1 + t)
|
|
95
|
+
const seam = (i: number, y: number): number => (i <= 0 ? -far : i >= n ? W + far : (W * i) / n + (H / 2 - y) * t)
|
|
96
|
+
const points: [number, number][] = [
|
|
97
|
+
[seam(j, 0), 0],
|
|
98
|
+
[seam(j + 1, 0), 0],
|
|
99
|
+
[seam(j + 1, H), H],
|
|
100
|
+
[seam(j, H), H],
|
|
101
|
+
]
|
|
102
|
+
// its middle: the mean of a fine grid's points inside it
|
|
103
|
+
let [sx, sy, k] = [0, 0, 0]
|
|
104
|
+
for (let gy = 0.5; gy < 36; gy++)
|
|
105
|
+
for (let gx = 0.5; gx < 64; gx++) {
|
|
106
|
+
const [x, y] = [(gx * W) / 64, (gy * H) / 36]
|
|
107
|
+
if (x >= seam(j, y) && x < seam(j + 1, y)) [sx, sy, k] = [sx + x, sy + y, k + 1]
|
|
108
|
+
}
|
|
109
|
+
return { points, cx: k ? sx / k : W / 2, cy: k ? sy / k : H / 2 }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 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 */
|
|
113
|
+
function growZoom(cells: { x: number; y: number; w: number; h: number }[], grow: [number, number], W: number, H: number): { z: string; x: string; y: string } {
|
|
114
|
+
const c0 = cells[0]!
|
|
115
|
+
const c4 = cells[4]!
|
|
116
|
+
const pad = cells[1]!.x - (c0.x + c0.w)
|
|
117
|
+
const fit = (x0: number, y0: number, x1: number, y1: number): [number, number, number] => {
|
|
118
|
+
// the frame's aspect round the rectangle, a little margin, centred on it
|
|
119
|
+
const w = Math.max(x1 - x0 + pad, ((y1 - y0 + pad) * W) / H)
|
|
120
|
+
return [(x0 + x1) / 2, (y0 + y1) / 2, w]
|
|
121
|
+
}
|
|
122
|
+
const one = fit(c0.x, c0.y, c0.x + c0.w, c0.y + c0.h)
|
|
123
|
+
const four = fit(c0.x, c0.y, c4.x + c4.w, c4.y + c4.h)
|
|
124
|
+
const all: [number, number, number] = [W / 2, H / 2, W]
|
|
125
|
+
// each move eases in and out over GROW_S, ending on its settle
|
|
126
|
+
const ease = (end: number): string => `(1-cos(PI*clip((it-${n3(end - GROW_S)})/${GROW_S},0,1)))/2`
|
|
127
|
+
const [a, b] = [ease(grow[0]), ease(grow[1])]
|
|
128
|
+
const lerp = (k: 0 | 1 | 2): string => `(${n3(one[k])}+(${n3(four[k] - one[k])})*${a}+(${n3(all[k] - four[k])})*${b})`
|
|
129
|
+
const w = lerp(2)
|
|
130
|
+
return { z: `${W}/${w}`, x: `${lerp(0)}-${w}/2`, y: `${lerp(1)}-${w}*${n3(H / W)}/2` }
|
|
131
|
+
}
|
|
53
132
|
/** ffmpeg's colour syntax for a hex colour */
|
|
54
133
|
const ff = (c: string): string => (c.startsWith('#') ? `0x${c.slice(1)}` : c)
|
|
55
134
|
|
|
@@ -102,9 +181,107 @@ function flashF(s: Shot): string {
|
|
|
102
181
|
.join('')
|
|
103
182
|
}
|
|
104
183
|
|
|
105
|
-
/** a
|
|
106
|
-
const
|
|
107
|
-
const
|
|
184
|
+
/** a frame's own pseudo random number, 0 to 1: the same cut draws the same frames */
|
|
185
|
+
const rnd = (...xs: number[]): number => {
|
|
186
|
+
const v = Math.sin(xs.reduce((n, x, j) => n + x * [12.9898, 78.233, 37.719, 4.581][j % 4]!, 0)) * 43758.5453
|
|
187
|
+
return v - Math.floor(v)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** the cuts the core draws, frame by frame over the frames both shots hold: `a` the shot leaving, `b` the one coming,
|
|
191
|
+
* `n` frames, the result labelled `out`. A whip pans both shots past at speed, blurred along the way they fly; a
|
|
192
|
+
* glitch tears the picture (colours split, bands torn sideways, grain) round a flicker from one shot to the next; a
|
|
193
|
+
* leak burns two soft lights of the look across a dissolve */
|
|
194
|
+
export function drawnCut(kind: CutKind, a: string, b: string, out: string, n: number, i: number, [W, H]: readonly [number, number], fps: number, look: Pick<TrailerLook, 'accent' | 'light'>): string[] {
|
|
195
|
+
const d = n / fps
|
|
196
|
+
if (kind === 'leak') {
|
|
197
|
+
const big = `s=${2 * W}x${H}:r=${fps}:d=${n3(d + 0.2)}:type=radial:speed=0`
|
|
198
|
+
const [y1, y2, r1, r2] = [Math.round(H * 0.4), Math.round(H * 0.85), Math.round(W * 0.45), Math.round(W * 0.3)]
|
|
199
|
+
return [
|
|
200
|
+
`[${a}][${b}]xfade=transition=fade:duration=${n3(d)}:offset=0,format=gbrp[lx${i}]`,
|
|
201
|
+
`gradients=${big}:n=3:c0=0xffdcaa:c1=${ff(look.accent)}:c2=0x000000:x0=${W}:y0=${y1}:x1=${W + r1}:y1=${y1},format=gbrp,crop=w=${W}:h=${H}:x='${W}*(1.1-t/${n3(d)})':y=0[la${i}]`,
|
|
202
|
+
`gradients=${big}:n=2:c0=0xff5a46:c1=0x000000:x0=${W}:y0=${y2}:x1=${W + r2}:y1=${y2},format=gbrp,crop=w=${W}:h=${H}:x='${W}*(0.25+0.6*t/${n3(d)})':y=0[lb${i}]`,
|
|
203
|
+
`[la${i}][lb${i}]blend=all_mode=screen,colorchannelmixer=rr=0.8:gg=0.8:bb=0.8,fade=t=in:st=0:d=${n3(d / 2)},fade=t=out:st=${n3(d / 2)}:d=${n3(d / 2)}[ll${i}]`,
|
|
204
|
+
`[lx${i}][ll${i}]blend=all_mode=screen:shortest=1,format=yuv420p[${out}]`,
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
const ks = Array.from({ length: n }, (_, k) => k)
|
|
208
|
+
const g = [
|
|
209
|
+
`[${a}]split=${n}${ks.map((k) => `[ca${i}_${k}x]`).join('')}`,
|
|
210
|
+
`[${b}]split=${n}${ks.map((k) => `[cb${i}_${k}x]`).join('')}`,
|
|
211
|
+
...ks.flatMap((k) => [`[ca${i}_${k}x]trim=start_frame=${k}:end_frame=${k + 1},setpts=PTS-STARTPTS[ca${i}_${k}]`, `[cb${i}_${k}x]trim=start_frame=${k}:end_frame=${k + 1},setpts=PTS-STARTPTS[cb${i}_${k}]`]),
|
|
212
|
+
]
|
|
213
|
+
for (const k of ks) {
|
|
214
|
+
const [fa, fb, fo] = [`ca${i}_${k}`, `cb${i}_${k}`, `co${i}_${k}`]
|
|
215
|
+
const p = (k + 0.5) / n
|
|
216
|
+
if (kind.startsWith('whip-')) {
|
|
217
|
+
// eased in and out: the speed peaks mid way, and the blur with it (a box as wide as a frame's travel)
|
|
218
|
+
const u = p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2
|
|
219
|
+
const across = kind === 'whip-left' || kind === 'whip-right'
|
|
220
|
+
const span = across ? W : H
|
|
221
|
+
const sigma = Math.min(600, (12 * Math.min(p, 1 - p) ** 2 * span * 0.35) / n)
|
|
222
|
+
const forward = kind === 'whip-left' || kind === 'whip-up'
|
|
223
|
+
const off = Math.round((forward ? u : 1 - u) * span)
|
|
224
|
+
const pair = forward ? `[${fa}][${fb}]` : `[${fb}][${fa}]`
|
|
225
|
+
g.push(`${pair}${across ? 'hstack' : 'vstack'}=inputs=2,crop=${W}:${H}:${across ? off : 0}:${across ? 0 : off},gblur=sigma=${n3(across ? sigma : 0)}:sigmaV=${n3(across ? 0 : sigma)}[${fo}]`)
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
// glitch: the picture flickers from one shot to the other round the middle, torn hardest there
|
|
229
|
+
const env = Math.sin(Math.PI * p) ** 0.7
|
|
230
|
+
const flick = p > 0.3 && p < 0.7 && rnd(i, k, 9) < 0.45
|
|
231
|
+
const src = (p < 0.5) !== flick ? fa : fb
|
|
232
|
+
g.push(`[${src === fa ? fb : fa}]nullsink`)
|
|
233
|
+
const dx = Math.round(W * 0.016 * env * (0.4 + rnd(i, k, 1)))
|
|
234
|
+
const bands = 1 + Math.floor(rnd(i, k, 2) * 3)
|
|
235
|
+
let cur = `[${src}]format=gbrp,rgbashift=rh=${dx}:bh=${-dx}:gv=${Math.round(dx / 3)}:edge=smear`
|
|
236
|
+
const parts: string[] = []
|
|
237
|
+
for (let j = 0; j < bands; j++) {
|
|
238
|
+
const bh = 2 * Math.round((H * (0.03 + 0.1 * rnd(i, k, j, 3))) / 2)
|
|
239
|
+
const by = Math.round((H - bh) * rnd(i, k, j, 4))
|
|
240
|
+
const shift = Math.round((rnd(i, k, j, 5) - 0.5) * W * 0.16 * env)
|
|
241
|
+
parts.push(`${cur},split[gb${i}_${k}_${j}][gs${i}_${k}_${j}]`, `[gs${i}_${k}_${j}]crop=${W}:${bh}:0:${by}[gt${i}_${k}_${j}]`)
|
|
242
|
+
cur = `[gb${i}_${k}_${j}][gt${i}_${k}_${j}]overlay=x=${shift}:y=${by}`
|
|
243
|
+
}
|
|
244
|
+
g.push(...parts, `${cur},noise=alls=${Math.round(28 * env)}:allf=t+u:all_seed=${i * 977 + k},format=yuv420p[${fo}]`)
|
|
245
|
+
}
|
|
246
|
+
g.push(`${ks.map((k) => `[co${i}_${k}]`).join('')}concat=n=${n}:v=1:a=0,settb=1/${fps},setpts=N,format=yuv420p[${out}]`)
|
|
247
|
+
return g
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** each title's span on the cut, shot by shot: its first and last frame (inclusive) and its seconds, a knock's flight
|
|
251
|
+
* in them. A progress step ends the frame before the next one starts: two steps never stand on one frame */
|
|
252
|
+
export function titleSpans(t: Pick<Trailer, 'shots' | 'fps'> & Parameters<typeof starts>[0]): { ta: number; te: number; seconds: number }[][] {
|
|
253
|
+
const st = starts(t)
|
|
254
|
+
return t.shots.map((s, i) =>
|
|
255
|
+
s.titles.map((ti) => {
|
|
256
|
+
const at = st[i]! + ti.at
|
|
257
|
+
const seconds = Math.min(ti.for ?? s.length - ti.at, ti.knock === undefined ? Infinity : ti.knock - ti.at + KNOCK_S)
|
|
258
|
+
const handOff = ti.style === 'progress' && ti.step !== undefined && ti.step.i < ti.step.n ? 1 : 0
|
|
259
|
+
return { ta: frameAt(at, t.fps), te: Math.floor((at + seconds) * t.fps + 1e-6) - handOff, seconds }
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** each title's entrance and exit in frames (motion.ts), shot by shot: what the build draws and the stitch lays */
|
|
265
|
+
export function titleMotions(t: Pick<Trailer, 'shots' | 'fps'> & Parameters<typeof starts>[0]): TitleMotion[][] {
|
|
266
|
+
const spans = titleSpans(t)
|
|
267
|
+
return t.shots.map((s, i) =>
|
|
268
|
+
s.titles.map((ti, j) => {
|
|
269
|
+
const sp = spans[i]![j]!
|
|
270
|
+
return titleMotion({
|
|
271
|
+
style: ti.style,
|
|
272
|
+
text: ti.text,
|
|
273
|
+
...(ti.in ? { in: ti.in } : {}),
|
|
274
|
+
...(ti.out ? { out: ti.out } : {}),
|
|
275
|
+
...(ti.knock !== undefined ? { knock: ti.knock - ti.at } : {}),
|
|
276
|
+
...(ti.step ? { step: ti.step } : {}),
|
|
277
|
+
seconds: sp.seconds,
|
|
278
|
+
frames: Math.max(0, sp.te - sp.ta + 1),
|
|
279
|
+
fps: t.fps,
|
|
280
|
+
cover: ti.style === 'short' && sp.ta === 0,
|
|
281
|
+
})
|
|
282
|
+
}),
|
|
283
|
+
)
|
|
284
|
+
}
|
|
108
285
|
|
|
109
286
|
/** the ffmpeg arguments that render a piece's frames, no sound, and the row of its shots' middle frames for the
|
|
110
287
|
* contact strip (`thumbs`, when a middle falls in it) */
|
|
@@ -143,7 +320,74 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
|
|
|
143
320
|
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
321
|
const frameF = cropF ? `,${cropF},scale=${W}:${H}` : ''
|
|
145
322
|
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 ('
|
|
323
|
+
if ('grid' in src) {
|
|
324
|
+
// the cells, each its take from its own `from`, cropped to fill its place and held on its last frame, laid on
|
|
325
|
+
// the slots' ground under the grid's frame; cell j pops in at j times `pop`, its take starting then. Then the
|
|
326
|
+
// shot's own frame, push and shakes as any footage
|
|
327
|
+
const G = src.grow ? GROW_RES : 1
|
|
328
|
+
const cells = gridLayout(src.grid.length, W * G, H * G)
|
|
329
|
+
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)}`)
|
|
330
|
+
graph.push(`[${ground}:v]format=yuv420p[g${i}_0]`)
|
|
331
|
+
src.grid.forEach((c, j) => {
|
|
332
|
+
const at = cells[j]!
|
|
333
|
+
const T0 = Math.min(len, j * src.pop)
|
|
334
|
+
const run = len - T0
|
|
335
|
+
const avail = Math.max(0, c.seconds - c.from)
|
|
336
|
+
const k = input(...DECODE, '-ss', n3(c.from), '-t', n3(Math.min(avail, run) + 0.1), '-i', c.footage)
|
|
337
|
+
const crop = !c.frame || c.frame <= 1 ? '' : c.at
|
|
338
|
+
? `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)',`
|
|
339
|
+
: `crop=iw/${n3(c.frame)}:ih/${n3(c.frame)},`
|
|
340
|
+
const fit = `${crop}scale=${at.w}:${at.h}:force_original_aspect_ratio=increase:${FULL_601},crop=${at.w}:${at.h},setsar=1`
|
|
341
|
+
if (src.pop <= 0) {
|
|
342
|
+
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}]`)
|
|
343
|
+
graph.push(`[g${i}_${j}][c${i}_${j}]overlay=x=${at.x}:y=${at.y}:eof_action=repeat[g${i}_${j + 1}]`)
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
// the pop: grown from 55 % to a hair past whole and back (an ease out with overshoot), faded in
|
|
347
|
+
const x = `clip((t-${n3(T0)})/${POP_S},0,1)`
|
|
348
|
+
const K = `(0.55+0.45*(1+${n3(POP_BACK + 1)}*pow(${x}-1,3)+${POP_BACK}*pow(${x}-1,2)))`
|
|
349
|
+
graph.push(
|
|
350
|
+
`[${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,` +
|
|
351
|
+
`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}]`,
|
|
352
|
+
)
|
|
353
|
+
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}]`)
|
|
354
|
+
})
|
|
355
|
+
const m = input('-loop', '1', '-framerate', String(fps), '-t', n3(len + 0.1), '-i', src.mask)
|
|
356
|
+
// 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
|
|
357
|
+
const zoom = src.grow ? growZoom(cells, src.grow, W * G, H * G) : null
|
|
358
|
+
const grow = zoom ? `,zoompan=z='${zoom.z}':x='${zoom.x}':y='${zoom.y}':d=1:s=${W}x${H}:fps=${fps}` : ''
|
|
359
|
+
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}`)
|
|
360
|
+
} else if ('split' in src) {
|
|
361
|
+
// each pane its take drawn a little bigger and centred on the pane's middle, cut to its shape, sliding in from
|
|
362
|
+
// its side with pane j a beat after pane j - 1; the seams laid over all; the ground where no pane shows yet
|
|
363
|
+
const ground = input('-f', 'lavfi', '-i', `color=c=${ff(a.look.edge)}:s=${W}x${H}:r=${fps}:d=${n3(len + 0.1)}`)
|
|
364
|
+
graph.push(`[${ground}:v]format=yuv420p[p${i}_0]`)
|
|
365
|
+
const [BW, BH] = [even(W * SPLIT_ZOOM), even(H * SPLIT_ZOOM)]
|
|
366
|
+
src.split.forEach((c, j) => {
|
|
367
|
+
const pane = splitPane(src.split.length, j, src.angle, W, H)
|
|
368
|
+
const ox = Math.round(Math.min(BW - W, Math.max(0, BW / 2 - pane.cx)))
|
|
369
|
+
const oy = Math.round(Math.min(BH - H, Math.max(0, BH / 2 - pane.cy)))
|
|
370
|
+
const avail = Math.max(0, c.seconds - c.from)
|
|
371
|
+
const k = input(...DECODE, '-ss', n3(c.from), '-t', n3(Math.min(avail, len) + 0.1), '-i', c.footage)
|
|
372
|
+
const m = input('-loop', '1', '-framerate', String(fps), '-t', n3(len + 0.1), '-i', src.masks.panes[j]!)
|
|
373
|
+
const crop = c.frame && c.frame > 1 ? `crop=iw/${n3(c.frame)}:ih/${n3(c.frame)},` : ''
|
|
374
|
+
graph.push(
|
|
375
|
+
`[${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}]`,
|
|
376
|
+
`[${m}:v]format=gray,scale=${W}:${H}[pm${i}_${j}]`,
|
|
377
|
+
`[pf${i}_${j}][pm${i}_${j}]alphamerge[pa${i}_${j}]`,
|
|
378
|
+
)
|
|
379
|
+
// it slides in from the side its middle is on, eased out, from a pane's width away
|
|
380
|
+
const T0 = j * SPLIT_STAGGER
|
|
381
|
+
const side = pane.cx < W / 2 - 1 ? -1 : pane.cx > W / 2 + 1 ? 1 : 0
|
|
382
|
+
const x = src.slide > 0 ? `clip((t-${n3(T0)})/${n3(src.slide)},0,1)` : '1'
|
|
383
|
+
const ease = `(1-pow(1-${x},3))`
|
|
384
|
+
const dx = side !== 0 ? `${n3(side * W * 0.7)}*(1-${ease})` : '0'
|
|
385
|
+
const dy = side === 0 ? `${n3(H * 0.8)}*(1-${ease})` : '0'
|
|
386
|
+
graph.push(`[p${i}_${j}][pa${i}_${j}]overlay=x='${dx}':y='${dy}':eval=frame:format=auto:eof_action=repeat[p${i}_${j + 1}]`)
|
|
387
|
+
})
|
|
388
|
+
const sm = input('-loop', '1', '-framerate', String(fps), '-t', n3(len + 0.1), '-i', src.masks.seams)
|
|
389
|
+
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}`)
|
|
390
|
+
} else if ('image' in src) {
|
|
147
391
|
// read at the cut's rate: a still read at the demuxer's 25 fps breaks the xfade of a cut into it
|
|
148
392
|
const k = input('-framerate', String(fps), '-i', src.image)
|
|
149
393
|
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}`)
|
|
@@ -177,7 +421,9 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
|
|
|
177
421
|
const kind = t.shots[i - 1]!.cut.kind as Exclude<CutKind, 'hard'>
|
|
178
422
|
const d = head / fps
|
|
179
423
|
const edge = SEAM_EDGE[kind]
|
|
180
|
-
|
|
424
|
+
const how = CUTS[kind] as string
|
|
425
|
+
if (how === 'drawn') graph.push(...drawnCut(kind, `e${i - 1}`, `h${i}`, `j${i}`, head, i, [W, H], fps, a.look))
|
|
426
|
+
else if (edge === undefined) graph.push(`[e${i - 1}][h${i}]xfade=transition=${how}:duration=${n3(d)}:offset=0,format=yuv420p[j${i}]`)
|
|
181
427
|
else {
|
|
182
428
|
// a wipe's seam rides its edge: a soft shadow of the look's edge, then a line of its light
|
|
183
429
|
const across = edge.axis === 'x'
|
|
@@ -186,7 +432,7 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
|
|
|
186
432
|
const at = (w: number): string => (across ? `x='${pos}-${w / 2}':y=0` : `x=0:y='${pos}-${w / 2}'`)
|
|
187
433
|
const [sw, lw] = [Math.round(H / 64), Math.max(4, Math.round(H / 300))]
|
|
188
434
|
graph.push(
|
|
189
|
-
`[e${i - 1}][h${i}]xfade=transition=${
|
|
435
|
+
`[e${i - 1}][h${i}]xfade=transition=${how}:duration=${n3(d)}:offset=0[jw${i}]`,
|
|
190
436
|
bar(`${ff(a.look.edge)}@0.35`, sw, 'ws'),
|
|
191
437
|
bar(ff(a.look.light), lw, 'wl'),
|
|
192
438
|
`[jw${i}][ws${i}]overlay=${at(sw)}:eval=frame:shortest=1[jv${i}]`,
|
|
@@ -210,32 +456,34 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
|
|
|
210
456
|
cur = 'ends'
|
|
211
457
|
}
|
|
212
458
|
|
|
213
|
-
// the titles that show in the piece, wherever their shot is: each
|
|
459
|
+
// the titles that show in the piece, wherever their shot is: each its entrance's frames, its still held, its exit's
|
|
460
|
+
// frames (titles.ts drew them all in one box), laid at that box
|
|
461
|
+
const tspans = titleSpans(t)
|
|
214
462
|
t.shots.forEach((s, i) => {
|
|
215
|
-
s.titles.forEach((
|
|
216
|
-
const
|
|
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)]
|
|
463
|
+
s.titles.forEach((_, j) => {
|
|
464
|
+
const { ta, te } = tspans[i]![j]!
|
|
219
465
|
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
466
|
const img = a.titles[i]![j]!
|
|
223
|
-
const
|
|
467
|
+
const [nIn, nOut] = [img.in ?? 0, img.out ?? 0]
|
|
468
|
+
const hold = te - ta + 1 - nIn - nOut
|
|
224
469
|
const [from, to] = [Math.max(ta, base), Math.min(te, p.to - 1)]
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
`[${
|
|
230
|
-
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
|
|
470
|
+
const segs: string[] = []
|
|
471
|
+
const seq = (pattern: string, n: number, tag: string): void => {
|
|
472
|
+
const k = input('-framerate', String(fps), '-start_number', '0', '-i', pattern)
|
|
473
|
+
graph.push(`[${k}:v]format=rgba,trim=end_frame=${n},settb=1/${fps},setpts=N[t${i}_${j}${tag}]`)
|
|
474
|
+
segs.push(`[t${i}_${j}${tag}]`)
|
|
475
|
+
}
|
|
476
|
+
if (nIn > 0) seq(inFrames(img.file), nIn, 'i')
|
|
477
|
+
if (hold > 0) {
|
|
478
|
+
const k = input('-i', img.file)
|
|
479
|
+
graph.push(`[${k}:v]format=rgba,loop=loop=${hold - 1}:size=1:start=0,settb=1/${fps},setpts=N[t${i}_${j}h]`)
|
|
480
|
+
segs.push(`[t${i}_${j}h]`)
|
|
481
|
+
}
|
|
482
|
+
if (nOut > 0) seq(outFrames(img.file), nOut, 'o')
|
|
483
|
+
const join = segs.length > 1 ? `${segs.join('')}concat=n=${segs.length}:v=1:a=0,` : `${segs[0]}`
|
|
484
|
+
graph.push(`${join}${segs.length > 1 ? '' : 'null,'}trim=start_frame=${from - ta}:end_frame=${to - ta + 1},settb=1/${fps},setpts=N+${from - base}[t${i}_${j}]`)
|
|
234
485
|
const on = `between(t,${n3((ta - base - 0.5) / fps)},${n3((te - base + 0.5) / fps)})`
|
|
235
|
-
|
|
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}]`)
|
|
486
|
+
graph.push(`[${cur}][t${i}_${j}]overlay=x=${img.x}:y=${img.y}:eof_action=pass:enable='${on}'[v${i}_${j}]`)
|
|
239
487
|
cur = `v${i}_${j}`
|
|
240
488
|
})
|
|
241
489
|
})
|
|
@@ -259,9 +507,17 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
|
|
|
259
507
|
]
|
|
260
508
|
}
|
|
261
509
|
|
|
510
|
+
/** the true peak the mix is held under, dBTP */
|
|
511
|
+
export const MIX_TRUE_PEAK = -1
|
|
512
|
+
/** what the limiter aims at below MIX_TRUE_PEAK: the aac encode after it overshoots by a few tenths */
|
|
513
|
+
const LIMIT_MARGIN_DB = 0.5
|
|
514
|
+
/** the limiter runs 4 times oversampled, so it sees the peaks between samples a player's converter makes */
|
|
515
|
+
const OVERSAMPLE = 4
|
|
516
|
+
|
|
262
517
|
/** 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
|
|
264
|
-
|
|
518
|
+
* and the voices on their times, the sum through a true peak limiter held under MIX_TRUE_PEAK. `trimDb` lowers the
|
|
519
|
+
* limited sum before the encode, for a mix whose aac encode still overshoots (stitch.ts) */
|
|
520
|
+
export function mixArgs(a: Pick<StitchInput, 't' | 'music' | 'sfx' | 'voices'>, out: string, trimDb = 0): string[] {
|
|
265
521
|
const { t } = a
|
|
266
522
|
const T = total(t)
|
|
267
523
|
const st = starts(t)
|
|
@@ -318,6 +574,9 @@ export function mixArgs(a: Pick<StitchInput, 't' | 'music' | 'sfx' | 'voices'>,
|
|
|
318
574
|
})
|
|
319
575
|
const silence = input('-f', 'lavfi', '-t', n3(T), '-i', 'anullsrc=r=48000:cl=stereo')
|
|
320
576
|
mix.push(`[${silence}:a]`)
|
|
321
|
-
|
|
577
|
+
// level=disabled: alimiter's default auto level lifts the output back to 0 dBFS, past the limit
|
|
578
|
+
const limit = n3(10 ** ((MIX_TRUE_PEAK - LIMIT_MARGIN_DB) / 20))
|
|
579
|
+
const limiter = `aresample=${48000 * OVERSAMPLE},alimiter=limit=${limit}:attack=1:release=60:level=disabled,aresample=48000${trimDb < 0 ? `,volume=${n3(trimDb)}dB` : ''}`
|
|
580
|
+
graph.push(`${mix.join('')}amix=inputs=${mix.length}:normalize=0:duration=longest,atrim=duration=${n3(T)},${limiter}[aout]`)
|
|
322
581
|
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
582
|
}
|
|
@@ -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
|
+
}
|
package/src/trailer/load-unit.ts
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
|
-
// the repo's trailer unit, found by the walk every unit file takes (core/discover-checks.ts)
|
|
1
|
+
// the repo's trailer unit, found by the walk every unit file takes (core/discover-checks.ts). Only a file whose text
|
|
2
|
+
// calls `trailer(` is imported: `sk trailer check` stays fast in a repo of many units
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
2
4
|
import { importFresh, repoUnitFiles } from '../core/discover-checks.ts'
|
|
3
5
|
import type { TrailerUnit } from './adapter.ts'
|
|
4
6
|
|
|
5
|
-
|
|
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> {
|
|
7
23
|
const found: { file: string; unit: TrailerUnit }[] = []
|
|
8
24
|
for (const f of repoUnitFiles(root)) {
|
|
25
|
+
if (!/\btrailer\s*\(/.test(readFileSync(f, 'utf8'))) continue
|
|
9
26
|
const d = (await importFresh(f)) as { kind?: unknown } | undefined
|
|
10
27
|
if (d?.kind === 'trailer') found.push({ file: f, unit: d as TrailerUnit })
|
|
11
28
|
}
|
package/src/trailer/look.ts
CHANGED
|
@@ -18,8 +18,13 @@ export interface TrailerLookInput {
|
|
|
18
18
|
font?: { file?: string; family?: string; weight?: number }
|
|
19
19
|
/** the logo the `logo` title draws over its words, repo relative (default: logo.png at the web root, when present) */
|
|
20
20
|
logo?: string
|
|
21
|
+
/** the words of a short: the `short` and `progress` titles and the vertical's words, each value the look's when left out */
|
|
22
|
+
short?: { font?: { file?: string; family?: string; weight?: number }; light?: string; edge?: string; accent?: string }
|
|
21
23
|
/** the vertical short's look (a `vertical` cut), each part `false` to leave it out (vertical.ts) */
|
|
22
24
|
vertical?: VerticalLookInput
|
|
25
|
+
/** text styles by name, each a title line's words (`style big font "Bebas Neue" in letters`): a title's `style`
|
|
26
|
+
* names one; one named like a built in style gives that style its defaults (grammar.ts resolveStyles) */
|
|
27
|
+
styles?: Record<string, string>
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
/** what a vertical short wears, image paths repo relative; its colours and face are the look's */
|
|
@@ -44,6 +49,8 @@ export interface TrailerLook {
|
|
|
44
49
|
chip: { fill: string; ink: string }
|
|
45
50
|
font: { file: string | null; family: string; weight: number }
|
|
46
51
|
logo: string | null
|
|
52
|
+
/** the short's words: their face and colours */
|
|
53
|
+
short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string }
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
export interface BrandLike {
|
|
@@ -97,14 +104,24 @@ export function lookOf(brand: BrandLike | null, webLogo: string | null = null):
|
|
|
97
104
|
const p = brand?.palette ?? HOUSE_PALETTE
|
|
98
105
|
const i = brand?.trailer ?? {}
|
|
99
106
|
const ground: [string, string] = i.ground ?? [p.bg, darker(p.bg, luminance(p.bg) > 0.5 ? 0.25 : 0.45)]
|
|
107
|
+
const font = { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 }
|
|
108
|
+
const light = i.light ?? (luminance(p.ink) > 0.45 ? p.ink : '#ffffff')
|
|
109
|
+
const edge = i.edge ?? (luminance(ground[1]) < 0.2 ? ground[1] : darker(p.bg, 0.8))
|
|
110
|
+
const accent = i.accent ?? p.accent
|
|
111
|
+
const sh = i.short ?? {}
|
|
112
|
+
const shortFont = sh.font ? { file: sh.font.file ?? null, family: sh.font.family ?? (sh.font.file ? 'TrailerShortFace' : font.family), weight: sh.font.weight ?? font.weight } : font
|
|
100
113
|
return {
|
|
101
114
|
ground,
|
|
102
|
-
light
|
|
103
|
-
edge
|
|
104
|
-
accent
|
|
115
|
+
light,
|
|
116
|
+
edge,
|
|
117
|
+
accent,
|
|
105
118
|
plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink },
|
|
106
119
|
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
120
|
font: { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 },
|
|
108
121
|
logo: i.logo ?? webLogo,
|
|
122
|
+
short: { font: shortFont, light: sh.light ?? light, edge: sh.edge ?? edge, accent: sh.accent ?? accent },
|
|
109
123
|
}
|
|
110
124
|
}
|
|
125
|
+
|
|
126
|
+
/** the look a short's words wear: the look with the short's face and colours in place */
|
|
127
|
+
export const shortLook = (look: TrailerLook): TrailerLook => ({ ...look, font: look.short.font, light: look.short.light, edge: look.short.edge, accent: look.short.accent })
|