rvlib-shipkit 2.2.0 → 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 +38 -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/cli/cmd_Commands.ts +5 -0
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +259 -6
- package/src/cli/kit/verb.ts +14 -4
- package/src/cli.ts +6 -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/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- 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/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 +9 -5
- package/src/trailer/adapter.ts +58 -3
- package/src/trailer/build.ts +300 -41
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/footage.ts +109 -40
- package/src/trailer/grammar.ts +444 -69
- package/src/trailer/graph.ts +199 -14
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +17 -3
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +80 -21
- 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 +92 -9
- package/src/trailer/titles.ts +123 -18
- package/src/trailer/vocab.ts +23 -4
- package/src/trailer/words.ts +254 -0
package/src/trailer/graph.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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 { 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
7
|
import type { TitleImage } from './titles.ts'
|
|
@@ -32,8 +32,29 @@ const XFADE: Record<Exclude<CutKind, 'hard'>, string> = {
|
|
|
32
32
|
pixel: 'pixelize',
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
export
|
|
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 }
|
|
37
58
|
|
|
38
59
|
export interface StitchInput {
|
|
39
60
|
t: Trailer
|
|
@@ -50,6 +71,81 @@ export interface StitchInput {
|
|
|
50
71
|
}
|
|
51
72
|
|
|
52
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
|
+
}
|
|
53
149
|
/** ffmpeg's colour syntax for a hex colour */
|
|
54
150
|
const ff = (c: string): string => (c.startsWith('#') ? `0x${c.slice(1)}` : c)
|
|
55
151
|
|
|
@@ -143,7 +239,74 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
|
|
|
143
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
|
|
144
240
|
const frameF = cropF ? `,${cropF},scale=${W}:${H}` : ''
|
|
145
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}]`
|
|
146
|
-
if ('
|
|
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) {
|
|
147
310
|
// read at the cut's rate: a still read at the demuxer's 25 fps breaks the xfade of a cut into it
|
|
148
311
|
const k = input('-framerate', String(fps), '-i', src.image)
|
|
149
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}`)
|
|
@@ -215,10 +378,19 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
|
|
|
215
378
|
s.titles.forEach((ti, j) => {
|
|
216
379
|
const at = st[i]! + ti.at
|
|
217
380
|
const dur = Math.min(ti.for ?? s.length - ti.at, ti.knock === undefined ? Infinity : ti.knock - ti.at + KNOCK_S)
|
|
218
|
-
|
|
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]
|
|
219
384
|
if (te < p.from || ta >= p.to || te < ta) return
|
|
220
|
-
// a tagline and the logo breathe in slowly; the plates pop
|
|
221
|
-
const
|
|
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
|
|
222
394
|
const img = a.titles[i]![j]!
|
|
223
395
|
const k = input('-i', img.file)
|
|
224
396
|
const [from, to] = [Math.max(ta, base), Math.min(te, p.to - 1)]
|
|
@@ -226,14 +398,16 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
|
|
|
226
398
|
const hyp = kn === null ? 0 : Math.ceil(Math.hypot(img.w, img.h))
|
|
227
399
|
const tip = kn === null ? '' : `rotate=a='-${KNOCK_TURN}*pow(max(0,t-${n3(kn)})/${KNOCK_S},2)':ow=${hyp}:oh=${hyp}:c=none,`
|
|
228
400
|
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
|
|
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,` : ''}` +
|
|
230
402
|
`trim=start_frame=${from - ta},settb=1/${fps},setpts=N+${from - base}[t${i}_${j}]`,
|
|
231
403
|
)
|
|
232
|
-
// it rises into place over its fade in, eased, by a fortieth of the frame; counted in frames
|
|
233
|
-
|
|
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)`
|
|
234
408
|
const on = `between(t,${n3((ta - base - 0.5) / fps)},${n3((te - base + 0.5) / fps)})`
|
|
235
409
|
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}`
|
|
410
|
+
const x = kn === null ? `${img.x}${slide}` : `${img.x - (hyp - img.w) / 2}-${n3(W * 1.1)}*${flown}`
|
|
237
411
|
const y = kn === null ? `${img.y}+${rise}` : `${img.y - (hyp - img.h) / 2}+${rise}-${n3(H * 0.45)}*${flown}`
|
|
238
412
|
graph.push(`[${cur}][t${i}_${j}]overlay=x='${x}':y='${y}':eof_action=pass:enable='${on}'[v${i}_${j}]`)
|
|
239
413
|
cur = `v${i}_${j}`
|
|
@@ -259,9 +433,17 @@ export function pieceArgs(a: Pick<StitchInput, 't' | 'sources' | 'titles' | 'loo
|
|
|
259
433
|
]
|
|
260
434
|
}
|
|
261
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
|
+
|
|
262
443
|
/** 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
|
-
|
|
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[] {
|
|
265
447
|
const { t } = a
|
|
266
448
|
const T = total(t)
|
|
267
449
|
const st = starts(t)
|
|
@@ -318,6 +500,9 @@ export function mixArgs(a: Pick<StitchInput, 't' | 'music' | 'sfx' | 'voices'>,
|
|
|
318
500
|
})
|
|
319
501
|
const silence = input('-f', 'lavfi', '-t', n3(T), '-i', 'anullsrc=r=48000:cl=stereo')
|
|
320
502
|
mix.push(`[${silence}:a]`)
|
|
321
|
-
|
|
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]`)
|
|
322
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]
|
|
323
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
|
+
}
|
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,6 +18,8 @@ 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
|
|
23
25
|
}
|
|
@@ -44,6 +46,8 @@ export interface TrailerLook {
|
|
|
44
46
|
chip: { fill: string; ink: string }
|
|
45
47
|
font: { file: string | null; family: string; weight: number }
|
|
46
48
|
logo: string | null
|
|
49
|
+
/** the short's words: their face and colours */
|
|
50
|
+
short: { font: { file: string | null; family: string; weight: number }; light: string; edge: string; accent: string }
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export interface BrandLike {
|
|
@@ -97,14 +101,24 @@ export function lookOf(brand: BrandLike | null, webLogo: string | null = null):
|
|
|
97
101
|
const p = brand?.palette ?? HOUSE_PALETTE
|
|
98
102
|
const i = brand?.trailer ?? {}
|
|
99
103
|
const ground: [string, string] = i.ground ?? [p.bg, darker(p.bg, luminance(p.bg) > 0.5 ? 0.25 : 0.45)]
|
|
104
|
+
const font = { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 }
|
|
105
|
+
const light = i.light ?? (luminance(p.ink) > 0.45 ? p.ink : '#ffffff')
|
|
106
|
+
const edge = i.edge ?? (luminance(ground[1]) < 0.2 ? ground[1] : darker(p.bg, 0.8))
|
|
107
|
+
const accent = i.accent ?? p.accent
|
|
108
|
+
const sh = i.short ?? {}
|
|
109
|
+
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
110
|
return {
|
|
101
111
|
ground,
|
|
102
|
-
light
|
|
103
|
-
edge
|
|
104
|
-
accent
|
|
112
|
+
light,
|
|
113
|
+
edge,
|
|
114
|
+
accent,
|
|
105
115
|
plate: { fill: i.plate?.fill ?? alpha(p.bg, 0.94), line: i.plate?.line ?? p.accent, ink: i.plate?.ink ?? p.ink },
|
|
106
116
|
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
117
|
font: { file: i.font?.file ?? null, family: i.font?.family ?? (i.font?.file ? 'TrailerFace' : HOUSE_FAMILIES), weight: i.font?.weight ?? 800 },
|
|
108
118
|
logo: i.logo ?? webLogo,
|
|
119
|
+
short: { font: shortFont, light: sh.light ?? light, edge: sh.edge ?? edge, accent: sh.accent ?? accent },
|
|
109
120
|
}
|
|
110
121
|
}
|
|
122
|
+
|
|
123
|
+
/** the look a short's words wear: the look with the short's face and colours in place */
|
|
124
|
+
export const shortLook = (look: TrailerLook): TrailerLook => ({ ...look, font: look.short.font, light: look.short.light, edge: look.short.edge, accent: look.short.accent })
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// the verbs over a built cut (wiki build/trailer.md, The verbs and Versions): each section's levels, a passage clipped
|
|
2
|
+
// with its sound (and the same passage of a kept version), a version kept, the take file of a shot
|
|
3
|
+
import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { basename, join, relative } from 'node:path'
|
|
5
|
+
import { type Loaded, load, Refused, type Say, takesOfLoaded } from './build.ts'
|
|
6
|
+
import { ffmpeg } from './ffmpeg.ts'
|
|
7
|
+
import { starts, type Trailer } from './grammar.ts'
|
|
8
|
+
|
|
9
|
+
const outOf = (l: Pick<Loaded, 'dir'>): string => join(l.dir, 'out')
|
|
10
|
+
|
|
11
|
+
/** the built video of a cut, refused when the build never ran */
|
|
12
|
+
function builtOf(l: Pick<Loaded, 'dir' | 'file' | 't'>, cwd: string): string {
|
|
13
|
+
const f = join(outOf(l), `${l.t.name}.mp4`)
|
|
14
|
+
if (!existsSync(f)) throw new Refused(`no ${relative(cwd, f)}: sk trailer ${relative(cwd, l.file)} first`)
|
|
15
|
+
return f
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** a moment of the cut on the video clock: a section or a mark, a shot, or seconds; `end` takes its end */
|
|
19
|
+
export function momentOf(t: Pick<Trailer, 'sections' | 'timelineAt' | 'shots'>, name: string, end: boolean): number {
|
|
20
|
+
if (/^\d+(\.\d+)?$/.test(name)) return Number(name)
|
|
21
|
+
if (t.timelineAt !== null) {
|
|
22
|
+
const sec = t.sections.find((s) => s.name === name)
|
|
23
|
+
if (sec) return t.timelineAt + sec.at + (end ? sec.seconds : 0)
|
|
24
|
+
for (const s of t.sections) {
|
|
25
|
+
const m = s.marks.find((x) => x.name === name)
|
|
26
|
+
if (m) return t.timelineAt + s.at + m.at
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const i = t.shots.findIndex((s) => s.name === name)
|
|
30
|
+
if (i >= 0) return starts(t)[i]! + (end ? t.shots[i]!.length : 0)
|
|
31
|
+
throw new Refused(`${name}: no section, mark or shot of that name (or write seconds)`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** each section's loudness in the built cut, and its step from the section before */
|
|
35
|
+
export async function levels(l: Loaded, say: Say, cwd: string): Promise<void> {
|
|
36
|
+
const video = builtOf(l, cwd)
|
|
37
|
+
const at0 = l.t.timelineAt
|
|
38
|
+
if (l.t.sections.length === 0 || at0 === null) throw new Refused('levels: this cut has no section')
|
|
39
|
+
const rows = await Promise.all(
|
|
40
|
+
l.t.sections.map(async (s) => {
|
|
41
|
+
const err = await ffmpeg(['-loglevel', 'info', '-ss', String(at0 + s.at), '-t', String(s.seconds), '-i', video, '-vn', '-af', 'volumedetect', '-f', 'null', '-'])
|
|
42
|
+
const mean = Number(/mean_volume: (-?[\d.]+)/.exec(err)?.[1] ?? Number.NaN)
|
|
43
|
+
const max = Number(/max_volume: (-?[\d.]+)/.exec(err)?.[1] ?? Number.NaN)
|
|
44
|
+
return { s, mean, max }
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
say(`${relative(cwd, video)}: each section's mean and peak, dB (video clock)`)
|
|
48
|
+
rows.forEach((r, i) => {
|
|
49
|
+
const prev = rows[i - 1]
|
|
50
|
+
const step = prev ? `${r.mean - prev.mean >= 0 ? '+' : ''}${(r.mean - prev.mean).toFixed(1)}` : ''
|
|
51
|
+
say(` ${r.s.name.padEnd(12)} ${(at0 + r.s.at).toFixed(1).padStart(6)} s ${r.s.seconds.toFixed(1).padStart(5)} s mean ${r.mean.toFixed(1).padStart(6)} peak ${r.max.toFixed(1).padStart(5)} ${step.padStart(5)}`)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** a kept version's video: the one mp4 of `versions/<v>/` */
|
|
56
|
+
export function versionVideo(dir: string, v: string): string | null {
|
|
57
|
+
const d = join(dir, 'versions', v)
|
|
58
|
+
if (!existsSync(d)) return null
|
|
59
|
+
const f = readdirSync(d).find((x) => x.endsWith('.mp4'))
|
|
60
|
+
return f ? join(d, f) : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** a version's cut text read as today's tool reads it, with the cut's own recipes, and its video */
|
|
64
|
+
async function versionCut(l: Loaded, v: string, cwd: string): Promise<{ t: Trailer; video: string }> {
|
|
65
|
+
const text = join(l.dir, 'versions', `${v}.trailer`)
|
|
66
|
+
if (!existsSync(text)) throw new Refused(`no ${relative(cwd, text)}`)
|
|
67
|
+
const kept = await load(text, l.root, { dir: l.dir, check: false })
|
|
68
|
+
if (kept.errors.length > 0) throw new Refused(`${v} does not read with today's tool (an older version builds only in a worktree at its tag): ${kept.errors[0]!.message}`)
|
|
69
|
+
const video = versionVideo(l.dir, v) ?? join(l.dir, 'versions', v, `${basename(l.dir)}-${v}.mp4`)
|
|
70
|
+
if (!existsSync(video)) throw new Refused(`no ${relative(cwd, video)}`)
|
|
71
|
+
return { t: kept.t, video }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** the passage from `from` to the end of `to` as its own mp4, sound included; with a version, the same passage of it too */
|
|
75
|
+
export async function clip(l: Loaded, from: string, to: string, pad: number, version: string | undefined, say: Say, cwd: string): Promise<void> {
|
|
76
|
+
const cut = async (t: Trailer, video: string, out: string): Promise<void> => {
|
|
77
|
+
const a = Math.max(0, momentOf(t, from, false) - pad)
|
|
78
|
+
const b = momentOf(t, to, true) + pad
|
|
79
|
+
if (b <= a) throw new Refused(`clip: ${to} ends before ${from} starts`)
|
|
80
|
+
await ffmpeg(['-ss', String(a), '-to', String(b), '-i', video, '-c:v', 'libx264', '-crf', '18', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', out], { nice: 10 })
|
|
81
|
+
say(`ok: ${relative(cwd, out)} (${a.toFixed(2)} to ${b.toFixed(2)} s)`)
|
|
82
|
+
}
|
|
83
|
+
const tag = from === to ? from : `${from}-${to}`
|
|
84
|
+
await cut(l.t, builtOf(l, cwd), join(outOf(l), `clip-${tag}.mp4`))
|
|
85
|
+
if (version) {
|
|
86
|
+
const v = await versionCut(l, version, cwd)
|
|
87
|
+
await cut(v.t, v.video, join(outOf(l), `clip-${tag}-${version}.mp4`))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** keep the built cut as a version: its text, its video, its strip and each shot's take, cloned so they cost nothing */
|
|
92
|
+
export function keep(l: Loaded, v: string, say: Say, cwd: string): void {
|
|
93
|
+
if (!/^v\d+$/.test(v)) throw new Refused(`keep: a version is v<n>, not ${v}`)
|
|
94
|
+
const text = join(l.dir, 'versions', `${v}.trailer`)
|
|
95
|
+
const dir = join(l.dir, 'versions', v)
|
|
96
|
+
if (existsSync(text) || existsSync(dir)) throw new Refused(`${v} is kept already: versions are never overwritten`)
|
|
97
|
+
const video = builtOf(l, cwd)
|
|
98
|
+
mkdirSync(join(dir, 'shots'), { recursive: true })
|
|
99
|
+
writeFileSync(text, readFileSync(l.file, 'utf8'))
|
|
100
|
+
// a build rewrites its files in place: a link would turn the version into the next build, a clone costs nothing on APFS
|
|
101
|
+
const clone = (a: string, b: string): void => copyFileSync(a, b, constants.COPYFILE_FICLONE)
|
|
102
|
+
clone(video, join(dir, `${basename(l.dir)}-${v}.mp4`))
|
|
103
|
+
const strip = join(outOf(l), `${l.t.name}.strip.png`)
|
|
104
|
+
if (existsSync(strip)) clone(strip, join(dir, `${l.t.name}.strip.png`))
|
|
105
|
+
const shots = join(outOf(l), `${l.t.name}-shots`)
|
|
106
|
+
const files = existsSync(shots) ? readdirSync(shots) : []
|
|
107
|
+
for (const f of files) clone(join(shots, f), join(dir, 'shots', f))
|
|
108
|
+
// the clones are the machine's: git keeps the text and the index, never a version's folder
|
|
109
|
+
const ignored = Bun.spawnSync(['git', 'check-ignore', '-q', dir], { cwd: l.root }).exitCode === 0
|
|
110
|
+
const ignore = join(l.dir, 'versions', '.gitignore')
|
|
111
|
+
if (!ignored && !existsSync(ignore)) writeFileSync(ignore, '*/\n')
|
|
112
|
+
say(`ok: kept ${v}: ${relative(cwd, text)}, ${relative(cwd, dir)}/ (the video, the strip, ${files.length} shots)`)
|
|
113
|
+
say(`then: its row in ${relative(cwd, join(l.dir, 'versions', 'index.md'))}, commit ${relative(cwd, text)} and that index, git tag trailer-${basename(l.dir)}-${v}, push the commit and the tag`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** the take file of each named shot or footage block */
|
|
117
|
+
export function footage(l: Loaded, names: string[], say: Say, cwd: string): void {
|
|
118
|
+
const takes = takesOfLoaded(l)
|
|
119
|
+
for (const name of names) {
|
|
120
|
+
const s = [...l.t.shots, ...l.t.footage].find((x) => x.name === name)
|
|
121
|
+
if (!s) throw new Refused(`${name}: no shot or footage block of that name`)
|
|
122
|
+
const tk = takes.find((x) => x.shot === s)
|
|
123
|
+
if (!tk) {
|
|
124
|
+
say(`${name} no take: its film is ${s.film?.kind ?? 'nothing'}`)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
say(`${name} ${relative(cwd, tk.file)}${existsSync(tk.file) ? '' : ' (not filmed)'}`)
|
|
128
|
+
}
|
|
129
|
+
}
|