o2o-ado2-short-form/server/app/pipeline/remotion_render.py

108 lines
4.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 import config as cfg
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 = cfg.REMOTION_FPS
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:
"""4-beat 레이아웃을 실제 프레임 수에 비례 스케일.
240프레임(8초 @30fps)을 기준으로 설계됐으나, 영상이 그보다 짧거나 길어도
각 구간 비율이 유지되도록 sc()로 선형 변환한다.
고정값 clamp 방식은 짧은 영상에서 brandLines 창이 너무 좁아져
interpolate 범위가 뒤집히는 크래시를 유발했음.
"""
REF = 240 # 기준 총 프레임 (8s @30fps)
def sc(f: int) -> int:
return max(0, min(round(f * total / REF), total))
return {
"hook": {"fromFrame": 0, "toFrame": sc(78)},
"sellingPoint": {"fromFrame": sc(80), "toFrame": sc(138)},
"brandLines": {"fromFrame": sc(138), "toFrame": sc(196)},
"endCard": {"fromFrame": max(0, total - sc(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) -> tuple[Path, list[Path]]:
"""렌더 후 호출자가 지울 임시 파일 목록도 함께 반환한다.
반환값: (최종 mp4, [삭제 대상 임시 파일])
호출자는 최종 mp4를 outputs/ 로 복사한 뒤 반환된 목록을 삭제해야 한다.
"""
OUT_DIR.mkdir(parents=True, exist_ok=True)
props, pub_job_name = 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", cfg.REMOTION_COMPOSITION, str(out_file),
f"--props={props_file}"],
cwd=str(REMOTION), check=True,
)
# 임시 파일: remotion/public/ 의 베이스 복사본 + props JSON (out_file 은 별도 반환)
temps = [PUBLIC / pub_job_name, props_file]
return out_file, temps