From 7217daeb44244ede1cfb38caeda2014432075796 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Wed, 24 Jun 2026 07:54:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(pipeline):=20crossfade=20seamless-loop=20a?= =?UTF-8?q?rg=20builder=20(content=20pipeline=20=C2=A73.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_pipeline_ffmpeg_ops.py | 20 +++++++++++++++++++- tools/pipeline/ffmpeg_ops.py | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/test_pipeline_ffmpeg_ops.py b/tests/test_pipeline_ffmpeg_ops.py index 91d0158..63d8336 100644 --- a/tests/test_pipeline_ffmpeg_ops.py +++ b/tests/test_pipeline_ffmpeg_ops.py @@ -1,4 +1,4 @@ -from tools.pipeline.ffmpeg_ops import frame_args +from tools.pipeline.ffmpeg_ops import frame_args, crossfade_loop_args def test_frame_args_trims_scales_pads_and_strips_audio(): @@ -15,3 +15,21 @@ def test_frame_args_trims_scales_pads_and_strips_audio(): assert "pad=1920:1080" in vf assert "format=yuv420p" in vf assert args[-1] == "out.mp4" + + +def test_crossfade_loop_builds_head_mid_tail_xfade_concat(): + args = crossfade_loop_args("in.mp4", "loop.mp4", duration=8.0, overlap=1.0) + fc = args[args.index("-filter_complex") + 1] + assert "trim=0:1.0" in fc # head = first O secs + assert "trim=1.0:7.0" in fc # mid = [O, D-O] + assert "trim=7.0:8.0" in fc # tail = last O secs + assert "xfade=transition=fade:duration=1.0:offset=0" in fc + assert "concat=n=2:v=1" in fc + assert "-an" in args + assert args[-1] == "loop.mp4" + + +def test_crossfade_loop_rejects_overlap_too_large(): + import pytest + with pytest.raises(ValueError): + crossfade_loop_args("in.mp4", "loop.mp4", duration=4.0, overlap=2.0) diff --git a/tools/pipeline/ffmpeg_ops.py b/tools/pipeline/ffmpeg_ops.py index 5ee73aa..f1476d7 100644 --- a/tools/pipeline/ffmpeg_ops.py +++ b/tools/pipeline/ffmpeg_ops.py @@ -28,3 +28,26 @@ def frame_args(src, dst, *, start: float, duration: float, "-c:v", "libx264", "-preset", "medium", "-crf", "16", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(dst), ] + + +def crossfade_loop_args(src, dst, *, duration: float, overlap: float, + 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.""" + 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"[tail][head]xfade=transition=fade:duration={o}:offset=0[xf];" + f"[xf][mid]concat=n=2:v=1,format=yuv420p[out]" + ) + return [ + ff, "-y", "-i", str(src), "-filter_complex", fc, + "-map", "[out]", "-an", + "-c:v", "libx264", "-preset", "medium", "-crf", "16", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(dst), + ]