From 1a43c4725d43c7532f3899caf778fc5e1cf41796 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 6 Apr 2026 13:51:02 +0200 Subject: [PATCH] feat: portrait crop filter in build_ffmpeg_command Co-Authored-By: Claude Sonnet 4.6 --- main.py | 36 +++++++++++++++++++++++++++++++++--- tests/test_utils.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 8cf572c..edbfc1b 100644 --- a/main.py +++ b/main.py @@ -33,6 +33,8 @@ def format_time(seconds: float) -> str: def build_ffmpeg_command( input_path: str, start: float, output_path: str, short_side: int | None = None, + portrait_ratio: str | None = None, + crop_center: float = 0.5, ) -> list[str]: # -ss before -i: fast input-seeking. Safe here because we always re-encode # (libx264/aac), so there is no keyframe-alignment issue from pre-input seek. @@ -42,16 +44,44 @@ def build_ffmpeg_command( "-i", input_path, "-t", "8", ] + + filters: list[str] = [] + if portrait_ratio is not None: + filters.append(_portrait_crop_filter(portrait_ratio, crop_center)) if short_side is not None: # Scale so the shorter dimension equals short_side. - # if(lt(iw,ih),...) → portrait: fix width; landscape: fix height. + # if(lt(iw,ih),...) → portrait output: fix width; landscape: fix height. # -2 keeps aspect ratio with even-pixel rounding (libx264 requirement). - scale = f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})'" - cmd += ["-vf", scale] + filters.append( + f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})'" + ) + if filters: + cmd += ["-vf", ",".join(filters)] + cmd += ["-c:v", "libx264", "-c:a", "aac", output_path] return cmd +_RATIOS: dict[str, tuple[int, int]] = { + "9:16": (9, 16), + "4:5": (4, 5), + "1:1": (1, 1), +} + + +def _portrait_crop_filter(ratio: str, crop_center: float) -> str: + """Return an ffmpeg crop= filter expression for the given portrait ratio. + + Uses ffmpeg expression syntax so source dimensions are resolved at runtime. + Commas inside min()/max() are escaped with \\, to prevent ffmpeg's + filtergraph parser from treating them as filter-chain separators. + """ + num, den = _RATIOS[ratio] + cw = f"ih*{num}/{den}" + x = f"max(0\\,min((iw-{cw})*{crop_center}\\,iw-{cw}))" + return f"crop={cw}:ih:{x}:0" + + def _normalize_filename(filename: str) -> str: """Strip extension and common resolution/quality tags for fuzzy comparison.""" name = os.path.splitext(filename)[0].lower() diff --git a/tests/test_utils.py b/tests/test_utils.py index 3b283b8..c567798 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -145,3 +145,31 @@ def test_db_get_markers_no_match(): def test_db_get_markers_disabled(): db = ProcessedDB("/no/such/directory/8cut.db") assert db.get_markers("x.mp4") == [] + +def test_ffmpeg_command_portrait_only(): + cmd = build_ffmpeg_command( + "/in/video.mp4", 0.0, "/out/clip.mp4", + portrait_ratio="9:16", crop_center=0.5, + ) + assert "-vf" in cmd + vf = cmd[cmd.index("-vf") + 1] + assert "crop" in vf + assert "9" in vf + assert "scale" not in vf + assert cmd[-1] == "/out/clip.mp4" + +def test_ffmpeg_command_portrait_and_resize(): + cmd = build_ffmpeg_command( + "/in/video.mp4", 0.0, "/out/clip.mp4", + short_side=256, portrait_ratio="9:16", crop_center=0.5, + ) + assert "-vf" in cmd + vf = cmd[cmd.index("-vf") + 1] + assert "crop" in vf + assert "scale" in vf + assert vf.index("crop") < vf.index("scale") + assert cmd[-1] == "/out/clip.mp4" + +def test_ffmpeg_command_portrait_off(): + cmd = build_ffmpeg_command("/in/video.mp4", 0.0, "/out/clip.mp4") + assert "-vf" not in cmd