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.
Files changed (69) hide show
  1. package/README.md +15 -0
  2. package/bin/init.mjs +72 -0
  3. package/package.json +18 -0
  4. package/template/CLAUDE.md +341 -0
  5. package/template/STYLE.md +165 -0
  6. package/template/package.json +45 -0
  7. package/template/remotion.config.ts +7 -0
  8. package/template/requirements.txt +17 -0
  9. package/template/scripts/align_script.py +106 -0
  10. package/template/scripts/assemble.mjs +364 -0
  11. package/template/scripts/beats.mjs +273 -0
  12. package/template/scripts/blur_regions.py +40 -0
  13. package/template/scripts/check_pair.py +90 -0
  14. package/template/scripts/doctor.mjs +141 -0
  15. package/template/scripts/find_cuts.py +45 -0
  16. package/template/scripts/grade.py +202 -0
  17. package/template/scripts/lib/media.mjs +228 -0
  18. package/template/scripts/lib/media.py +99 -0
  19. package/template/scripts/make_cutout.py +68 -0
  20. package/template/scripts/motion-check.mjs +268 -0
  21. package/template/scripts/motion-lint.mjs +122 -0
  22. package/template/scripts/new-video.mjs +49 -0
  23. package/template/scripts/prep.mjs +287 -0
  24. package/template/scripts/ramp.mjs +149 -0
  25. package/template/scripts/refs.mjs +72 -0
  26. package/template/scripts/refstyle.mjs +177 -0
  27. package/template/scripts/sounddesign.mjs +332 -0
  28. package/template/scripts/stills.mjs +64 -0
  29. package/template/scripts/sync-audio.mjs +134 -0
  30. package/template/scripts/transcribe.py +103 -0
  31. package/template/scripts/trim.mjs +374 -0
  32. package/template/src/Root.tsx +42 -0
  33. package/template/src/components/BrandBackground.tsx +22 -0
  34. package/template/src/components/Captions.tsx +141 -0
  35. package/template/src/components/Cursor.tsx +93 -0
  36. package/template/src/components/DrawnCircle.tsx +55 -0
  37. package/template/src/components/FilmOverlay.tsx +139 -0
  38. package/template/src/components/Hero.tsx +114 -0
  39. package/template/src/components/HeroTag.tsx +92 -0
  40. package/template/src/components/HighlightMark.tsx +34 -0
  41. package/template/src/components/LogoIcon.tsx +45 -0
  42. package/template/src/components/MacWindow.tsx +94 -0
  43. package/template/src/components/MaskReveal.tsx +34 -0
  44. package/template/src/components/MotionBlur.tsx +34 -0
  45. package/template/src/components/PlatformTile.tsx +153 -0
  46. package/template/src/components/Presence.tsx +27 -0
  47. package/template/src/components/RollingNumber.tsx +89 -0
  48. package/template/src/components/ScreenView.tsx +52 -0
  49. package/template/src/components/Stage.tsx +103 -0
  50. package/template/src/components/Transition.tsx +100 -0
  51. package/template/src/components/VelocityBlur.tsx +53 -0
  52. package/template/src/compositions/FilmTest.tsx +67 -0
  53. package/template/src/compositions/PresetTest.tsx +52 -0
  54. package/template/src/design/color.ts +6 -0
  55. package/template/src/design/glow.ts +11 -0
  56. package/template/src/design/logos.json +18 -0
  57. package/template/src/design/motion.ts +90 -0
  58. package/template/src/design/overlays.ts +121 -0
  59. package/template/src/design/presets.ts +65 -0
  60. package/template/src/design/tokens.ts +200 -0
  61. package/template/src/index.ts +4 -0
  62. package/template/src/lib/animate.ts +194 -0
  63. package/template/src/lib/camera.ts +58 -0
  64. package/template/src/lib/continuity.ts +86 -0
  65. package/template/src/lib/project.ts +13 -0
  66. package/template/src/lib/spine.ts +37 -0
  67. package/template/src/videos/sample/Sample.tsx +58 -0
  68. package/template/templates/BRIEF.md +88 -0
  69. 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()