From 787f3002c5153dc23ed64389987c100febcd4e36 Mon Sep 17 00:00:00 2001 From: 0x78f1935 Date: Sun, 10 May 2026 22:09:21 +0200 Subject: [PATCH] ADD: Aspect ratio / FIX: Documentation --- README.md | 19 +++++--- jelly_coder/cli.py | 18 ++++++++ jelly_coder/core.py | 17 ++++++- pyproject.toml | 2 +- tests/test_cli.py | 19 ++++++++ tests/test_core.py | 1 + tests/test_core_branches.py | 90 +++++++++++++++++++++++++++++++++++++ 7 files changed, 157 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 836bb02..6f67637 100644 --- a/README.md +++ b/README.md @@ -37,23 +37,28 @@ pip install -e . python -m jelly_coder --help # Reduce a folder, mirror outputs under ./output/, auto-select backend -python -m jelly_coder --input D:\media --quality 720p +python -m jelly_coder D:\media --quality 720p # Force Intel QSV, enable overwrite, run at debug verbosity -python -m jelly_coder --input D:\media --encoder-backend qsv --overwrite --log-level debug +python -m jelly_coder D:\media --backend qsv --overwrite --log-level debug + +# Change the display aspect ratio to 16:9 +python -m jelly_coder D:\media --aspect 16:9 # Legacy wrapper remains available -python encode_videos.py --input D:\media +python encode_videos.py D:\media ``` ### Key Flags -- `--input PATH`: Directory scanned recursively for supported video extensions. -- `--encoder-backend BACKEND`: `auto` (default), `nvenc`, `x264`, `qsv`, or `amf`. -- `--preferred-codec CODEC`: Hint `h264` or `hevc`; respected when the backend supports it. +- `PATH` (positional): Directory scanned recursively for supported video extensions. +- `--backend BACKEND`: `auto` (default), `nvenc`, `x264`, `qsv`, or `amf`. +- `--codec CODEC`: Hint `h264` or `hevc`; respected when the backend supports it. - `--quality PRESET`: Downscale preset (`auto`, `1080p`, `720p`, `480p`, `360p`). -- `--workers N`: Concurrent encodes (default 1; hardware encoders generally behave best at 1). +- `--aspect W:H`: Set the output display aspect ratio (e.g. `16:9`, `4:3`). +- `--max-workers N`: Concurrent encodes (default 1; hardware encoders generally behave best at 1). - `--overwrite`: Replace sources in place. When omitted, outputs land in `./output/`. +- `--output PATH`: Custom output directory root when not overwriting. - `--log-level LEVEL`: `info` (default), `debug`, `warning`, etc. ### Output Behavior diff --git a/jelly_coder/cli.py b/jelly_coder/cli.py index 6955cc9..16495ef 100644 --- a/jelly_coder/cli.py +++ b/jelly_coder/cli.py @@ -2,6 +2,7 @@ import argparse import logging +import re from pathlib import Path from typing import Optional @@ -14,6 +15,16 @@ DEFAULT_LOG_LEVEL = logging.INFO LOG_FORMAT = "%(asctime)s [%(levelname)s] %(message)s" +_ASPECT_PATTERN = re.compile(r"^\d+:\d+$") + + +def _validate_aspect(value: str) -> str: + if not _ASPECT_PATTERN.match(value): + raise argparse.ArgumentTypeError( + f"Invalid aspect ratio '{value}'. Expected W:H format (e.g. 16:9, 4:3)." + ) + return value + def _prompt_for_directory() -> Path: while True: @@ -107,6 +118,12 @@ def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: choices=QUALITY_PRESETS, help="Target video quality preset (default: auto).", ) + parser.add_argument( + "--aspect", + default=None, + type=_validate_aspect, + help="Output display aspect ratio in W:H format (e.g. 16:9, 4:3).", + ) return parser.parse_args(argv) @@ -165,6 +182,7 @@ def main(argv: Optional[list[str]] = None) -> None: preferred_codec=None if args.codec == "auto" else args.codec, quality=quality, encoder_backend=backend, + aspect=args.aspect, ) reduce_videos(config) diff --git a/jelly_coder/core.py b/jelly_coder/core.py index 22576a7..7da2505 100644 --- a/jelly_coder/core.py +++ b/jelly_coder/core.py @@ -180,6 +180,7 @@ class ReducerConfig: preferred_codec: Optional[str] = None quality: str = "auto" encoder_backend: str = "auto" + aspect: Optional[str] = None def ensure_ffmpeg_available() -> None: @@ -665,6 +666,7 @@ def encode_video( encoder: str, output_extension: str, quality: str, + aspect: Optional[str] = None, *, _allow_encoder_fallback: bool = True, _media_info_override: Optional[MediaInfo] = None, @@ -812,6 +814,10 @@ def encode_video( log_message += f" Downmixing from {audio_channels} channels to stereo." logging.info(log_message) + aspect_filter: Optional[str] = None + if aspect: + aspect_filter = f"setdar={aspect.replace(':', '/')}" + normalized_quality = quality.lower() scale_filter: Optional[str] = None if normalized_quality != "auto": @@ -921,8 +927,13 @@ def build_ffmpeg_cmd(use_hw_decode: bool, force_hw_format: bool) -> List[str]: if NVENC_TEMPORAL_AQ: cmd.extend(["-temporal_aq", NVENC_TEMPORAL_AQ]) + vf_parts: List[str] = [] if scale_filter: - cmd.extend(["-vf", scale_filter]) + vf_parts.append(scale_filter) + if aspect_filter: + vf_parts.append(aspect_filter) + if vf_parts: + cmd.extend(["-vf", ",".join(vf_parts)]) cmd.extend([ "-pix_fmt", @@ -1021,6 +1032,7 @@ def build_ffmpeg_cmd(use_hw_decode: bool, force_hw_format: bool) -> List[str]: encoder=fallback_encoder, output_extension=output_extension, quality=quality, + aspect=aspect, _allow_encoder_fallback=False, _media_info_override=media_info, ) @@ -1070,6 +1082,7 @@ def process_videos( output_extension: str, max_workers: int, quality: str, + aspect: Optional[str] = None, ) -> None: tasks = {} encode = partial( @@ -1078,6 +1091,7 @@ def process_videos( encoder=encoder, output_extension=output_extension, quality=quality, + aspect=aspect, ) with ThreadPoolExecutor(max_workers=max_workers) as pool: @@ -1139,4 +1153,5 @@ def reduce_videos(config: ReducerConfig) -> None: output_extension=selection.output_extension, max_workers=config.max_workers, quality=quality_choice, + aspect=config.aspect, ) diff --git a/pyproject.toml b/pyproject.toml index 3f55b14..3c53b35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jellycoder" -version = "1.1.0" +version = "1.2.0" description = "Video reduction tool" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_cli.py b/tests/test_cli.py index bc71f29..f036df2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,6 +63,7 @@ def test_parse_args_defaults() -> None: assert args.input is None assert args.max_workers == 1 assert args.codec == "auto" + assert args.aspect is None def test_parse_args_full(tmp_path: Path) -> None: @@ -81,6 +82,8 @@ def test_parse_args_full(tmp_path: Path) -> None: "720p", "--backend", "qsv", + "--aspect", + "16:9", ]) assert args.input == str(tmp_path) assert args.overwrite is True @@ -90,6 +93,7 @@ def test_parse_args_full(tmp_path: Path) -> None: assert args.codec == "h264" assert args.quality == "720p" assert args.backend == "qsv" + assert args.aspect == "16:9" def test_main_with_args(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -102,6 +106,7 @@ def test_main_with_args(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None codec="h264", quality="1080p", backend="nvenc", + aspect="16:9", ) captured = {} @@ -117,6 +122,7 @@ def test_main_with_args(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None assert cfg.preferred_codec == "h264" assert cfg.quality == "1080p" assert cfg.encoder_backend == "nvenc" + assert cfg.aspect == "16:9" assert (tmp_path / "jelly_coder.log").exists() @@ -130,6 +136,7 @@ def test_main_interactive(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> No codec="auto", quality=None, backend="auto", + aspect=None, ) captured = {} @@ -162,6 +169,7 @@ def test_main_writes_log_to_output_directory(monkeypatch: pytest.MonkeyPatch, tm codec="auto", quality="auto", backend="auto", + aspect=None, ) captured = {} @@ -187,6 +195,7 @@ def test_main_writes_log_to_default_output(monkeypatch: pytest.MonkeyPatch, tmp_ codec="auto", quality=None, backend="auto", + aspect=None, ) captured = {} @@ -209,3 +218,13 @@ def test_package_main_entry(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(cli, "main", lambda argv=None: called.update({"ran": True})) runpy.run_module("jelly_coder.__main__", run_name="__main__") assert called == {"ran": True} + + +def test_parse_args_aspect_valid() -> None: + args = cli.parse_args(["somedir", "--aspect", "16:9"]) + assert args.aspect == "16:9" + + +def test_parse_args_aspect_invalid() -> None: + with pytest.raises(SystemExit): + cli.parse_args(["somedir", "--aspect", "widescreen"]) diff --git a/tests/test_core.py b/tests/test_core.py index 0a43656..082711a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -747,6 +747,7 @@ def fake_encode(*, src: Path, dst: Path, **_: object) -> None: output_extension=".mp4", max_workers=2, quality="auto", + aspect=None, ) assert len(calls) == 2 assert any("Failed" in message for message in caplog.messages) diff --git a/tests/test_core_branches.py b/tests/test_core_branches.py index a429a7a..b5c59cc 100644 --- a/tests/test_core_branches.py +++ b/tests/test_core_branches.py @@ -433,6 +433,96 @@ def fake_run(cmd: list[str], *_: object) -> None: assert any("Adjusting target bitrate" in msg for msg in caplog.messages) +def test_encode_video_aspect_only( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + src = tmp_path / "aspect.mkv" + src.write_bytes(b"x" * 500) + dst = tmp_path / "aspect.mp4" + info = core.MediaInfo( + frames=50, + duration=3.0, + bitrate_kbps=1800.0, + width=1920, + height=1080, + audio_codec="aac", + audio_bitrate_kbps=96.0, + audio_channels=2, + subtitle_codecs=[], + video_codecs=["h264"], + attached_pic_codecs=[], + data_stream_codecs=[], + ) + monkeypatch.setattr(core, "probe_media_info", lambda path: info) + + commands: list[list[str]] = [] + + def fake_run(cmd: list[str], *_: object) -> None: + commands.append(cmd) + Path(cmd[-1]).write_bytes(b"y" * 200) + + monkeypatch.setattr(core, "run_ffmpeg_with_progress", fake_run) + + core.encode_video( + src=src, + dst=dst, + overwrite=False, + encoder="h264_nvenc", + output_extension=".mp4", + quality="auto", + aspect="16:9", + ) + encoded = commands[0] + vf_index = encoded.index("-vf") + assert encoded[vf_index + 1] == "setdar=16/9" + + +def test_encode_video_aspect_with_scale( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + src = tmp_path / "both.mkv" + src.write_bytes(b"x" * 500) + dst = tmp_path / "both.mp4" + info = core.MediaInfo( + frames=50, + duration=3.0, + bitrate_kbps=1800.0, + width=1920, + height=1080, + audio_codec="aac", + audio_bitrate_kbps=96.0, + audio_channels=2, + subtitle_codecs=[], + video_codecs=["h264"], + attached_pic_codecs=[], + data_stream_codecs=[], + ) + monkeypatch.setattr(core, "probe_media_info", lambda path: info) + + commands: list[list[str]] = [] + + def fake_run(cmd: list[str], *_: object) -> None: + commands.append(cmd) + Path(cmd[-1]).write_bytes(b"y" * 200) + + monkeypatch.setattr(core, "run_ffmpeg_with_progress", fake_run) + + core.encode_video( + src=src, + dst=dst, + overwrite=False, + encoder="h264_nvenc", + output_extension=".mp4", + quality="720p", + aspect="4:3", + ) + encoded = commands[0] + vf_index = encoded.index("-vf") + assert encoded[vf_index + 1] == "scale=-2:720,setdar=4/3" + + def test_encode_video_skips_when_destination_exists( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,