creator-editing-studio 1.0.20260930
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/README.md +15 -0
- package/bin/init.mjs +72 -0
- package/package.json +18 -0
- package/template/CLAUDE.md +341 -0
- package/template/STYLE.md +165 -0
- package/template/package.json +45 -0
- package/template/remotion.config.ts +7 -0
- package/template/requirements.txt +17 -0
- package/template/scripts/align_script.py +106 -0
- package/template/scripts/assemble.mjs +364 -0
- package/template/scripts/beats.mjs +273 -0
- package/template/scripts/blur_regions.py +40 -0
- package/template/scripts/check_pair.py +90 -0
- package/template/scripts/doctor.mjs +141 -0
- package/template/scripts/find_cuts.py +45 -0
- package/template/scripts/grade.py +202 -0
- package/template/scripts/lib/media.mjs +228 -0
- package/template/scripts/lib/media.py +99 -0
- package/template/scripts/make_cutout.py +68 -0
- package/template/scripts/motion-check.mjs +268 -0
- package/template/scripts/motion-lint.mjs +122 -0
- package/template/scripts/new-video.mjs +49 -0
- package/template/scripts/prep.mjs +287 -0
- package/template/scripts/ramp.mjs +149 -0
- package/template/scripts/refs.mjs +72 -0
- package/template/scripts/refstyle.mjs +177 -0
- package/template/scripts/sounddesign.mjs +332 -0
- package/template/scripts/stills.mjs +64 -0
- package/template/scripts/sync-audio.mjs +134 -0
- package/template/scripts/transcribe.py +103 -0
- package/template/scripts/trim.mjs +374 -0
- package/template/src/Root.tsx +42 -0
- package/template/src/components/BrandBackground.tsx +22 -0
- package/template/src/components/Captions.tsx +141 -0
- package/template/src/components/Cursor.tsx +93 -0
- package/template/src/components/DrawnCircle.tsx +55 -0
- package/template/src/components/FilmOverlay.tsx +139 -0
- package/template/src/components/Hero.tsx +114 -0
- package/template/src/components/HeroTag.tsx +92 -0
- package/template/src/components/HighlightMark.tsx +34 -0
- package/template/src/components/LogoIcon.tsx +45 -0
- package/template/src/components/MacWindow.tsx +94 -0
- package/template/src/components/MaskReveal.tsx +34 -0
- package/template/src/components/MotionBlur.tsx +34 -0
- package/template/src/components/PlatformTile.tsx +153 -0
- package/template/src/components/Presence.tsx +27 -0
- package/template/src/components/RollingNumber.tsx +89 -0
- package/template/src/components/ScreenView.tsx +52 -0
- package/template/src/components/Stage.tsx +103 -0
- package/template/src/components/Transition.tsx +100 -0
- package/template/src/components/VelocityBlur.tsx +53 -0
- package/template/src/compositions/FilmTest.tsx +67 -0
- package/template/src/compositions/PresetTest.tsx +52 -0
- package/template/src/design/color.ts +6 -0
- package/template/src/design/glow.ts +11 -0
- package/template/src/design/logos.json +18 -0
- package/template/src/design/motion.ts +90 -0
- package/template/src/design/overlays.ts +121 -0
- package/template/src/design/presets.ts +65 -0
- package/template/src/design/tokens.ts +200 -0
- package/template/src/index.ts +4 -0
- package/template/src/lib/animate.ts +194 -0
- package/template/src/lib/camera.ts +58 -0
- package/template/src/lib/continuity.ts +86 -0
- package/template/src/lib/project.ts +13 -0
- package/template/src/lib/spine.ts +37 -0
- package/template/src/videos/sample/Sample.tsx +58 -0
- package/template/templates/BRIEF.md +88 -0
- package/template/tsconfig.json +17 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Beat map for a music track, so cuts can land on the beat.
|
|
4
|
+
*
|
|
5
|
+
* npm run beats -- <audio-or-video> [--project <name>] [--min 60] [--max 190]
|
|
6
|
+
*
|
|
7
|
+
* Writes work/<project>/beats.json (or next to the file) with:
|
|
8
|
+
* bpm, beatSeconds, downbeatSeconds, onsetSeconds, and the same in frames at 25/30/60fps.
|
|
9
|
+
*
|
|
10
|
+
* How it works: spectral flux gives an onset strength curve, autocorrelation of that curve
|
|
11
|
+
* gives the tempo, then we slide a grid at that tempo to the phase that lands on the most
|
|
12
|
+
* energy. No Python, no model — just ffmpeg and maths.
|
|
13
|
+
*/
|
|
14
|
+
import {existsSync, mkdirSync, writeFileSync} from 'node:fs';
|
|
15
|
+
import {basename, extname, join} from 'node:path';
|
|
16
|
+
import {decodePcm, fmt, median, onsetEnvelope, probe} from './lib/media.mjs';
|
|
17
|
+
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const file = args.find((a) => !a.startsWith('--'));
|
|
20
|
+
const flag = (name, fallback) => {
|
|
21
|
+
const i = args.indexOf(`--${name}`);
|
|
22
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (!file || !existsSync(file)) {
|
|
26
|
+
console.error('Usage: npm run beats -- <audio-or-video> [--project <name>] [--min 60] [--max 190]');
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const RATE = 22050;
|
|
31
|
+
const minBpm = Number(flag('min', 60));
|
|
32
|
+
const maxBpm = Number(flag('max', 190));
|
|
33
|
+
const project = flag('project', null);
|
|
34
|
+
|
|
35
|
+
const info = probe(file);
|
|
36
|
+
const samples = decodePcm(file, RATE);
|
|
37
|
+
if (!samples || samples.length < RATE) {
|
|
38
|
+
console.error('No usable audio in that file.');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const {env, hopSeconds} = onsetEnvelope(samples, RATE);
|
|
43
|
+
|
|
44
|
+
// Smooth a little, then subtract a moving median so a loud section doesn't hide the beats in a quiet one.
|
|
45
|
+
const smooth = new Float64Array(env.length);
|
|
46
|
+
for (let i = 0; i < env.length; i++) {
|
|
47
|
+
smooth[i] = (env[Math.max(0, i - 1)] + env[i] + env[Math.min(env.length - 1, i + 1)]) / 3;
|
|
48
|
+
}
|
|
49
|
+
const detrended = new Float64Array(env.length);
|
|
50
|
+
const W = Math.round(0.5 / hopSeconds);
|
|
51
|
+
for (let i = 0; i < smooth.length; i++) {
|
|
52
|
+
const from = Math.max(0, i - W);
|
|
53
|
+
const to = Math.min(smooth.length, i + W);
|
|
54
|
+
detrended[i] = Math.max(0, smooth[i] - median(Array.from(smooth.slice(from, to))));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ---------------------------------------------------------------- tempo */
|
|
58
|
+
|
|
59
|
+
const lagFor = (bpm) => Math.round(60 / bpm / hopSeconds);
|
|
60
|
+
const minLag = lagFor(maxBpm);
|
|
61
|
+
const maxLag = lagFor(minBpm);
|
|
62
|
+
let bestLag = minLag;
|
|
63
|
+
let bestScore = -1;
|
|
64
|
+
const scores = [];
|
|
65
|
+
for (let lag = minLag; lag <= maxLag; lag++) {
|
|
66
|
+
let sum = 0;
|
|
67
|
+
for (let i = 0; i + lag < detrended.length; i++) sum += detrended[i] * detrended[i + lag];
|
|
68
|
+
const score = sum / (detrended.length - lag);
|
|
69
|
+
scores.push({lag, score});
|
|
70
|
+
if (score > bestScore) {
|
|
71
|
+
bestScore = score;
|
|
72
|
+
bestLag = lag;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Autocorrelation alone confuses a tempo with its half and double. Settle it by
|
|
78
|
+
* laying each candidate grid over the actual hits and keeping the one that lands on them.
|
|
79
|
+
*/
|
|
80
|
+
const phaseFor = (lag) => {
|
|
81
|
+
let phase = 0;
|
|
82
|
+
let best = -1;
|
|
83
|
+
for (let p = 0; p < lag; p++) {
|
|
84
|
+
let sum = 0;
|
|
85
|
+
for (let i = p; i < detrended.length; i += lag) sum += detrended[i];
|
|
86
|
+
if (sum > best) {
|
|
87
|
+
best = sum;
|
|
88
|
+
phase = p;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return phase;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const peakForOnsets = Math.max(...detrended);
|
|
95
|
+
const onsetIdx = [];
|
|
96
|
+
for (let i = 1; i < detrended.length - 1; i++) {
|
|
97
|
+
if (detrended[i] > peakForOnsets * 0.18 && detrended[i] >= detrended[i - 1] && detrended[i] > detrended[i + 1]) {
|
|
98
|
+
if (!onsetIdx.length || (i - onsetIdx[onsetIdx.length - 1]) * hopSeconds > 0.09) onsetIdx.push(i);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const duration = info?.duration || samples.length / RATE;
|
|
103
|
+
const TOL = 0.07;
|
|
104
|
+
const onsetTimes = onsetIdx.map((i) => i * hopSeconds);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Least-squares fit of period and phase to the onsets a grid already lands on.
|
|
108
|
+
* The analysis hop is ~23ms, so an integer lag can only ever approximate the real
|
|
109
|
+
* period — which matters twice: it drifts across a long track, and (worse) it makes a
|
|
110
|
+
* true period that falls between two hops score lower than its own double, which may sit
|
|
111
|
+
* exactly on one. Refine before comparing, or the tempo comes back halved.
|
|
112
|
+
*/
|
|
113
|
+
const refit = (periodSeconds, phaseSeconds) => {
|
|
114
|
+
const xs = [];
|
|
115
|
+
const ys = [];
|
|
116
|
+
for (const t of onsetTimes) {
|
|
117
|
+
const k = Math.round((t - phaseSeconds) / periodSeconds);
|
|
118
|
+
if (Math.abs(t - (phaseSeconds + k * periodSeconds)) <= TOL) {
|
|
119
|
+
xs.push(k);
|
|
120
|
+
ys.push(t);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (xs.length < 4) return {period: periodSeconds, phase: phaseSeconds};
|
|
124
|
+
const n = xs.length;
|
|
125
|
+
const sx = xs.reduce((a, b) => a + b, 0);
|
|
126
|
+
const sy = ys.reduce((a, b) => a + b, 0);
|
|
127
|
+
const sxx = xs.reduce((a, b) => a + b * b, 0);
|
|
128
|
+
const sxy = xs.reduce((a, b, i) => a + b * ys[i], 0);
|
|
129
|
+
const denom = n * sxx - sx * sx;
|
|
130
|
+
if (denom === 0) return {period: periodSeconds, phase: phaseSeconds};
|
|
131
|
+
const slope = (n * sxy - sx * sy) / denom;
|
|
132
|
+
let intercept = (sy - slope * sx) / n;
|
|
133
|
+
if (!(slope > 0.2 && slope < 2)) return {period: periodSeconds, phase: phaseSeconds};
|
|
134
|
+
while (intercept - slope >= 0) intercept -= slope;
|
|
135
|
+
return {period: slope, phase: intercept};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One pass only refines what it already matches, so a seed that is 2% out drifts past the
|
|
140
|
+
* tolerance after a few beats and the fit gives up on its own. Iterating lets each pass
|
|
141
|
+
* match more of the track than the last, until the period stops moving.
|
|
142
|
+
*/
|
|
143
|
+
const refitToConvergence = (periodSeconds, phaseSeconds, passes = 6) => {
|
|
144
|
+
let p = periodSeconds;
|
|
145
|
+
let ph = phaseSeconds;
|
|
146
|
+
for (let i = 0; i < passes; i++) {
|
|
147
|
+
const next = refit(p, ph);
|
|
148
|
+
if (Math.abs(next.period - p) < 1e-5) return next;
|
|
149
|
+
p = next.period;
|
|
150
|
+
ph = next.phase;
|
|
151
|
+
}
|
|
152
|
+
return {period: p, phase: ph};
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const onsetSorted = [...onsetTimes].sort((a, b) => a - b);
|
|
156
|
+
const onsetNear = (t) => {
|
|
157
|
+
let lo = 0;
|
|
158
|
+
let hi = onsetSorted.length - 1;
|
|
159
|
+
while (lo < hi) {
|
|
160
|
+
const mid = (lo + hi) >> 1;
|
|
161
|
+
if (onsetSorted[mid] < t) lo = mid + 1;
|
|
162
|
+
else hi = mid;
|
|
163
|
+
}
|
|
164
|
+
return (
|
|
165
|
+
(onsetSorted[lo] !== undefined && Math.abs(onsetSorted[lo] - t) <= TOL) ||
|
|
166
|
+
(onsetSorted[lo - 1] !== undefined && Math.abs(onsetSorted[lo - 1] - t) <= TOL)
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** Reward grids that land on hits, punish grids with lots of empty beats. */
|
|
171
|
+
const scoreGrid = (period, phase) => {
|
|
172
|
+
let hit = 0;
|
|
173
|
+
let grid = 0;
|
|
174
|
+
for (let t = phase; t <= duration; t += period) {
|
|
175
|
+
grid++;
|
|
176
|
+
if (onsetNear(t)) hit++;
|
|
177
|
+
}
|
|
178
|
+
if (!grid) return -1;
|
|
179
|
+
return hit / onsetTimes.length - 0.35 * (1 - hit / grid);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A candidate period can only be a whole analysis hop (~23ms), which at 120 BPM is a 2%
|
|
184
|
+
* error — enough that the grid walks off the beat within a few bars and scores worse than
|
|
185
|
+
* its own half, whose longer period happens to divide the hop more cleanly. That is how a
|
|
186
|
+
* tempo comes back halved. So search a continuous window around each candidate instead of
|
|
187
|
+
* trusting the integer lag, and let the best (period, phase) pair represent it.
|
|
188
|
+
*/
|
|
189
|
+
const gridScore = (lag) => {
|
|
190
|
+
const seed = lag * hopSeconds;
|
|
191
|
+
const span = seed * 0.05;
|
|
192
|
+
const step = seed * 0.002;
|
|
193
|
+
let best = {lag, period: seed, phase: 0, score: -1};
|
|
194
|
+
for (let p = seed - span; p <= seed + span; p += step) {
|
|
195
|
+
if (p <= 0) continue;
|
|
196
|
+
for (let ph = 0; ph < p; ph += 0.01) {
|
|
197
|
+
const score = scoreGrid(p, ph);
|
|
198
|
+
if (score > best.score) best = {lag, period: p, phase: ph, score};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Now that the period is close, the beat-index correspondence is right and the fit lands.
|
|
202
|
+
const fitted = refitToConvergence(best.period, best.phase);
|
|
203
|
+
const fittedScore = scoreGrid(fitted.period, fitted.phase);
|
|
204
|
+
return fittedScore >= best.score ? {lag, ...fitted, score: fittedScore} : best;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Autocorrelation is one opinion about the tempo; the spacing between the hits themselves
|
|
209
|
+
* is another, and it is the one that survives a track whose strongest periodicity is not
|
|
210
|
+
* its beat. Offer both — plus each one's half and double — and let the grid score decide.
|
|
211
|
+
*/
|
|
212
|
+
const gaps = [];
|
|
213
|
+
for (let i = 1; i < onsetTimes.length; i++) gaps.push(onsetTimes[i] - onsetTimes[i - 1]);
|
|
214
|
+
const gapLag = gaps.length ? Math.max(1, Math.round(median(gaps) / hopSeconds)) : bestLag;
|
|
215
|
+
|
|
216
|
+
const candidates = [...new Set([Math.round(bestLag / 2), bestLag, bestLag * 2, Math.round(gapLag / 2), gapLag, gapLag * 2])]
|
|
217
|
+
.filter((lag) => lag >= minLag && lag <= maxLag)
|
|
218
|
+
.map(gridScore);
|
|
219
|
+
const chosen = candidates.reduce((best, c) => (c.score > best.score ? c : best), candidates[0]);
|
|
220
|
+
bestLag = chosen.lag;
|
|
221
|
+
|
|
222
|
+
// One more fit on the winner, so beat 400 is as accurate as beat 4.
|
|
223
|
+
const {period: beatPeriod, phase: phaseSeconds} = refitToConvergence(chosen.period, chosen.phase);
|
|
224
|
+
const bpm = 60 / beatPeriod;
|
|
225
|
+
|
|
226
|
+
const beats = [];
|
|
227
|
+
for (let t = phaseSeconds; t <= duration; t += beatPeriod) beats.push(Number(t.toFixed(3)));
|
|
228
|
+
|
|
229
|
+
// Downbeats: the strongest of every four beats decides where bar one starts.
|
|
230
|
+
let barPhase = 0;
|
|
231
|
+
let barScore = -1;
|
|
232
|
+
for (let p = 0; p < 4; p++) {
|
|
233
|
+
let sum = 0;
|
|
234
|
+
for (let i = p; i < beats.length; i += 4) {
|
|
235
|
+
const idx = Math.round(beats[i] / hopSeconds);
|
|
236
|
+
sum += detrended[idx] || 0;
|
|
237
|
+
}
|
|
238
|
+
if (sum > barScore) {
|
|
239
|
+
barScore = sum;
|
|
240
|
+
barPhase = p;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const downbeats = beats.filter((_, i) => (i - barPhase) % 4 === 0);
|
|
244
|
+
|
|
245
|
+
/* ---------------------------------------------------------------- onsets (hits, not grid) */
|
|
246
|
+
|
|
247
|
+
const onsets = onsetIdx.map((i) => Number((i * hopSeconds).toFixed(3)));
|
|
248
|
+
|
|
249
|
+
/* ---------------------------------------------------------------- write */
|
|
250
|
+
|
|
251
|
+
const frames = (list, fps) => list.map((t) => Math.round(t * fps));
|
|
252
|
+
const out = {
|
|
253
|
+
source: file,
|
|
254
|
+
durationSeconds: Number(duration.toFixed(3)),
|
|
255
|
+
bpm: Number(bpm.toFixed(2)),
|
|
256
|
+
beatSeconds: beats,
|
|
257
|
+
downbeatSeconds: downbeats,
|
|
258
|
+
onsetSeconds: onsets,
|
|
259
|
+
frames: {25: frames(beats, 25), 30: frames(beats, 30), 60: frames(beats, 60)},
|
|
260
|
+
downbeatFrames: {25: frames(downbeats, 25), 30: frames(downbeats, 30), 60: frames(downbeats, 60)},
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const dir = project ? join('work', project) : join('work', 'beats');
|
|
264
|
+
mkdirSync(dir, {recursive: true});
|
|
265
|
+
const outPath = join(dir, `${basename(file, extname(file))}.beats.json`);
|
|
266
|
+
writeFileSync(outPath, JSON.stringify(out, null, 2));
|
|
267
|
+
|
|
268
|
+
console.log(`Tempo ${fmt(bpm, 1)} BPM (beat every ${fmt(beatPeriod, 3)}s)`);
|
|
269
|
+
console.log(`Beats ${beats.length}`);
|
|
270
|
+
console.log(`Downbeats ${downbeats.length} (bar starts at ${fmt(downbeats[0] ?? 0, 2)}s)`);
|
|
271
|
+
console.log(`Hits ${onsets.length} onsets`);
|
|
272
|
+
console.log(`Written ${outPath}`);
|
|
273
|
+
console.log('\nCut on downbeats for a calm edit, on beats for a fast one, on hits for accents.');
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Bake blurred regions (brand names, logos, personal names) into copies of screenshots.
|
|
2
|
+
|
|
3
|
+
Usage (from the project root):
|
|
4
|
+
python scripts/blur_regions.py assets/projects/<video>/screenshots/blur-regions.json
|
|
5
|
+
|
|
6
|
+
JSON: {"<source file next to the json>": {"out": "<name>.png", "regions": [[x0, y0, x1, y1], ...]}}
|
|
7
|
+
Coordinates are in the source image's own pixels. Outputs go to the sibling processed/ folder
|
|
8
|
+
(assets/projects/<video>/processed/).
|
|
9
|
+
Blurring is baked in (not done live) so it is identical on every frame and costs nothing to render.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from PIL import Image, ImageDraw, ImageFilter
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
config_path = Path(sys.argv[1])
|
|
20
|
+
config = json.load(open(config_path, encoding="utf-8"))
|
|
21
|
+
SRC = config_path.parent
|
|
22
|
+
OUT = SRC.parent / "processed"
|
|
23
|
+
OUT.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
for source, spec in config.items():
|
|
25
|
+
image = Image.open(SRC / source).convert("RGB")
|
|
26
|
+
for x0, y0, x1, y1 in spec["regions"]:
|
|
27
|
+
box = (x0, y0, x1, y1)
|
|
28
|
+
patch = image.crop(box)
|
|
29
|
+
# Pixelate then blur: shapes of bold wordmarks stay unreadable even when zoomed in.
|
|
30
|
+
small = patch.resize((max(1, (x1 - x0) // 16), max(1, (y1 - y0) // 16)), Image.BILINEAR)
|
|
31
|
+
patch = small.resize(patch.size, Image.BILINEAR).filter(ImageFilter.GaussianBlur(radius=9))
|
|
32
|
+
mask = Image.new("L", patch.size, 0)
|
|
33
|
+
ImageDraw.Draw(mask).rounded_rectangle((0, 0, patch.size[0] - 1, patch.size[1] - 1), radius=min(patch.size) // 3, fill=255)
|
|
34
|
+
image.paste(patch, box, mask.filter(ImageFilter.GaussianBlur(radius=2)))
|
|
35
|
+
image.save(OUT / spec["out"], optimize=True)
|
|
36
|
+
print(f"{source} -> {OUT / spec['out']} ({len(spec['regions'])} regions)")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Check that a green-screen clip is frame-aligned with its original.
|
|
2
|
+
|
|
3
|
+
Usage (from the project root):
|
|
4
|
+
python scripts/check_pair.py <original> <greenscreen> [--samples 7]
|
|
5
|
+
|
|
6
|
+
At several points through the clip, compares the subject pixels of green-screen frame K with
|
|
7
|
+
original frames K-2..K+2. The offset with the smallest difference is the alignment; it must be the
|
|
8
|
+
same at every sample (otherwise the clips drift).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import sys as _sys, pathlib as _pl
|
|
17
|
+
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parent / 'lib'))
|
|
18
|
+
from media import FFMPEG, FFPROBE # resolved per platform - never hardcode a tool path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
OFFSETS = range(-2, 3)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def probe(path):
|
|
27
|
+
out = subprocess.run(
|
|
28
|
+
[FFPROBE, "-v", "error", "-select_streams", "v:0", "-count_frames",
|
|
29
|
+
"-show_entries", "stream=width,height,r_frame_rate,nb_read_frames", "-of", "json", path],
|
|
30
|
+
capture_output=True, text=True, check=True,
|
|
31
|
+
)
|
|
32
|
+
s = json.loads(out.stdout)["streams"][0]
|
|
33
|
+
num, den = s["r_frame_rate"].split("/")
|
|
34
|
+
return int(s["width"]), int(s["height"]), float(num) / float(den), int(s["nb_read_frames"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def frames(path, start, count, width, height, fps):
|
|
38
|
+
out = subprocess.run(
|
|
39
|
+
[FFMPEG, "-v", "error", "-ss", f"{start / fps:.6f}", "-i", path, "-frames:v", str(count),
|
|
40
|
+
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
|
41
|
+
capture_output=True, check=True,
|
|
42
|
+
)
|
|
43
|
+
data = np.frombuffer(out.stdout, dtype=np.uint8)
|
|
44
|
+
return data.reshape(-1, height, width, 3).astype(np.float32)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
parser = argparse.ArgumentParser()
|
|
49
|
+
parser.add_argument("original")
|
|
50
|
+
parser.add_argument("greenscreen")
|
|
51
|
+
parser.add_argument("--samples", type=int, default=7)
|
|
52
|
+
args = parser.parse_args()
|
|
53
|
+
|
|
54
|
+
w1, h1, fps1, n1 = probe(args.original)
|
|
55
|
+
w2, h2, fps2, n2 = probe(args.greenscreen)
|
|
56
|
+
print(f"original: {w1}x{h1} @ {fps1:.3f}fps, {n1} frames")
|
|
57
|
+
print(f"greenscreen: {w2}x{h2} @ {fps2:.3f}fps, {n2} frames")
|
|
58
|
+
if (w1, h1) != (w2, h2) or abs(fps1 - fps2) > 1e-3:
|
|
59
|
+
print("MISMATCH: resolution or frame rate differ")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
last = min(n1, n2) - 3
|
|
63
|
+
points = np.linspace(3, last, args.samples).astype(int)
|
|
64
|
+
results = []
|
|
65
|
+
for k in points:
|
|
66
|
+
green = frames(args.greenscreen, k, 1, w2, h2, fps2)[0]
|
|
67
|
+
r, g, b = green[..., 0], green[..., 1], green[..., 2]
|
|
68
|
+
background = (g > 120) & (g > r * 1.35) & (g > b * 1.35)
|
|
69
|
+
subject = ~background
|
|
70
|
+
# Erode the subject mask so blended edge pixels don't bias the comparison.
|
|
71
|
+
for _ in range(4):
|
|
72
|
+
subject = subject & np.roll(subject, 1, 0) & np.roll(subject, -1, 0) & np.roll(subject, 1, 1) & np.roll(subject, -1, 1)
|
|
73
|
+
coverage = subject.mean()
|
|
74
|
+
originals = frames(args.original, k + OFFSETS.start, len(OFFSETS), w1, h1, fps1)
|
|
75
|
+
diffs = {off: float(np.abs(originals[i][subject] - green[subject]).mean()) for i, off in enumerate(OFFSETS) if i < len(originals)}
|
|
76
|
+
best = min(diffs, key=diffs.get)
|
|
77
|
+
results.append(best)
|
|
78
|
+
pretty = " ".join(f"{off:+d}:{d:5.2f}" for off, d in diffs.items())
|
|
79
|
+
print(f"frame {k:5d} ({k / fps1:6.2f}s) subject {coverage:5.1%} best offset {best:+d} [{pretty}]")
|
|
80
|
+
|
|
81
|
+
if len(set(results)) == 1:
|
|
82
|
+
off = results[0]
|
|
83
|
+
print(f"\nRESULT: aligned with a constant offset of {off:+d} frame(s)"
|
|
84
|
+
+ (" — perfect." if off == 0 else " — shift the green-screen clip to match."))
|
|
85
|
+
else:
|
|
86
|
+
print(f"\nRESULT: offsets vary {sorted(set(results))} — clips drift; re-export both from the same timeline.")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Does this machine actually have everything the studio claims to do?
|
|
4
|
+
*
|
|
5
|
+
* npm run doctor
|
|
6
|
+
*
|
|
7
|
+
* This exists because a working setup went quiet. WhisperX had been installed and used
|
|
8
|
+
* (there are words.json files from a fortnight earlier), then the Python environment
|
|
9
|
+
* underneath it was emptied, and nothing noticed until a job needed a transcript. The
|
|
10
|
+
* code was fine the whole time. The environment was not, and there was no way to tell
|
|
11
|
+
* except by running something and watching it fail.
|
|
12
|
+
*
|
|
13
|
+
* So this checks CAPABILITIES rather than files: it runs the binaries, imports the
|
|
14
|
+
* modules, and reports what you can and cannot do right now. A capability that is
|
|
15
|
+
* missing is reported with the command that fixes it, because "not installed" with no
|
|
16
|
+
* next step is what turns a five-minute problem into a phone call.
|
|
17
|
+
*
|
|
18
|
+
* Exit code is 0 when everything the studio needs for a render is present, 1 when
|
|
19
|
+
* something core is broken. Optional pieces report but never fail the run.
|
|
20
|
+
*/
|
|
21
|
+
import {execFileSync} from 'node:child_process';
|
|
22
|
+
import {existsSync, readdirSync} from 'node:fs';
|
|
23
|
+
import {join, dirname, resolve} from 'node:path';
|
|
24
|
+
import {fileURLToPath} from 'node:url';
|
|
25
|
+
|
|
26
|
+
// fileURLToPath, not URL.pathname: a path with a space in it comes back percent-encoded
|
|
27
|
+
// from pathname, so every existsSync below silently answers false and the report claims
|
|
28
|
+
// a working studio is broken.
|
|
29
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const VENV = process.platform === 'win32'
|
|
31
|
+
? join(ROOT, '.venv', 'Scripts', 'python.exe')
|
|
32
|
+
: join(ROOT, '.venv', 'bin', 'python');
|
|
33
|
+
|
|
34
|
+
const results = [];
|
|
35
|
+
const record = (area, name, ok, detail, fix, core = true) =>
|
|
36
|
+
results.push({area, name, ok, detail, fix, core});
|
|
37
|
+
|
|
38
|
+
const tryRun = (bin, args, pick = (s) => s.trim().split('\n')[0]) => {
|
|
39
|
+
try {
|
|
40
|
+
return {ok: true, out: pick(execFileSync(bin, args, {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}))};
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return {ok: false, out: (e.stderr || e.message || '').toString().trim().split('\n')[0]};
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/* ------------------------------------------------------------------ the engine */
|
|
47
|
+
const node = process.versions.node;
|
|
48
|
+
const nodeMajor = Number(node.split('.')[0]);
|
|
49
|
+
record('engine', 'Node', nodeMajor >= 20, `v${node}`, 'Install the LTS build from nodejs.org');
|
|
50
|
+
|
|
51
|
+
const hasModules = existsSync(join(ROOT, 'node_modules', 'remotion'));
|
|
52
|
+
const pkgCount = existsSync(join(ROOT, 'node_modules')) ? readdirSync(join(ROOT, 'node_modules')).length : 0;
|
|
53
|
+
record('engine', 'Video engine', hasModules, hasModules ? `${pkgCount} packages` : 'node_modules/remotion missing', 'npm install');
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------- media handling */
|
|
56
|
+
let FFMPEG = 'ffmpeg', FFPROBE = 'ffprobe';
|
|
57
|
+
try {
|
|
58
|
+
const media = await import('./lib/media.mjs');
|
|
59
|
+
FFMPEG = media.FFMPEG;
|
|
60
|
+
FFPROBE = media.FFPROBE;
|
|
61
|
+
} catch { /* fall back to PATH and let the run below report it */ }
|
|
62
|
+
|
|
63
|
+
for (const [name, bin] of [['ffmpeg', FFMPEG], ['ffprobe', FFPROBE]]) {
|
|
64
|
+
const r = tryRun(bin, ['-version']);
|
|
65
|
+
record('media', name, r.ok, r.ok ? r.out.split(' ').slice(0, 3).join(' ') : 'not runnable',
|
|
66
|
+
'Set $FFMPEG / $FFPROBE, or install ffmpeg so it is on PATH');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ------------------------------------------------------ the transcriber (optional) */
|
|
70
|
+
const venvOk = existsSync(VENV);
|
|
71
|
+
record('transcriber', 'Python 3.11/3.12 venv', venvOk, venvOk ? VENV.replace(ROOT, '.') : '.venv missing',
|
|
72
|
+
'py -3.12 -m venv .venv (3.13+ will not build WhisperX)', false);
|
|
73
|
+
|
|
74
|
+
if (venvOk) {
|
|
75
|
+
const ver = tryRun(VENV, ['-c', 'import sys;print(sys.version.split()[0])']);
|
|
76
|
+
const minor = Number((ver.out || '0.0').split('.')[1]);
|
|
77
|
+
record('transcriber', 'Python version', ver.ok && (minor === 11 || minor === 12),
|
|
78
|
+
ver.out, 'Rebuild the venv with python 3.11 or 3.12', false);
|
|
79
|
+
|
|
80
|
+
// Every module the Python half imports, not just the headline one.
|
|
81
|
+
for (const mod of ['whisperx', 'torch', 'numpy', 'PIL', 'certifi']) {
|
|
82
|
+
const r = tryRun(VENV, ['-c', `import ${mod}`], () => 'ok');
|
|
83
|
+
record('transcriber', mod, r.ok, r.ok ? 'ok' : 'not importable',
|
|
84
|
+
'.venv/Scripts/pip install -r requirements.txt', false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ------------------------------------------------- the python half, script by script */
|
|
89
|
+
if (venvOk) {
|
|
90
|
+
// Importing whisperx is not the same as the scripts running. Compile each one, which
|
|
91
|
+
// catches a missing dependency or a syntax error without doing any of the slow work.
|
|
92
|
+
const py = readdirSync(join(ROOT, 'scripts')).filter((f) => f.endsWith('.py'));
|
|
93
|
+
const bad = [];
|
|
94
|
+
for (const f of py) {
|
|
95
|
+
const r = tryRun(VENV, ['-m', 'py_compile', join(ROOT, 'scripts', f)], () => 'ok');
|
|
96
|
+
if (!r.ok) bad.push(f);
|
|
97
|
+
}
|
|
98
|
+
record('transcriber', 'Python scripts', bad.length === 0,
|
|
99
|
+
bad.length ? `${bad.join(', ')} will not compile` : `${py.length} compile clean`,
|
|
100
|
+
'.venv/Scripts/pip install -r requirements.txt', false);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ----------------------------------------------------------------------- brand */
|
|
104
|
+
const tokens = existsSync(join(ROOT, 'src', 'design', 'tokens.ts'));
|
|
105
|
+
record('brand', 'Design tokens', tokens, tokens ? 'src/design/tokens.ts' : 'missing',
|
|
106
|
+
'Restore src/design/tokens.ts, which is where the whole look lives');
|
|
107
|
+
|
|
108
|
+
// A missing font file does not fail a render: it silently swaps in a fallback, and the
|
|
109
|
+
// video comes out looking almost right, which is worse than an error.
|
|
110
|
+
const fontDir = join(ROOT, 'assets', 'fonts');
|
|
111
|
+
const fonts = existsSync(fontDir) ? readdirSync(fontDir).filter((f) => /\.(ttf|otf|woff2?)$/i.test(f)) : [];
|
|
112
|
+
record('brand', 'Fonts', fonts.length > 0,
|
|
113
|
+
fonts.length ? `${fonts.length} file(s): ${fonts.slice(0, 2).join(', ')}` : 'assets/fonts is empty',
|
|
114
|
+
'Put the brand font files back in assets/fonts, or a render falls back to a system face');
|
|
115
|
+
|
|
116
|
+
/* --------------------------------------------------------------------- report */
|
|
117
|
+
const W = 22;
|
|
118
|
+
let lastArea = '';
|
|
119
|
+
for (const r of results) {
|
|
120
|
+
if (r.area !== lastArea) { console.log(`\n${r.area.toUpperCase()}`); lastArea = r.area; }
|
|
121
|
+
const mark = r.ok ? ' ok ' : (r.core ? ' FAIL ' : ' -- ');
|
|
122
|
+
console.log(`${mark} ${r.name.padEnd(W)} ${r.detail}`);
|
|
123
|
+
if (!r.ok) console.log(` ${''.padEnd(W)} fix: ${r.fix}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const brokenCore = results.filter((r) => !r.ok && r.core);
|
|
127
|
+
const brokenOpt = results.filter((r) => !r.ok && !r.core);
|
|
128
|
+
|
|
129
|
+
console.log('\n' + '-'.repeat(64));
|
|
130
|
+
if (!brokenCore.length && !brokenOpt.length) {
|
|
131
|
+
console.log('Everything is present. You can cut, render and caption.');
|
|
132
|
+
} else if (!brokenCore.length) {
|
|
133
|
+
console.log(`Rendering works. ${brokenOpt.length} optional piece(s) missing:`);
|
|
134
|
+
console.log(` ${[...new Set(brokenOpt.map((r) => r.fix))].join('\n ')}`);
|
|
135
|
+
console.log('Without these: no word-level captions, no auto cut detection, no green-screen cutouts.');
|
|
136
|
+
} else {
|
|
137
|
+
console.log(`${brokenCore.length} thing(s) needed for a render are missing. Fix these first:`);
|
|
138
|
+
console.log(` ${[...new Set(brokenCore.map((r) => r.fix))].join('\n ')}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
process.exit(brokenCore.length ? 1 : 0);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Find jump cuts in a talking-head clip (frames where the picture jumps instead of moving).
|
|
2
|
+
|
|
3
|
+
Usage (from the project root):
|
|
4
|
+
python scripts/find_cuts.py <video> <out.json>
|
|
5
|
+
|
|
6
|
+
Writes a JSON array of frame numbers: each is the first frame AFTER a cut. Used to
|
|
7
|
+
- punch in/out on each cut (the Stage `punchIns` prop), so cuts read as intentional camera changes
|
|
8
|
+
- tell motion-check these changes are in the footage (--allow-file)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import sys as _sys, pathlib as _pl
|
|
17
|
+
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parent / 'lib'))
|
|
18
|
+
from media import FFMPEG, FFPROBE # resolved per platform - never hardcode a tool path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
video, out_path = sys.argv[1], sys.argv[2]
|
|
24
|
+
raw = subprocess.run(
|
|
25
|
+
[FFMPEG, "-v", "error", "-i", video, "-vf", "scale=320:180", "-f", "rawvideo", "-pix_fmt", "gray", "-"],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
check=True,
|
|
28
|
+
).stdout
|
|
29
|
+
frames = np.frombuffer(raw, np.uint8).reshape(-1, 180, 320).astype(np.float32)
|
|
30
|
+
change = np.abs(np.diff(frames, axis=0)).mean(axis=(1, 2))
|
|
31
|
+
typical = float(np.median(change))
|
|
32
|
+
|
|
33
|
+
cuts = []
|
|
34
|
+
for i in range(2, len(change) - 2):
|
|
35
|
+
neighbours = float(np.median(np.concatenate([change[i - 2 : i], change[i + 1 : i + 3]])))
|
|
36
|
+
# A cut: one transition far larger than both the clip's typical motion and its neighbours.
|
|
37
|
+
if change[i] > 6 and change[i] > 4 * max(neighbours, typical):
|
|
38
|
+
cuts.append(i + 1)
|
|
39
|
+
|
|
40
|
+
json.dump(cuts, open(out_path, "w"), indent=0)
|
|
41
|
+
print(f"{len(cuts)} cuts -> {out_path}: {cuts}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|