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,