91 lines
3.1 KiB
Python
91 lines
3.1 KiB
Python
"""③ 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 <out> --props=<file>`. 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
|