diff --git a/tests/test_pipeline_integration.py b/tests/test_pipeline_integration.py index d6df6b8..206ca38 100644 --- a/tests/test_pipeline_integration.py +++ b/tests/test_pipeline_integration.py @@ -1,25 +1,34 @@ -import shutil -import subprocess from pathlib import Path import pytest -from tools.pipeline.run import process_clip +from tools.pipeline.run import process_clip, resolve_ffmpeg -_HAS_FF = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None _FOREST = Path("simulator/sample_media/forest/base.mp4") -@pytest.mark.skipif(not _HAS_FF, reason="ffmpeg/ffprobe not installed") +def _ffmpeg_available() -> bool: + try: + resolve_ffmpeg() + return True + except Exception: + return False + + +_HAS_FF = _ffmpeg_available() + + +@pytest.mark.skipif(not _HAS_FF, + reason="neither system ffmpeg nor imageio-ffmpeg available") @pytest.mark.skipif(not _FOREST.exists(), reason="forest POC base absent (run simulator/setup_sample_media.py)") def test_process_clip_emits_proxy_and_master(tmp_path): out = process_clip(_FOREST, tmp_path, overlap=0.5, start=0.0, duration=4.0) assert out["proxy"].exists() and out["master"].exists() - # Proxy is exactly 1920x1080. - probe = subprocess.run( - ["ffprobe", "-v", "quiet", "-select_streams", "v:0", - "-show_entries", "stream=width,height", "-of", "csv=p=0", str(out["proxy"])], - capture_output=True, text=True, check=True, - ).stdout.strip() - assert probe == "1920,1080" + # Proxy is exactly 1920×1080 — verified with cv2, no ffprobe dependency. + import cv2 + cap = cv2.VideoCapture(str(out["proxy"])) + w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + cap.release() + assert (w, h) == (1920, 1080) diff --git a/tools/pipeline/ffmpeg_ops.py b/tools/pipeline/ffmpeg_ops.py index a8a2655..6a7f232 100644 --- a/tools/pipeline/ffmpeg_ops.py +++ b/tools/pipeline/ffmpeg_ops.py @@ -31,17 +31,20 @@ def frame_args(src, dst, *, start: float, duration: float, def crossfade_loop_args(src, dst, *, duration: float, overlap: float, - ff: str = "ffmpeg") -> list[str]: + fps: int = 30, ff: str = "ffmpeg") -> list[str]: """Stage 3 — make `src` loop seamlessly by crossfading its tail over its head. Output length = duration − overlap. Requires overlap < duration/2 so a non-empty - middle remains.""" + middle remains. + + fps is applied after each trim so xfade receives a constant-frame-rate stream + (xfade requires CFR; a raw trim output reports rate 1/0 which xfade rejects).""" if overlap <= 0 or overlap >= duration / 2: raise ValueError(f"overlap {overlap} must be in (0, duration/2={duration/2})") d, o = duration, overlap fc = ( - f"[0:v]trim=0:{o},setpts=PTS-STARTPTS[head];" - f"[0:v]trim={o}:{d - o},setpts=PTS-STARTPTS[mid];" - f"[0:v]trim={d - o}:{d},setpts=PTS-STARTPTS[tail];" + f"[0:v]trim=0:{o},setpts=PTS-STARTPTS,fps={fps}[head];" + f"[0:v]trim={o}:{d - o},setpts=PTS-STARTPTS,fps={fps}[mid];" + f"[0:v]trim={d - o}:{d},setpts=PTS-STARTPTS,fps={fps}[tail];" f"[tail][head]xfade=transition=fade:duration={o}:offset=0[xf];" f"[xf][mid]concat=n=2:v=1,format=yuv420p[out]" ) diff --git a/tools/pipeline/run.py b/tools/pipeline/run.py index 56c7346..ec22c41 100644 --- a/tools/pipeline/run.py +++ b/tools/pipeline/run.py @@ -4,6 +4,7 @@ this glue is covered by the opt-in integration test.""" from __future__ import annotations +import shutil import subprocess from pathlib import Path @@ -12,6 +13,15 @@ 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, *, ff: str = "ffprobe") -> float: out = subprocess.run( [ff, "-v", "quiet", "-show_entries", "format=duration", @@ -27,9 +37,10 @@ def _run(args: list[str]) -> None: def process_clip(src, out_dir, *, start: float = 0.0, duration: float | None = None, overlap: float = 1.0, master_w: int = 3840, master_h: int = 2160, - ff: str = "ffmpeg") -> dict: + 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).""" + ff = ff or resolve_ffmpeg() src = Path(src) out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True)