"""Thin runner: probe the source duration, then execute the frame -> loop -> transcode stages with tools.pipeline.ffmpeg_ops. The pure arg builders are unit-tested; this glue is covered by the opt-in integration test.""" from __future__ import annotations import shutil import subprocess from pathlib import Path from .ffmpeg_ops import crossfade_loop_args, frame_args, transcode_args MASTER_MAX_W = 3840 def resolve_ffmpeg() -> str: """ffmpeg from PATH, else the bundled imageio-ffmpeg binary (matches simulator/setup_scales_media.py). The Pi/dev box may lack a system ffmpeg.""" if shutil.which("ffmpeg"): return "ffmpeg" import imageio_ffmpeg return imageio_ffmpeg.get_ffmpeg_exe() def probe_duration(src) -> float: """Clip duration in seconds via cv2 (frame_count / fps) — avoids a system ffprobe dependency (imageio-ffmpeg ships ffmpeg only; the Pi has no ffprobe).""" import cv2 cap = cv2.VideoCapture(str(src)) fps = cap.get(cv2.CAP_PROP_FPS) or 0.0 frames = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0 cap.release() if fps <= 0: raise ValueError(f"could not read fps from {src}") return frames / fps def _run(args: list[str]) -> None: subprocess.run(args, check=True, capture_output=True) def process_clip(src, out_dir, *, start: float = 0.0, duration: float | None = None, overlap: float = 1.0, master_w: int = MASTER_MAX_W, master_h: int = 2160, ff: str | None = None) -> dict: """Run stages 2–4 for one clip; return {'master','proxy','loop','mezzanine'} paths. `duration` defaults to the full source length (minus the trim start). Note: `crossfade_loop_args` uses a fixed fps only to satisfy xfade's constant-frame-rate requirement; the final output fps is set by `transcode_args` (both default 30 — keep in sync if overridden).""" ff = ff or resolve_ffmpeg() src = Path(src) out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) if duration is None: duration = probe_duration(src) - start mezz = out_dir / "mezzanine.mp4" loop = out_dir / "loop.mp4" master = out_dir / "master.mp4" proxy = out_dir / "base.mp4" # the proxy is what the manifest's base_file points at _run(frame_args(src, mezz, start=start, duration=duration, width=master_w, height=master_h, ff=ff)) _run(crossfade_loop_args(mezz, loop, duration=duration, overlap=overlap, ff=ff)) _run(transcode_args(loop, master, width=master_w, height=master_h, crf=18, ff=ff)) _run(transcode_args(loop, proxy, width=1920, height=1080, crf=20, ff=ff)) return {"mezzanine": mezz, "loop": loop, "master": master, "proxy": proxy}