diff --git a/tests/test_pipeline_ffmpeg_ops.py b/tests/test_pipeline_ffmpeg_ops.py new file mode 100644 index 0000000..91d0158 --- /dev/null +++ b/tests/test_pipeline_ffmpeg_ops.py @@ -0,0 +1,17 @@ +from tools.pipeline.ffmpeg_ops import frame_args + + +def test_frame_args_trims_scales_pads_and_strips_audio(): + args = frame_args( + "src.mp4", "out.mp4", + start=2.0, duration=8.0, width=1920, height=1080, ff="ffmpeg", + ) + assert args[0] == "ffmpeg" + assert "-ss" in args and args[args.index("-ss") + 1] == "2.0" + assert "-t" in args and args[args.index("-t") + 1] == "8.0" + assert "-an" in args # audio stripped (bases are video-only) + vf = args[args.index("-vf") + 1] + assert "scale=1920:1080:force_original_aspect_ratio=decrease" in vf + assert "pad=1920:1080" in vf + assert "format=yuv420p" in vf + assert args[-1] == "out.mp4" diff --git a/tools/pipeline/__init__.py b/tools/pipeline/__init__.py new file mode 100644 index 0000000..7f38557 --- /dev/null +++ b/tools/pipeline/__init__.py @@ -0,0 +1,5 @@ +"""Content pipeline: source -> frame -> loop -> transcode -> manifest. + +Pure ffmpeg-argument builders (ffmpeg_ops), a thin runner (run), provenance +records, and manifest assembly. See docs/superpowers/specs/2026-06-24-content-pipeline-design.md. +""" diff --git a/tools/pipeline/ffmpeg_ops.py b/tools/pipeline/ffmpeg_ops.py new file mode 100644 index 0000000..5ee73aa --- /dev/null +++ b/tools/pipeline/ffmpeg_ops.py @@ -0,0 +1,30 @@ +"""Pure ffmpeg argument builders (no I/O) for the content pipeline. + +Each function returns a list[str] ready for ffmpeg, so command construction is +unit-testable without running ffmpeg. tools/pipeline/run.py executes them. The +ffmpeg binary name is injected (default "ffmpeg"). +""" + +from __future__ import annotations + + +def _fit(width: int, height: int) -> str: + """A scale+pad filter chain fitting any input into width×height (letterbox), + fixing SAR and pixel format so downstream concat/xfade get identical geometry.""" + return ( + f"scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2," + "setsar=1,format=yuv420p" + ) + + +def frame_args(src, dst, *, start: float, duration: float, + width: int, height: int, ff: str = "ffmpeg") -> list[str]: + """Stage 2 — trim [start, start+duration], fit to width×height, strip audio. + Produces a high-quality mezzanine for the loop stage.""" + return [ + ff, "-y", "-ss", str(start), "-t", str(duration), + "-i", str(src), "-vf", _fit(width, height), "-an", + "-c:v", "libx264", "-preset", "medium", "-crf", "16", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(dst), + ]