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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// a title's entrance and exit, frame by frame: what each move does to the whole title or to each of its letters at a
|
|
2
|
+
// point of its run. Pure: the grammar checks the words, titles.ts draws each state, graph.ts lays the frames. An exit
|
|
3
|
+
// is its move played backwards; the knock is the one exit of its own
|
|
4
|
+
|
|
5
|
+
export const MOVES = ['none', 'fade', 'rise', 'drop', 'slide', 'pop', 'shake', 'blur', 'wipe', 'typewriter', 'letters', 'wave'] as const
|
|
6
|
+
export type MoveKind = (typeof MOVES)[number]
|
|
7
|
+
/** the moves drawn letter by letter: the title is drawn again each frame, the rest move the drawn title */
|
|
8
|
+
export const LETTER_MOVES: ReadonlySet<MoveKind> = new Set(['typewriter', 'letters', 'wave'])
|
|
9
|
+
|
|
10
|
+
/** a move a title line names: `in pop 0.4s`; no seconds, its own default */
|
|
11
|
+
export interface Move {
|
|
12
|
+
kind: MoveKind
|
|
13
|
+
seconds?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** the whole title at a frame: its alpha, its offset in frame pixels, its scale and turn (radians) about its box's
|
|
17
|
+
* middle, a blur in frame pixels, and the share of its width a wipe shows (1: all) */
|
|
18
|
+
export interface Whole {
|
|
19
|
+
a: number
|
|
20
|
+
dx: number
|
|
21
|
+
dy: number
|
|
22
|
+
s: number
|
|
23
|
+
rot: number
|
|
24
|
+
blur: number
|
|
25
|
+
wipe: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** one letter at a frame, about its own middle: alpha, offset in frame pixels, scale */
|
|
29
|
+
export interface Glyph {
|
|
30
|
+
a: number
|
|
31
|
+
dx: number
|
|
32
|
+
dy: number
|
|
33
|
+
s: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** what a frame draws: the whole title's state; with a letter move, each letter's and the plates' and bands' alpha */
|
|
37
|
+
export interface TitleFrame {
|
|
38
|
+
whole: Whole
|
|
39
|
+
glyph?: (i: number, n: number) => Glyph
|
|
40
|
+
decor: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const STILL: Whole = { a: 1, dx: 0, dy: 0, s: 1, rot: 0, blur: 0, wipe: 1 }
|
|
44
|
+
const AT_REST: TitleFrame = { whole: STILL, decor: 1 }
|
|
45
|
+
|
|
46
|
+
const clip01 = (x: number): number => Math.max(0, Math.min(1, x))
|
|
47
|
+
const BACK = 1.70158
|
|
48
|
+
/** an ease out that overshoots a little and settles */
|
|
49
|
+
export const backOut = (x: number): number => 1 + (BACK + 1) * (x - 1) ** 3 + BACK * (x - 1) ** 2
|
|
50
|
+
const inOut = (x: number): number => (x < 0.5 ? 4 * x ** 3 : 1 - (-2 * x + 2) ** 3 / 2)
|
|
51
|
+
const smooth = (x: number): number => x * x * (3 - 2 * x)
|
|
52
|
+
|
|
53
|
+
/** a knocked title tips and flies out over this long */
|
|
54
|
+
export const KNOCK_S = 0.4
|
|
55
|
+
const KNOCK_TURN = 1.4
|
|
56
|
+
|
|
57
|
+
/** the frame of `kind` at `p`, 0 (before the entrance) to 1 (at rest), on a frame W by H whose letters are `px` tall */
|
|
58
|
+
export function moveFrame(kind: MoveKind, p: number, [W, H]: readonly [number, number], px: number): TitleFrame {
|
|
59
|
+
void W
|
|
60
|
+
p = clip01(p)
|
|
61
|
+
const whole = (w: Partial<Whole>): TitleFrame => ({ whole: { ...STILL, ...w }, decor: 1 })
|
|
62
|
+
switch (kind) {
|
|
63
|
+
case 'none':
|
|
64
|
+
return AT_REST
|
|
65
|
+
case 'fade':
|
|
66
|
+
return whole({ a: p })
|
|
67
|
+
case 'rise':
|
|
68
|
+
return whole({ a: p, dy: (H / 40) * (1 - p) ** 2 })
|
|
69
|
+
case 'drop':
|
|
70
|
+
return whole({ a: p, dy: -(H / 30) * (1 - p) ** 2 })
|
|
71
|
+
case 'slide':
|
|
72
|
+
return whole({ a: p, dx: -(H / 32) * (1 - p) ** 3 })
|
|
73
|
+
case 'pop':
|
|
74
|
+
return whole({ a: clip01(p * 3), s: 0.55 + 0.45 * backOut(p) })
|
|
75
|
+
case 'shake': {
|
|
76
|
+
const amp = (H / 26) * (1 - p) ** 2
|
|
77
|
+
return whole({ a: clip01(p * 4), dx: (amp * (Math.sin(p * 53) + 0.5 * Math.sin(p * 31 + 1))) / 1.5, dy: (amp * (Math.cos(p * 47) + 0.5 * Math.sin(p * 23 + 2))) / 1.5, rot: (amp / H) * 0.6 * Math.sin(p * 41) })
|
|
78
|
+
}
|
|
79
|
+
case 'blur':
|
|
80
|
+
return whole({ a: clip01(p * 1.6), blur: (H / 40) * (1 - p) ** 2, s: 1 + 0.04 * (1 - p) })
|
|
81
|
+
case 'wipe':
|
|
82
|
+
return whole({ wipe: inOut(p) })
|
|
83
|
+
case 'typewriter':
|
|
84
|
+
return { whole: STILL, decor: clip01(p * 4), glyph: (i, n) => ({ a: p * n >= i + 1 - 1e-9 ? 1 : 0, dx: 0, dy: 0, s: 1 }) }
|
|
85
|
+
case 'letters':
|
|
86
|
+
return {
|
|
87
|
+
whole: STILL,
|
|
88
|
+
decor: clip01(p * 3),
|
|
89
|
+
glyph: (i, n) => {
|
|
90
|
+
const span = 0.45
|
|
91
|
+
const q = smooth(clip01((p - (n > 1 ? (i / (n - 1)) * (1 - span) : 0)) / span))
|
|
92
|
+
return { a: q, dx: 0, dy: px * 0.4 * (1 - q) ** 2, s: 1 }
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
case 'wave':
|
|
96
|
+
return {
|
|
97
|
+
whole: STILL,
|
|
98
|
+
decor: clip01(p * 3),
|
|
99
|
+
glyph: (i, n) => {
|
|
100
|
+
const span = 0.5
|
|
101
|
+
const q = clip01((p - (n > 1 ? (i / (n - 1)) * (1 - span) : 0)) / span)
|
|
102
|
+
return { a: clip01(q * 2.5), dx: 0, dy: px * 0.7 * (1 - backOut(q)), s: 0.7 + 0.3 * backOut(q) }
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** a knocked title at `q`, 0 (still in place) to 1 (gone): it tips and flies off left and up */
|
|
109
|
+
export function knockFrame(q: number, [W, H]: readonly [number, number]): TitleFrame {
|
|
110
|
+
const k = clip01(q) ** 2
|
|
111
|
+
return { whole: { ...STILL, rot: -KNOCK_TURN * k, dx: -W * 1.1 * k, dy: -H * 0.45 * k }, decor: 1 }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** how many letters a title's text draws: every character but spaces, the `*` marks and the `\n` breaks */
|
|
115
|
+
export const letterCount = (text: string): number => [...text.replaceAll('\\n', '').replace(/[*\s]/g, '')].length
|
|
116
|
+
|
|
117
|
+
/** a move's length when its line names none; the default entrance and exit keep the style's own (`styleFade`) */
|
|
118
|
+
export function moveSeconds(kind: MoveKind, letters: number): number {
|
|
119
|
+
switch (kind) {
|
|
120
|
+
case 'none':
|
|
121
|
+
return 0
|
|
122
|
+
case 'typewriter':
|
|
123
|
+
return 0.045 * letters
|
|
124
|
+
case 'letters':
|
|
125
|
+
return 0.3 + 0.03 * letters
|
|
126
|
+
case 'wave':
|
|
127
|
+
return 0.35 + 0.035 * letters
|
|
128
|
+
case 'pop':
|
|
129
|
+
return 0.35
|
|
130
|
+
case 'shake':
|
|
131
|
+
case 'blur':
|
|
132
|
+
case 'wipe':
|
|
133
|
+
return 0.5
|
|
134
|
+
default:
|
|
135
|
+
return 0.4
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** the style's own fade: a tagline and the logo breathe in slowly, the plates pop, a short's words come quick */
|
|
140
|
+
export const styleFade = (style: string): number => (style === 'line' || style === 'logo' ? 0.7 : style === 'short' || style === 'progress' ? 0.15 : 0.25)
|
|
141
|
+
|
|
142
|
+
/** what a title's entrance and exit are, their frames counted */
|
|
143
|
+
export interface TitleMotion {
|
|
144
|
+
in: { kind: MoveKind; frames: number }
|
|
145
|
+
out: { kind: MoveKind | 'knock'; frames: number }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** the title's span in seconds and frames, and what it shows: the words of its line, its style, its step */
|
|
149
|
+
export interface MotionInput {
|
|
150
|
+
style: string
|
|
151
|
+
text: string
|
|
152
|
+
in?: Move
|
|
153
|
+
out?: Move
|
|
154
|
+
/** seconds from the title's start to its knock */
|
|
155
|
+
knock?: number
|
|
156
|
+
step?: { i: number; n: number }
|
|
157
|
+
/** seconds it stands, a knock's flight included */
|
|
158
|
+
seconds: number
|
|
159
|
+
/** frames it stands */
|
|
160
|
+
frames: number
|
|
161
|
+
fps: number
|
|
162
|
+
/** a short's caption on the cut's first frame: there whole, the cover the platforms take */
|
|
163
|
+
cover: boolean
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** a title's entrance and exit in frames. With no `in`, a title rises in (a short slides, a progress fades) over its
|
|
167
|
+
* style's fade; with no `out`, it fades out over it; a default never takes more than a third of the title, a letter
|
|
168
|
+
* move more than half. A progress step swaps on its beat: only the first fades in, only the last fades out */
|
|
169
|
+
export function titleMotion(m: MotionInput): TitleMotion {
|
|
170
|
+
const fade = Math.min(styleFade(m.style), m.seconds / 3)
|
|
171
|
+
const letters = letterCount(m.text)
|
|
172
|
+
const own = (mv: Move | undefined, fallback: MoveKind): [MoveKind, number] => {
|
|
173
|
+
if (!mv) return [fallback, fallback === 'none' ? 0 : fade]
|
|
174
|
+
if (mv.seconds !== undefined) return [mv.kind, mv.seconds]
|
|
175
|
+
return [mv.kind, Math.min(moveSeconds(mv.kind, letters), m.seconds * (LETTER_MOVES.has(mv.kind) ? 0.5 : 1 / 3))]
|
|
176
|
+
}
|
|
177
|
+
const firstStep = m.step === undefined || m.step.i === 1
|
|
178
|
+
const lastStep = m.step === undefined || m.step.i === m.step.n
|
|
179
|
+
const defIn: MoveKind = m.style === 'short' ? 'slide' : m.style === 'progress' ? 'fade' : 'rise'
|
|
180
|
+
let [inKind, inS] = m.cover || !firstStep ? ['none' as MoveKind, 0] : own(m.in, defIn)
|
|
181
|
+
let [outKind, outS]: [MoveKind | 'knock', number] = m.knock !== undefined ? ['knock', KNOCK_S] : !lastStep ? ['none', 0] : own(m.out, 'fade')
|
|
182
|
+
if (inKind === 'none') inS = 0
|
|
183
|
+
if (outKind === 'none') outS = 0
|
|
184
|
+
let a = Math.round(inS * m.fps)
|
|
185
|
+
let b = Math.round(outS * m.fps)
|
|
186
|
+
// a title shorter than its moves shares its frames between them
|
|
187
|
+
if (a + b > m.frames) {
|
|
188
|
+
const k = m.frames / (a + b)
|
|
189
|
+
a = Math.floor(a * k)
|
|
190
|
+
b = Math.floor(b * k)
|
|
191
|
+
}
|
|
192
|
+
if (a === 0) inKind = 'none'
|
|
193
|
+
if (b === 0) outKind = 'none'
|
|
194
|
+
return { in: { kind: inKind, frames: a }, out: { kind: outKind, frames: b } }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** the frame `k` of an entrance of `n` frames: frame 0 is before it, the frame after the last at rest */
|
|
198
|
+
export const inFrame = (kind: MoveKind, k: number, n: number, size: readonly [number, number], px: number): TitleFrame => moveFrame(kind, k / n, size, px)
|
|
199
|
+
|
|
200
|
+
/** the frame `k` of an exit of `n` frames: its move played backwards, the last frame gone; a knock leaves from its
|
|
201
|
+
* first frame, still in place */
|
|
202
|
+
export const outFrame = (kind: MoveKind | 'knock', k: number, n: number, size: readonly [number, number], px: number): TitleFrame =>
|
|
203
|
+
kind === 'knock' ? knockFrame(k / n, size) : moveFrame(kind, 1 - (k + 1) / n, size, px)
|
|
204
|
+
|
|
205
|
+
/** the files of an entrance's or an exit's frames beside a title's PNG, as ffmpeg reads a numbered run */
|
|
206
|
+
export const inFrames = (file: string): string => `${file}.in-%04d.png`
|
|
207
|
+
export const outFrames = (file: string): string => `${file}.out-%04d.png`
|
|
@@ -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
|
+
}
|
package/src/trailer/plan.ts
CHANGED
|
@@ -1,40 +1,87 @@
|
|
|
1
1
|
// what a build does, as data: each shot's take and its key, which takes film, the pieces the stitch renders. Pure: the
|
|
2
2
|
// io (stamps on disk, the world's git stamp, ffmpeg) lives in footage.ts and stitch.ts, which read these answers
|
|
3
|
-
import type { Film, Shot, Trailer } from './grammar.ts'
|
|
3
|
+
import type { Film, Shot, TakeLength, Trailer } from './grammar.ts'
|
|
4
4
|
import { starts } from './grammar.ts'
|
|
5
5
|
|
|
6
|
-
/** bump when the core films differently from the same line: every
|
|
7
|
-
export const FILM_VERSION =
|
|
6
|
+
/** bump when the core films differently from the same line: every take films again */
|
|
7
|
+
export const FILM_VERSION = 2
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
* take filmed there under the same version, recipe, size, fps and seconds keeps its file
|
|
12
|
-
*/
|
|
13
|
-
export function footageKey(version: number | readonly number[], recipe: unknown, size: readonly [number, number], fps: number, seconds: number | null): string {
|
|
9
|
+
/** the key of a take: what films (its version, its recipe, the frame, the rate, its seconds), never how the cut uses it */
|
|
10
|
+
export function footageKey(version: unknown, recipe: unknown, size: readonly [number, number], fps: number, seconds: number | null): string {
|
|
14
11
|
return new Bun.CryptoHasher('sha1').update(JSON.stringify([version, recipe, size, fps, seconds])).digest('hex').slice(0, 12)
|
|
15
12
|
}
|
|
16
13
|
|
|
17
14
|
/** a film that writes a take of its own: the others are read where they lie (a clip, an image) or drawn (a card) */
|
|
18
|
-
export type TakeFilm = Extract<Film, { kind: 'run' | 'cli' | 'url' }>
|
|
19
|
-
export const isTake = (f: Film | null): f is TakeFilm => f?.kind === 'run' || f?.kind === 'cli' || f?.kind === 'url'
|
|
15
|
+
export type TakeFilm = Extract<Film, { kind: 'run' | 'cli' | 'url' | 'page' }>
|
|
16
|
+
export const isTake = (f: Film | null): f is TakeFilm => f?.kind === 'run' || f?.kind === 'cli' || f?.kind === 'url' || f?.kind === 'page'
|
|
20
17
|
|
|
21
18
|
/** the take is one picture, held for the shot */
|
|
22
19
|
export const takeIsStill = (f: TakeFilm): boolean => (f.kind === 'cli' ? f.output === 'still' : f.still === true)
|
|
23
20
|
|
|
24
|
-
/** the seconds a clip take films: what the shot shows
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
/** the seconds a clip take films: what the shot shows rounded up to 2 s (so a small edit never films again), exactly
|
|
22
|
+
* that, or none when it films as long as it plays; a still: none */
|
|
23
|
+
export function takeSeconds(s: Pick<Shot, 'from' | 'length'>, f: TakeFilm, length: TakeLength = defaultLength(f)): number | null {
|
|
24
|
+
if (takeIsStill(f) || length === 'own') return null
|
|
25
|
+
return length === 'exact' ? +(s.from + s.length).toFixed(3) : Math.ceil((s.from + s.length) / 2) * 2
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const defaultLength = (f: TakeFilm): TakeLength => (f.kind === 'cli' && f.length === 'own' ? 'own' : 'steps')
|
|
29
|
+
|
|
30
|
+
/** the film line as its key reads it: the line it sits on is never part of what films, nor its row's marks (the
|
|
31
|
+
* spec's version and length key them) */
|
|
32
|
+
export function recipeOf(f: TakeFilm): unknown {
|
|
33
|
+
if (f.kind === 'cli') {
|
|
34
|
+
const { line: _l, version: _v, length: _n, ...recipe } = f
|
|
35
|
+
return recipe
|
|
36
|
+
}
|
|
31
37
|
const { line: _line, ...recipe } = f
|
|
32
38
|
return recipe
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/** what one take is, past its words: its version, how long it films, a world of its own, what its key reads. A row
|
|
42
|
+
* gives the defaults, the repo's trailer unit refines one line (adapter.ts `take`) */
|
|
43
|
+
export interface TakeSpec {
|
|
44
|
+
version: number | string | null
|
|
45
|
+
length: TakeLength
|
|
46
|
+
/** a fixed world (`rev:<commit>`): the take is never stale */
|
|
47
|
+
world: string | null
|
|
48
|
+
/** the paths its world is, when its row names them (over the unit's `stamp`) */
|
|
49
|
+
stamp?: string[]
|
|
50
|
+
recipe: unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** a recipe file's text as its key reads it: comment lines (`#` first), trailing comments (a space, `#`, then a space or
|
|
54
|
+
* the line's end, outside double quotes) and blank lines left out; a `#` inside a value (`#ff0000`) is content */
|
|
55
|
+
export function recipeText(text: string): string {
|
|
56
|
+
return text
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((l) => {
|
|
59
|
+
if (l.trimStart().startsWith('#')) return ''
|
|
60
|
+
let q = false
|
|
61
|
+
for (let i = 0; i < l.length; i++) {
|
|
62
|
+
if (l[i] === '"') q = !q
|
|
63
|
+
else if (l[i] === '#' && !q && /\s/.test(l[i - 1] ?? '') && /^(\s|$)/.test(l.slice(i + 1, i + 2))) return l.slice(0, i).trim()
|
|
64
|
+
}
|
|
65
|
+
return l.trim()
|
|
66
|
+
})
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join('\n')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** the `@<file>` words of a film line */
|
|
72
|
+
export const recipeWords = (argv: readonly string[]): string[] => argv.filter((a) => /^@\S/.test(a))
|
|
73
|
+
|
|
74
|
+
/** a take's spec from its row alone; `files` the text of each `@<file>` word, as recipeText reads it */
|
|
75
|
+
export function specOf(f: TakeFilm, files: Readonly<Record<string, string>> = {}): TakeSpec {
|
|
76
|
+
const read = Object.keys(files).length > 0 ? { files: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, new Bun.CryptoHasher('sha1').update(v).digest('hex').slice(0, 12)])) } : {}
|
|
77
|
+
return { version: f.kind === 'cli' ? (f.version ?? null) : null, length: defaultLength(f), world: null, recipe: { ...(recipeOf(f) as object), ...read }, ...(f.kind === 'cli' && f.stamp ? { stamp: f.stamp } : {}) }
|
|
78
|
+
}
|
|
79
|
+
|
|
35
80
|
/** the take's file name, by its key alone: shots on one film line, in one cut or in several, share one file */
|
|
36
|
-
export function takeName(s: Pick<Shot, 'from' | 'length'>, f: TakeFilm, t: Pick<Trailer, 'size' | 'fps'
|
|
37
|
-
|
|
81
|
+
export function takeName(s: Pick<Shot, 'from' | 'length'>, f: TakeFilm, t: Pick<Trailer, 'size' | 'fps'>, spec: TakeSpec = specOf(f)): string {
|
|
82
|
+
// a fixed world is what films: two past commits are two takes, whatever the recipe keeps of their words
|
|
83
|
+
const recipe = spec.world === null ? spec.recipe : { world: spec.world, recipe: spec.recipe }
|
|
84
|
+
return `${footageKey([FILM_VERSION, spec.version ?? 0], recipe, t.size, t.fps, takeSeconds(s, f, spec.length))}.${takeIsStill(f) ? 'png' : 'mp4'}`
|
|
38
85
|
}
|
|
39
86
|
|
|
40
87
|
/** the world a take was filmed on, beside it */
|
|
@@ -46,9 +93,13 @@ export interface Stamp {
|
|
|
46
93
|
seconds: number
|
|
47
94
|
/** what the film said of it, its first line */
|
|
48
95
|
note: string
|
|
96
|
+
/** each frame's state, run length encoded (states.ts), when the film said it */
|
|
97
|
+
states?: string
|
|
98
|
+
/** its world was fixed by its spec (a film of a past commit): never stale */
|
|
99
|
+
fixed?: true
|
|
49
100
|
}
|
|
50
101
|
|
|
51
|
-
export const fresh = (stamp: Stamp, world: string): boolean => stamp.world === world
|
|
102
|
+
export const fresh = (stamp: Stamp, world: string): boolean => stamp.fixed === true || stamp.world === world
|
|
52
103
|
|
|
53
104
|
/** a take as a build sees it: its shot, its file, its stamp when it was filmed */
|
|
54
105
|
export interface Take {
|
|
@@ -56,6 +107,9 @@ export interface Take {
|
|
|
56
107
|
film: TakeFilm
|
|
57
108
|
file: string
|
|
58
109
|
stamp: Stamp | null
|
|
110
|
+
spec: TakeSpec
|
|
111
|
+
/** the words the film runs: each `@<file>` as its repo relative path */
|
|
112
|
+
argv?: string[]
|
|
59
113
|
}
|
|
60
114
|
|
|
61
115
|
/** `--refilm`: every stale take, or the takes of the named shots */
|
|
@@ -63,7 +117,11 @@ export type Refilm = 'stale' | ReadonlySet<string> | null
|
|
|
63
117
|
|
|
64
118
|
/** the takes to film, each group sharing one file and filmed once by its first shot: a missing take, a stale one under
|
|
65
119
|
* `--refilm`, or one whose shot `--refilm` names */
|
|
66
|
-
|
|
120
|
+
/** the world a take is measured against: one for the cut, or one a take (a row that names its own paths) */
|
|
121
|
+
export type World = string | ((tk: Take) => string)
|
|
122
|
+
export const worldFor = (w: World, tk: Take): string => (typeof w === 'string' ? w : w(tk))
|
|
123
|
+
|
|
124
|
+
export function takesToFilm(takes: readonly Take[], world: World, refilm: Refilm, only?: ReadonlySet<string>): Take[][] {
|
|
67
125
|
const groups = new Map<string, Take[]>()
|
|
68
126
|
for (const tk of takes) {
|
|
69
127
|
if (only !== undefined && !only.has(tk.shot.name)) continue
|
|
@@ -74,14 +132,15 @@ export function takesToFilm(takes: readonly Take[], world: string, refilm: Refil
|
|
|
74
132
|
return [...groups.values()].filter((g) => {
|
|
75
133
|
const first = g[0]!
|
|
76
134
|
if (first.stamp === null) return true
|
|
77
|
-
if (refilm === 'stale') return !fresh(first.stamp, world)
|
|
135
|
+
if (refilm === 'stale') return !fresh(first.stamp, worldFor(world, first))
|
|
78
136
|
return refilm instanceof Set && g.some((tk) => refilm.has(tk.shot.name))
|
|
79
137
|
})
|
|
80
138
|
}
|
|
81
139
|
|
|
82
|
-
/** the argv a project CLI film runs: its words, then the file and, for a clip, its
|
|
83
|
-
|
|
84
|
-
|
|
140
|
+
/** the argv a project CLI film runs: its words (`argv`, the recipe files resolved), then the file and, for a clip, its
|
|
141
|
+
* seconds */
|
|
142
|
+
export function cliArgv(cli: readonly string[], f: Extract<Film, { kind: 'cli' }>, out: string, seconds: number | null, argv: readonly string[] = f.argv): string[] {
|
|
143
|
+
return [...cli, ...argv, '--out', out, ...(seconds === null ? [] : ['--seconds', String(seconds)])]
|
|
85
144
|
}
|
|
86
145
|
|
|
87
146
|
/** a `film run` command with its marks filled: `{out}`, `{seconds}`, `{w}`, `{h}`, `{fps}` */
|