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,202 @@
1
+ """Build a colour-grade LUT that moves footage towards the look of reference images.
2
+
3
+ Usage (from the project root):
4
+ python scripts/grade.py <source_frames_dir> <out.cube> <ref.png> [<ref.png> ...]
5
+ [--tone 0.8] [--tint 0.85] [--sat 0.8] [--greens 0.8] [--strength 1.0] [--report out.json]
6
+
7
+ Everything happens in CIE Lab, and every step is a function of the pixel colour only, so the whole
8
+ grade bakes into a 33^3 .cube LUT (usable in ffmpeg `lut3d` and Premiere Lumetri):
9
+
10
+ 1. tone — contrast shape matched to the references (quantile matching of L), keeping part of the
11
+ footage's own exposure (EXPOSURE_KEEP) so dusk footage isn't lifted flat.
12
+ 2. tint — colour of shadows / mids / highlights (split toning), measured on low-to-moderate
13
+ chroma surfaces, so strongly coloured content (grass, clothes) doesn't leak into the grade.
14
+ 3. sat — overall saturation towards the references' chroma level.
15
+ 4. greens — hue and saturation of foliage towards the references' greens (e.g. olive/yellow).
16
+ Skin (orange hues) is protected: no hue rotation and no desaturation below 95%.
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+
23
+ import numpy as np
24
+ from PIL import Image
25
+
26
+ M = np.array([[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]])
27
+ M_INV = np.linalg.inv(M)
28
+ WHITE = np.array([0.95047, 1.0, 1.08883])
29
+ D = 6 / 29
30
+
31
+
32
+ def srgb_to_lab(rgb):
33
+ c = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
34
+ xyz = c @ M.T / WHITE
35
+ f = np.where(xyz > D**3, np.cbrt(xyz), xyz / (3 * D * D) + 4 / 29)
36
+ return np.stack([116 * f[..., 1] - 16, 500 * (f[..., 0] - f[..., 1]), 200 * (f[..., 1] - f[..., 2])], axis=-1)
37
+
38
+
39
+ def lab_to_srgb(lab):
40
+ fy = (lab[..., 0] + 16) / 116
41
+ f = np.stack([fy + lab[..., 1] / 500, fy, fy - lab[..., 2] / 200], axis=-1)
42
+ xyz = np.where(f > D, f**3, 3 * D * D * (f - 4 / 29)) * WHITE
43
+ lin = np.clip(xyz @ M_INV.T, 0, 1)
44
+ return np.clip(np.where(lin <= 0.0031308, lin * 12.92, 1.055 * lin ** (1 / 2.4) - 0.055), 0, 1)
45
+
46
+
47
+ def load(path, max_side=640):
48
+ img = Image.open(path).convert("RGB")
49
+ img.thumbnail((max_side, max_side))
50
+ a = np.asarray(img, dtype=np.float64) / 255
51
+ # Trim uniform borders (letterbox bars, white frames) — they are not part of the look.
52
+ rows = a.std(axis=(1, 2)) > 0.02
53
+ cols = a.std(axis=(0, 2)) > 0.02
54
+ if rows.any() and cols.any():
55
+ a = a[np.argmax(rows) : len(rows) - np.argmax(rows[::-1]), np.argmax(cols) : len(cols) - np.argmax(cols[::-1])]
56
+ return a
57
+
58
+
59
+ def pixels(paths):
60
+ lab = np.concatenate([srgb_to_lab(load(p)).reshape(-1, 3) for p in paths])
61
+ chroma = np.hypot(lab[:, 1], lab[:, 2])
62
+ # Drop overlay text / UI (pure white or pure black) from the statistics.
63
+ keep = ~(((lab[:, 0] > 96) & (chroma < 4)) | (lab[:, 0] < 1))
64
+ return lab[keep]
65
+
66
+
67
+ BANDS = (15.0, 45.0, 80.0) # shadow / mid / highlight centres in L
68
+ NEUTRAL_MAX = 32.0 # chroma below this counts towards the tint estimate
69
+ EXPOSURE_KEEP = 0.45 # 0 = match reference exposure fully, 1 = keep footage exposure
70
+
71
+
72
+ def band_weights(L):
73
+ w = np.stack([np.exp(-0.5 * ((L - c) / 16) ** 2) for c in BANDS], axis=-1)
74
+ return w / np.maximum(w.sum(axis=-1, keepdims=True), 1e-9)
75
+
76
+
77
+ def neutral_tint(lab):
78
+ """Mean a/b of near-neutral pixels per brightness band (the grade's tint, not the scene's colours)."""
79
+ chroma = np.hypot(lab[:, 1], lab[:, 2])
80
+ # Low-to-moderate chroma surfaces carry the grade's tint (sunlit walls, skin highlights); strongly
81
+ # coloured objects (grass, clothes) are excluded, and lower-chroma pixels count more.
82
+ sel = chroma < NEUTRAL_MAX
83
+ n = lab[sel]
84
+ trust = 1 - chroma[sel] / NEUTRAL_MAX
85
+ w = band_weights(n[:, 0]) * trust[:, None]
86
+ return np.array([[np.average(n[:, k], weights=w[:, i] + 1e-9) for k in (1, 2)] for i in range(len(BANDS))])
87
+
88
+
89
+ def hue_deg(lab):
90
+ return np.degrees(np.arctan2(lab[..., 2], lab[..., 1])) % 360
91
+
92
+
93
+ GREEN = (95.0, 175.0)
94
+ SKIN = (15.0, 75.0)
95
+
96
+
97
+ def greens_stats(lab):
98
+ h = hue_deg(lab)
99
+ c = np.hypot(lab[:, 1], lab[:, 2])
100
+ g = (h > GREEN[0]) & (h < GREEN[1]) & (c > 8)
101
+ if g.sum() < 200:
102
+ return None
103
+ ang = np.radians(h[g])
104
+ mean_h = np.degrees(np.arctan2(np.average(np.sin(ang), weights=c[g]), np.average(np.cos(ang), weights=c[g]))) % 360
105
+ return {"hue": float(mean_h), "chroma": float(np.percentile(c[g], 75))}
106
+
107
+
108
+ def window(h, lo, hi, soft=15.0):
109
+ """1 inside [lo, hi], cosine fall-off over `soft` degrees outside."""
110
+ d = np.maximum(lo - h, 0) + np.maximum(h - hi, 0)
111
+ return np.where(d >= soft, 0.0, 0.5 + 0.5 * np.cos(np.pi * np.clip(d / soft, 0, 1)))
112
+
113
+
114
+ def build(src, tgt, tone, tint, sat, greens):
115
+ qs = np.linspace(0.5, 99.5, 41)
116
+ src_q = np.percentile(src[:, 0], qs)
117
+ tgt_q = np.percentile(tgt[:, 0], qs)
118
+ src_q = np.maximum.accumulate(src_q + np.arange(len(qs)) * 1e-6)
119
+ # Match contrast shape, not absolute exposure: pull the target curve part-way back to the
120
+ # footage's own median, so a dusk shot is not lifted into a flat, washed look.
121
+ tgt_q = tgt_q - EXPOSURE_KEEP * (np.median(tgt[:, 0]) - np.median(src[:, 0]))
122
+ tgt_q = np.clip(tgt_q, 0, 100)
123
+ shift = neutral_tint(tgt) - neutral_tint(src)
124
+ sat_ratio = float(np.clip(np.percentile(np.hypot(tgt[:, 1], tgt[:, 2]), 85) / np.percentile(np.hypot(src[:, 1], src[:, 2]), 85), 0.7, 1.15))
125
+ gs, gt = greens_stats(src), greens_stats(tgt)
126
+ if gs and gt:
127
+ g_rot = float(np.clip(((gt["hue"] - gs["hue"] + 180) % 360) - 180, -30, 30))
128
+ g_sat = float(np.clip(gt["chroma"] / gs["chroma"], 0.55, 1.1))
129
+ else:
130
+ g_rot, g_sat = 0.0, 1.0
131
+ params = {"tone_src_q": src_q.tolist(), "tone_tgt_q": tgt_q.tolist(), "tint_shift": shift.tolist(), "sat_ratio": sat_ratio, "green_rot_deg": g_rot, "green_sat": g_sat}
132
+
133
+ def apply(lab):
134
+ L, a, b = lab[..., 0], lab[..., 1], lab[..., 2]
135
+ # 1. tone
136
+ L2 = L + tone * (np.interp(L, src_q, tgt_q) - L)
137
+ h = hue_deg(lab)
138
+ c = np.hypot(a, b)
139
+ skin = window(h, *SKIN) * np.clip(c / 12, 0, 1)
140
+ grn = window(h, *GREEN, soft=20) * np.clip(c / 8, 0, 1)
141
+ # 3 + 4. saturation (skin never below 95%) and greens
142
+ s = 1 + sat * (sat_ratio - 1)
143
+ s = np.where(skin > 0, np.maximum(s, 0.95 * skin + s * (1 - skin)), s)
144
+ s = s * (1 + greens * grn * (g_sat - 1))
145
+ rot = np.radians(greens * grn * g_rot)
146
+ h2 = np.arctan2(b, a) + rot
147
+ c2 = c * s
148
+ a2, b2 = c2 * np.cos(h2), c2 * np.sin(h2)
149
+ # 2. tint (split toning) by the new brightness; skin gets 70% of it
150
+ w = band_weights(L2)
151
+ da = (w * shift[:, 0]).sum(axis=-1)
152
+ db = (w * shift[:, 1]).sum(axis=-1)
153
+ k = tint * (1 - 0.15 * skin)
154
+ return np.stack([np.clip(L2, 0, 100), a2 + k * da, b2 + k * db], axis=-1)
155
+
156
+ return apply, params
157
+
158
+
159
+ def write_cube(path, fn, strength, size=33):
160
+ g = np.linspace(0, 1, size)
161
+ b, gg, r = np.meshgrid(g, g, g, indexing="ij") # red changes fastest in .cube order
162
+ rgb = np.stack([r, gg, b], axis=-1).reshape(-1, 3)
163
+ lab = srgb_to_lab(rgb)
164
+ out = lab_to_srgb(lab + strength * (fn(lab) - lab))
165
+ with open(path, "w", encoding="ascii") as fh:
166
+ fh.write(f'TITLE "{os.path.splitext(os.path.basename(path))[0]}"\nLUT_3D_SIZE {size}\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n')
167
+ for v in out:
168
+ fh.write(f"{v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n")
169
+
170
+
171
+ def main():
172
+ p = argparse.ArgumentParser()
173
+ p.add_argument("frames_dir")
174
+ p.add_argument("out_cube")
175
+ p.add_argument("refs", nargs="+")
176
+ p.add_argument("--tone", type=float, default=0.8)
177
+ p.add_argument("--tint", type=float, default=0.85)
178
+ p.add_argument("--sat", type=float, default=0.8)
179
+ p.add_argument("--greens", type=float, default=0.8)
180
+ p.add_argument("--strength", type=float, default=1.0)
181
+ p.add_argument("--report")
182
+ a = p.parse_args()
183
+ frames = [os.path.join(a.frames_dir, f) for f in sorted(os.listdir(a.frames_dir)) if f.lower().endswith((".png", ".jpg"))]
184
+ src, tgt = pixels(frames), pixels(a.refs)
185
+ fn, params = build(src, tgt, a.tone, a.tint, a.sat, a.greens)
186
+ write_cube(a.out_cube, fn, a.strength)
187
+ summary = {
188
+ "source_L_p5_p50_p95": np.percentile(src[:, 0], [5, 50, 95]).round(1).tolist(),
189
+ "target_L_p5_p50_p95": np.percentile(tgt[:, 0], [5, 50, 95]).round(1).tolist(),
190
+ "tint_shift_ab_shadow_mid_high": np.round(params["tint_shift"], 2).tolist(),
191
+ "sat_ratio": round(params["sat_ratio"], 3),
192
+ "green_rot_deg": round(params["green_rot_deg"], 1),
193
+ "green_sat": round(params["green_sat"], 3),
194
+ }
195
+ print(json.dumps(summary))
196
+ if a.report:
197
+ with open(a.report, "w", encoding="utf-8") as fh:
198
+ json.dump(summary, fh, indent=2)
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Shared media helpers for the analysis scripts (beats, sync, reference style).
3
+ * Everything goes through ffmpeg; nothing here needs Python or a GPU.
4
+ */
5
+ import {spawnSync} from 'node:child_process';
6
+ import {existsSync} from 'node:fs';
7
+ import {createRequire} from 'node:module';
8
+ import {dirname, join, resolve} from 'node:path';
9
+ import {fileURLToPath} from 'node:url';
10
+
11
+ /** The studio root, found from this file rather than from wherever the shell happens to be. */
12
+ const STUDIO = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
13
+
14
+ /**
15
+ * Find ffmpeg wherever this machine keeps it, in order of how reliable it is:
16
+ *
17
+ * 1. $FFMPEG / $FFPROBE, for anyone who knows exactly what they want.
18
+ * 2. The ffmpeg-static / ffprobe-static packages, which ship a binary per platform and
19
+ * arrive with `npm install`. This is the one that makes a fresh machine just work —
20
+ * no Homebrew, no winget, no PATH editing, nothing for anyone to get wrong.
21
+ * 3. Whatever is on PATH.
22
+ * 4. The usual install locations per platform.
23
+ *
24
+ * Every script goes through this, so a studio behaves the same on a Mac laptop as on a
25
+ * Windows desktop, and the same for us as for someone who bought it this morning.
26
+ */
27
+ const require_ = createRequire(import.meta.url);
28
+
29
+ const fromPackage = (tool) => {
30
+ try {
31
+ const mod = require_(`${tool}-static`);
32
+ const resolved = typeof mod === 'string' ? mod : mod?.path;
33
+ return resolved && existsSync(resolved) ? resolved : null;
34
+ } catch {
35
+ return null; // Not installed — fall through to the system copy.
36
+ }
37
+ };
38
+
39
+ const locate = (tool) => {
40
+ const fromEnv = process.env[tool.toUpperCase()];
41
+ if (fromEnv) return fromEnv;
42
+
43
+ const packaged = fromPackage(tool);
44
+ if (packaged) return packaged;
45
+
46
+ const win = process.platform === 'win32';
47
+ const exe = win ? `${tool}.exe` : tool;
48
+
49
+ // On PATH is the common case once the installer has run.
50
+ const which = spawnSync(win ? 'where' : 'which', [exe], {encoding: 'utf8'});
51
+ if (which.status === 0) {
52
+ const hit = which.stdout.split('\n')[0].trim();
53
+ if (hit) return hit;
54
+ }
55
+
56
+ const candidates = win
57
+ ? [`C:\\ffmpeg\\bin\\${exe}`, `D:\\Tools\\ffmpeg\\bin\\${exe}`, `${process.env.LOCALAPPDATA}\\ffmpeg\\bin\\${exe}`]
58
+ : [`/opt/homebrew/bin/${exe}`, `/usr/local/bin/${exe}`, `/usr/bin/${exe}`, `${process.env.HOME}/.local/bin/${exe}`];
59
+ for (const c of candidates) if (c && existsSync(c)) return c;
60
+
61
+ // Fall back to the bare name so the error says "not found" rather than a wrong path.
62
+ return exe;
63
+ };
64
+
65
+ export const FFMPEG = locate('ffmpeg');
66
+ export const FFPROBE = locate('ffprobe');
67
+
68
+ /**
69
+ * The Python that has the studio's packages. A virtual environment inside the studio wins,
70
+ * then $PYTHON, then whatever is on PATH. Mac and Linux answer to `python3`; a bare
71
+ * `python` there is often Python 2 or nothing at all.
72
+ */
73
+ export const PYTHON = (() => {
74
+ if (process.env.PYTHON) return process.env.PYTHON;
75
+ const win = process.platform === 'win32';
76
+ // Resolved against the studio, not the cwd: run any of these scripts from another folder
77
+ // and a cwd-relative path silently misses the venv, falls through to whatever python is
78
+ // on PATH, and dies with ModuleNotFoundError as though nothing had ever been installed.
79
+ const venv = win ? join(STUDIO, '.venv', 'Scripts', 'python.exe') : join(STUDIO, '.venv', 'bin', 'python3');
80
+ if (existsSync(venv)) return venv;
81
+ for (const name of win ? ['python.exe', 'python3.exe'] : ['python3', 'python']) {
82
+ const which = spawnSync(win ? 'where' : 'which', [name], {encoding: 'utf8'});
83
+ if (which.status === 0) {
84
+ const hit = which.stdout.split('\n')[0].trim();
85
+ if (hit) return hit;
86
+ }
87
+ }
88
+ return win ? 'python' : 'python3';
89
+ })();
90
+
91
+ export function run(bin, args, opts = {}) {
92
+ const r = spawnSync(bin, args, {encoding: 'utf8', maxBuffer: 1024 * 1024 * 256, ...opts});
93
+ return {ok: r.status === 0, out: r.stdout || '', err: r.stderr || '', status: r.status};
94
+ }
95
+
96
+ export function probe(path) {
97
+ const r = run(FFPROBE, [
98
+ '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path,
99
+ ]);
100
+ if (!r.ok) return null;
101
+ try {
102
+ const data = JSON.parse(r.out);
103
+ const v = (data.streams || []).find((s) => s.codec_type === 'video');
104
+ const a = (data.streams || []).find((s) => s.codec_type === 'audio');
105
+ const [n, d] = String(v?.r_frame_rate || '0/1').split('/').map(Number);
106
+ return {
107
+ duration: Number(data.format?.duration || 0),
108
+ width: v?.width ?? 0,
109
+ height: v?.height ?? 0,
110
+ fps: d ? n / d : 0,
111
+ hasAudio: Boolean(a),
112
+ };
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /** Decode any media's audio to mono float32 samples at the given rate. */
119
+ export function decodePcm(path, rate = 22050) {
120
+ const r = spawnSync(
121
+ FFMPEG,
122
+ ['-v', 'error', '-i', path, '-vn', '-ac', '1', '-ar', String(rate), '-f', 'f32le', '-'],
123
+ {maxBuffer: 1024 * 1024 * 512, encoding: 'buffer'},
124
+ );
125
+ if (r.status !== 0 || !r.stdout?.length) return null;
126
+ const buf = r.stdout;
127
+ const usable = buf.length - (buf.length % 4);
128
+ return new Float32Array(buf.buffer, buf.byteOffset, usable / 4);
129
+ }
130
+
131
+ /** In-place iterative radix-2 FFT. re/im are Float64Array of length 2^k. */
132
+ export function fft(re, im) {
133
+ const n = re.length;
134
+ for (let i = 1, j = 0; i < n; i++) {
135
+ let bit = n >> 1;
136
+ for (; j & bit; bit >>= 1) j ^= bit;
137
+ j ^= bit;
138
+ if (i < j) {
139
+ [re[i], re[j]] = [re[j], re[i]];
140
+ [im[i], im[j]] = [im[j], im[i]];
141
+ }
142
+ }
143
+ for (let len = 2; len <= n; len <<= 1) {
144
+ const ang = (-2 * Math.PI) / len;
145
+ const wr = Math.cos(ang);
146
+ const wi = Math.sin(ang);
147
+ for (let i = 0; i < n; i += len) {
148
+ let cr = 1;
149
+ let ci = 0;
150
+ for (let k = 0; k < len / 2; k++) {
151
+ const ur = re[i + k];
152
+ const ui = im[i + k];
153
+ const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
154
+ const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
155
+ re[i + k] = ur + vr;
156
+ im[i + k] = ui + vi;
157
+ re[i + k + len / 2] = ur - vr;
158
+ im[i + k + len / 2] = ui - vi;
159
+ const ncr = cr * wr - ci * wi;
160
+ ci = cr * wi + ci * wr;
161
+ cr = ncr;
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Onset strength over time (spectral flux): how much new energy arrives in each
169
+ * short window. This is the signal every beat and cut detector is built on.
170
+ */
171
+ export function onsetEnvelope(samples, rate, {frameSize = 1024, hop = 512} = {}) {
172
+ const frames = Math.max(0, Math.floor((samples.length - frameSize) / hop));
173
+ const env = new Float64Array(frames);
174
+ const window = new Float64Array(frameSize);
175
+ for (let i = 0; i < frameSize; i++) window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (frameSize - 1));
176
+ let prev = new Float64Array(frameSize / 2);
177
+ const re = new Float64Array(frameSize);
178
+ const im = new Float64Array(frameSize);
179
+ for (let f = 0; f < frames; f++) {
180
+ const off = f * hop;
181
+ for (let i = 0; i < frameSize; i++) {
182
+ re[i] = samples[off + i] * window[i];
183
+ im[i] = 0;
184
+ }
185
+ fft(re, im);
186
+ let flux = 0;
187
+ const mag = new Float64Array(frameSize / 2);
188
+ for (let b = 0; b < frameSize / 2; b++) {
189
+ const m = Math.sqrt(re[b] * re[b] + im[b] * im[b]);
190
+ mag[b] = m;
191
+ const diff = m - prev[b];
192
+ if (diff > 0) flux += diff;
193
+ }
194
+ env[f] = flux;
195
+ prev = mag;
196
+ }
197
+ return {env, hopSeconds: hop / rate};
198
+ }
199
+
200
+ /** Loudness envelope, used for lining two recordings up against each other. */
201
+ export function energyEnvelope(samples, rate, hop = 256) {
202
+ const frames = Math.floor(samples.length / hop);
203
+ const env = new Float64Array(frames);
204
+ for (let f = 0; f < frames; f++) {
205
+ let sum = 0;
206
+ for (let i = 0; i < hop; i++) {
207
+ const s = samples[f * hop + i];
208
+ sum += s * s;
209
+ }
210
+ env[f] = Math.sqrt(sum / hop);
211
+ }
212
+ return {env, hopSeconds: hop / rate};
213
+ }
214
+
215
+ export const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0);
216
+ export const median = (a) => {
217
+ if (!a.length) return 0;
218
+ const s = [...a].sort((x, y) => x - y);
219
+ const m = s.length >> 1;
220
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
221
+ };
222
+ export const fmt = (n, d = 2) => Number(n).toFixed(d);
223
+ export const timecode = (sec) => {
224
+ const s = Math.max(0, sec);
225
+ const mm = String(Math.floor(s / 60)).padStart(2, '0');
226
+ const ss = String(Math.floor(s % 60)).padStart(2, '0');
227
+ return `${mm}:${ss}`;
228
+ };
@@ -0,0 +1,99 @@
1
+ """
2
+ Shared tool resolution for the Python helpers.
3
+
4
+ The Node side has lib/media.mjs; this is its twin, and it exists for the same reason:
5
+ every script that shells out to ffmpeg must ask *here* where ffmpeg is, never hardcode a
6
+ path. Seven scripts once carried `D:\\Tools\\ffmpeg\\bin\\ffmpeg.exe`, which meant the
7
+ whole Python half of the studio was dead on any machine that was not one specific Windows
8
+ desktop. A studio that only runs on its author's computer is not a product.
9
+
10
+ Resolution order, most explicit first:
11
+ 1. $FFMPEG / $FFPROBE, for anyone who knows exactly what they want
12
+ 2. the ffmpeg-static / ffprobe-static binaries that arrive with `npm install`
13
+ 3. whatever is on PATH
14
+ 4. the usual install locations per platform
15
+ """
16
+
17
+ import os
18
+ import platform
19
+ import shutil
20
+ from pathlib import Path
21
+
22
+ _IS_WIN = platform.system() == "Windows"
23
+ _EXE = ".exe" if _IS_WIN else ""
24
+
25
+ # Repo root, from scripts/lib/media.py
26
+ _ROOT = Path(__file__).resolve().parent.parent.parent
27
+
28
+
29
+ def _from_node_modules(tool: str):
30
+ """The per-platform binary npm installs. Present in every studio after setup."""
31
+ pkg = _ROOT / "node_modules" / f"{tool}-static"
32
+ if not pkg.is_dir():
33
+ return None
34
+ # ffmpeg-static puts the binary at the package root; ffprobe-static nests it per platform.
35
+ direct = pkg / f"{tool}{_EXE}"
36
+ if direct.is_file():
37
+ return str(direct)
38
+ for found in pkg.rglob(f"{tool}{_EXE}"):
39
+ if found.is_file():
40
+ return str(found)
41
+ return None
42
+
43
+
44
+ def _candidates(tool: str):
45
+ if _IS_WIN:
46
+ return [
47
+ rf"C:\ffmpeg\bin\{tool}{_EXE}",
48
+ rf"D:\Tools\ffmpeg\bin\{tool}{_EXE}",
49
+ os.path.join(os.environ.get("LOCALAPPDATA", ""), "ffmpeg", "bin", f"{tool}{_EXE}"),
50
+ ]
51
+ return [
52
+ f"/opt/homebrew/bin/{tool}",
53
+ f"/usr/local/bin/{tool}",
54
+ f"/usr/bin/{tool}",
55
+ os.path.expanduser(f"~/.local/bin/{tool}"),
56
+ ]
57
+
58
+
59
+ def locate(tool: str) -> str:
60
+ from_env = os.environ.get(tool.upper())
61
+ if from_env:
62
+ return from_env
63
+
64
+ packaged = _from_node_modules(tool)
65
+ if packaged:
66
+ return packaged
67
+
68
+ on_path = shutil.which(tool)
69
+ if on_path:
70
+ return on_path
71
+
72
+ for candidate in _candidates(tool):
73
+ if candidate and Path(candidate).is_file():
74
+ return candidate
75
+
76
+ # Fall back to the bare name so the error reads "not found" rather than a wrong path.
77
+ return tool
78
+
79
+
80
+ FFMPEG = locate("ffmpeg")
81
+ FFPROBE = locate("ffprobe")
82
+
83
+
84
+ def use_certifi() -> None:
85
+ """
86
+ Point urllib at certifi's root certificates.
87
+
88
+ torch.hub downloads model weights with urllib, which on a python.org macOS build has
89
+ no root certificates at all — so transcription dies with CERTIFICATE_VERIFY_FAILED
90
+ partway through, after the slow part has already succeeded. Call this before importing
91
+ torch in any script that downloads a model.
92
+ """
93
+ try:
94
+ import certifi
95
+ except ImportError:
96
+ return
97
+ bundle = certifi.where()
98
+ os.environ.setdefault("SSL_CERT_FILE", bundle)
99
+ os.environ.setdefault("REQUESTS_CA_BUNDLE", bundle)
@@ -0,0 +1,68 @@
1
+ """Build a transparent cutout of the subject for a frame range of a paired clip.
2
+
3
+ Usage (from the project root):
4
+ python scripts/make_cutout.py <original> <greenscreen> <out_dir> <first_frame> <last_frame>
5
+
6
+ The green-screen clip is used ONLY for the matte (alpha). Colour comes from the original clip, so no
7
+ green ever reaches the subject's edges. Writes RGBA PNGs named f<frame>.png (absolute source frame
8
+ numbers) so compositions can map composition frames to cutout frames directly.
9
+ """
10
+
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ from PIL import Image
17
+ import sys as _sys, pathlib as _pl
18
+ _sys.path.insert(0, str(_pl.Path(__file__).resolve().parent / 'lib'))
19
+ from media import FFMPEG, FFPROBE # resolved per platform - never hardcode a tool path
20
+
21
+
22
+
23
+ W, H, FPS = 1920, 1080, 30
24
+
25
+
26
+ def read_frames(path: str, first: int, count: int):
27
+ proc = subprocess.Popen(
28
+ [FFMPEG, "-v", "error", "-ss", f"{first / FPS:.6f}", "-i", path, "-frames:v", str(count),
29
+ "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
30
+ stdout=subprocess.PIPE,
31
+ )
32
+ size = W * H * 3
33
+ for _ in range(count):
34
+ buf = proc.stdout.read(size)
35
+ if len(buf) < size:
36
+ break
37
+ yield np.frombuffer(buf, np.uint8).reshape(H, W, 3)
38
+ proc.stdout.close()
39
+ proc.wait()
40
+
41
+
42
+ def matte(green: np.ndarray) -> np.ndarray:
43
+ """Alpha 0..1 from how green each pixel is, relative to the flat background green."""
44
+ g = green.astype(np.float32)
45
+ greenness = g[..., 1] - np.maximum(g[..., 0], g[..., 2])
46
+ background = np.median(greenness[greenness > 60]) if (greenness > 60).any() else 150.0
47
+ lo, hi = 0.2 * background, 0.7 * background
48
+ t = np.clip((greenness - lo) / (hi - lo), 0, 1)
49
+ alpha = 1 - t * t * (3 - 2 * t) # smoothstep: soft, stable edge
50
+ # Choke by 1px so the background never leaks in, then feather lightly.
51
+ choked = np.minimum.reduce([alpha, np.roll(alpha, 1, 0), np.roll(alpha, -1, 0), np.roll(alpha, 1, 1), np.roll(alpha, -1, 1)])
52
+ feathered = (choked * 4 + np.roll(choked, 1, 0) + np.roll(choked, -1, 0) + np.roll(choked, 1, 1) + np.roll(choked, -1, 1)) / 8
53
+ return feathered
54
+
55
+
56
+ def main() -> None:
57
+ original, greenscreen, out_dir, first, last = sys.argv[1], sys.argv[2], Path(sys.argv[3]), int(sys.argv[4]), int(sys.argv[5])
58
+ out_dir.mkdir(parents=True, exist_ok=True)
59
+ count = last - first + 1
60
+ for offset, (rgb, green) in enumerate(zip(read_frames(original, first, count), read_frames(greenscreen, first, count))):
61
+ alpha = (matte(green) * 255).round().astype(np.uint8)
62
+ rgba = np.dstack([rgb, alpha])
63
+ Image.fromarray(rgba, "RGBA").save(out_dir / f"f{first + offset}.png", compress_level=1)
64
+ print(f"wrote {count} frames to {out_dir}")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()