๐Ÿ— KeyzHub
19Keys ยท community archive
5546 bytes raw
  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))