From 02c762fd45ae684b0ea17a4c799a611f9679a0bd Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Wed, 24 Jun 2026 08:09:01 -0700 Subject: [PATCH] fix(pipeline): probe duration via cv2 (no ffprobe dep); wire MASTER_MAX_W; fps-sync note Co-Authored-By: Claude Sonnet 4.6 --- tests/test_pipeline_integration.py | 10 +++++++++- tools/pipeline/run.py | 31 ++++++++++++++++++------------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/tests/test_pipeline_integration.py b/tests/test_pipeline_integration.py index 206ca38..6cd2b35 100644 --- a/tests/test_pipeline_integration.py +++ b/tests/test_pipeline_integration.py @@ -2,7 +2,7 @@ from pathlib import Path import pytest -from tools.pipeline.run import process_clip, resolve_ffmpeg +from tools.pipeline.run import probe_duration, process_clip, resolve_ffmpeg _FOREST = Path("simulator/sample_media/forest/base.mp4") @@ -18,6 +18,14 @@ def _ffmpeg_available() -> bool: _HAS_FF = _ffmpeg_available() +@pytest.mark.skipif(not _FOREST.exists(), + reason="forest POC base absent (run simulator/setup_sample_media.py)") +def test_probe_duration_reads_forest_base(): + if not _FOREST.exists(): + pytest.skip("forest POC base absent") + assert probe_duration(_FOREST) > 0 + + @pytest.mark.skipif(not _HAS_FF, reason="neither system ffmpeg nor imageio-ffmpeg available") @pytest.mark.skipif(not _FOREST.exists(), diff --git a/tools/pipeline/run.py b/tools/pipeline/run.py index ec22c41..d9d4464 100644 --- a/tools/pipeline/run.py +++ b/tools/pipeline/run.py @@ -1,6 +1,6 @@ -"""Thin runner: ffprobe the source, 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.""" +"""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 @@ -22,13 +22,17 @@ def resolve_ffmpeg() -> str: return imageio_ffmpeg.get_ffmpeg_exe() -def probe_duration(src, *, ff: str = "ffprobe") -> float: - out = subprocess.run( - [ff, "-v", "quiet", "-show_entries", "format=duration", - "-of", "csv=p=0", str(src)], - capture_output=True, text=True, check=True, - ).stdout.strip() - return float(out) +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: @@ -36,10 +40,13 @@ 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, + 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).""" + `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)