1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119 | #!/usr/bin/env python3
"""motion_render.py โ render a clip through a Remotion motion composition (animation + cuts).
Pre-cuts the source segment into compositions/motion/public, extracts clip-relative word timings,
writes props, and runs `npx remotion render`. First capability: KineticCaptions (word-punch karaoke
captions + animated hook + handle) over the live footage.
CLI: python3 motion_render.py --source V --start S --end E --out O [--words W.json] [--hook ".."] [--comp KineticCaptions]
"""
import argparse
import json
import os
import subprocess
from pathlib import Path
HERE = Path(__file__).resolve().parent # tools/
STUDIO = HERE.parent
MOTION = STUDIO / "compositions" / "motion"
PUBLIC = MOTION / "public"
FFMPEG = "/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg"
FFPROBE = "/opt/homebrew/opt/ffmpeg-full/bin/ffprobe"
def probe_src(source):
"""(width, height, fps) of the source โ so renders match it instead of forcing 1080x1920@30."""
try:
o = subprocess.run([FFPROBE, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,r_frame_rate", "-of", "csv=p=0",
str(source)], capture_output=True, text=True, timeout=30).stdout.strip()
w, h, fr = o.split(",")[:3]
num, den = (fr.split("/") + ["1"])[:2]
fps = float(num) / float(den or 1)
return int(w), int(h), round(fps, 3)
except Exception:
return 1920, 1080, 30.0
def clip_words(words_path, start, end):
try:
words = json.loads(Path(words_path).read_text()).get("words", [])
except Exception:
return []
out = []
for w in words:
if w.get("type", "word") != "word":
continue
s = w.get("start", 0)
if start - 0.05 <= s <= end + 0.05:
out.append({"start": round(s - start, 3), "end": round(w.get("end", s) - start, 3),
"text": w.get("text", "")})
return out
def render(source, start, end, out, words_path=None, hook="", handle="@19keys",
accent="#E4C56B", fps=None, comp="KineticCaptions", extra=None):
start, end = float(start), float(end)
dur = end - start
# HOUSE RULE: never downscale, match source fps. Comps are authored 1080x1920;
# Remotion --scale multiplies the canvas, so a 4K source renders at 2160-tall, 60fps stays 60.
src_w, src_h, src_fps = probe_src(source)
if not fps:
fps = src_fps if src_fps and 10 <= src_fps <= 120 else 30.0
crop_h = min(src_h, int(src_w * 16 / 9)) # true vertical pixels available from a 9:16 crop
scale = max(1.0, min(2.0, round(crop_h / 1920, 3))) # 1080p src โ 1.0; 4K src โ 1.125..2.0
W, H = int(1080 * scale) // 2 * 2, int(1920 * scale) // 2 * 2
cid = f"{int(start * 1000)}_{int(end * 1000)}"
clip_name = f"clip_{cid}.mp4"
PUBLIC.mkdir(parents=True, exist_ok=True)
clip_path = PUBLIC / clip_name
subprocess.run([FFMPEG, "-y", "-ss", str(start), "-to", str(end), "-i", str(source),
"-vf", f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},setsar=1",
"-r", str(fps), "-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "160k", str(clip_path)], capture_output=True)
if not clip_path.exists():
raise RuntimeError("pre-cut failed")
# "clip" (KineticCaptions) and "src" (VoxEditorial) both point at the cut footage
props = {"clip": clip_name, "src": clip_name, "isImage": False,
"fps": fps, "durationInFrames": max(1, round(dur * fps)),
"words": clip_words(words_path, start, end) if words_path else [],
"hook": hook, "handle": handle, "accent": accent}
if comp == "AudioWaveform":
audio_name = f"audio_{cid}.mp3"
subprocess.run([FFMPEG, "-y", "-i", str(clip_path), "-vn",
"-c:a", "libmp3lame", "-q:a", "4", str(PUBLIC / audio_name)], capture_output=True)
props["audio"] = audio_name
if extra:
props.update(extra)
propf = MOTION / f"props_{cid}.json"
propf.write_text(json.dumps(props))
out = Path(out); out.parent.mkdir(parents=True, exist_ok=True)
env = dict(os.environ); env["PATH"] = "/opt/homebrew/bin:" + env.get("PATH", "")
cmd = ["npx", "remotion", "render", "src/index.ts", comp, str(out),
f"--props={propf.name}", "--concurrency=2"]
if scale > 1.0:
cmd.append(f"--scale={scale}") # render the canvas at source-true resolution
r = subprocess.run(cmd, cwd=str(MOTION), env=env, capture_output=True, text=True, timeout=1800)
try:
propf.unlink()
except OSError:
pass
if not out.exists():
raise RuntimeError("remotion failed: " + (r.stderr or r.stdout or "")[-700:])
return str(out)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--source", required=True)
ap.add_argument("--start", type=float, required=True)
ap.add_argument("--end", type=float, required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--words", default=None)
ap.add_argument("--hook", default="")
ap.add_argument("--handle", default="@19keys")
ap.add_argument("--accent", default="#E4C56B")
ap.add_argument("--comp", default="KineticCaptions")
a = ap.parse_args()
print(render(a.source, a.start, a.end, a.out, words_path=a.words, hook=a.hook,
handle=a.handle, accent=a.accent, comp=a.comp))
|