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,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()
|