"""③ Remotion step — VideoSpec + base video → final mp4 with overlays. Generalized: builds a props JSON (video config + default timings + spec content) and runs `npx remotion render MumumShort --props=`. The base video is copied into remotion/public/ so OffthreadVideo's staticFile() can resolve it. """ from __future__ import annotations import json import shutil import subprocess import uuid from pathlib import Path from app.schemas import VideoSpec ENGINE = Path(__file__).resolve().parents[3] # engine/higgsfield_shorts REMOTION = ENGINE / "remotion" PUBLIC = REMOTION / "public" OUT_DIR = REMOTION / "out" FPS = 30 def _probe_frames(video_path: Path, fps: int = FPS) -> tuple[int, int, int]: """Return (durationInFrames, width, height) via ffprobe.""" out = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,duration", "-of", "json", str(video_path)], capture_output=True, text=True, check=True, ) info = json.loads(out.stdout)["streams"][0] width = int(info["width"]) height = int(info["height"]) seconds = float(info.get("duration", 8.0)) return max(1, round(seconds * fps)), width, height def _timings(total: int) -> dict: """Default 4-beat layout, clamped to the actual video length.""" def clamp(f: int) -> int: return max(0, min(f, total)) return { "hook": {"fromFrame": 0, "toFrame": clamp(78)}, "sellingPoint": {"fromFrame": clamp(80), "toFrame": clamp(138)}, "brandLines": {"fromFrame": clamp(138), "toFrame": clamp(196)}, "endCard": {"fromFrame": clamp(total - 49), "toFrame": total}, } def build_props(spec: VideoSpec, base_video: Path) -> tuple[dict, str]: """Copy base video into public/ and assemble the Remotion props dict.""" PUBLIC.mkdir(parents=True, exist_ok=True) job = f"job_{uuid.uuid4().hex[:8]}.mp4" shutil.copy(base_video, PUBLIC / job) frames, width, height = _probe_frames(PUBLIC / job) t = _timings(frames) props = { "videoSrc": job, "fps": FPS, "width": width, "height": height, "durationInFrames": frames, "hook": {"eyebrow": spec.hook.eyebrow, "title": spec.hook.title, **t["hook"]}, "sellingPoint": {"items": spec.selling_point.items, **t["sellingPoint"]}, "brandLines": {"lines": spec.brand_lines, **t["brandLines"]}, "endCard": { "brand": spec.end_card.brand, "location": spec.end_card.location, "disclosure": spec.end_card.disclosure, **t["endCard"], }, } return props, job def render(spec: VideoSpec, base_video: Path) -> Path: OUT_DIR.mkdir(parents=True, exist_ok=True) props, _ = build_props(spec, base_video) props_file = OUT_DIR / f"props_{uuid.uuid4().hex[:8]}.json" props_file.write_text(json.dumps(props, ensure_ascii=False), encoding="utf-8") out_file = OUT_DIR / f"final_{uuid.uuid4().hex[:8]}.mp4" subprocess.run( ["npx", "remotion", "render", "MumumShort", str(out_file), f"--props={props_file}"], cwd=str(REMOTION), check=True, ) return out_file