Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,28 @@ pip install -e .
python -m jelly_coder --help

# Reduce a folder, mirror outputs under ./output/<dir>, 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/<input-folder>`.
- `--output PATH`: Custom output directory root when not overwriting.
- `--log-level LEVEL`: `info` (default), `debug`, `warning`, etc.

### Output Behavior
Expand Down
18 changes: 18 additions & 0 deletions jelly_coder/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import argparse
import logging
import re
from pathlib import Path
from typing import Optional

Expand All @@ -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:
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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)
Expand Down
17 changes: 16 additions & 1 deletion jelly_coder/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -1070,6 +1082,7 @@ def process_videos(
output_extension: str,
max_workers: int,
quality: str,
aspect: Optional[str] = None,
) -> None:
tasks = {}
encode = partial(
Expand All @@ -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:
Expand Down Expand Up @@ -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,
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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 = {}

Expand All @@ -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()


Expand All @@ -130,6 +136,7 @@ def test_main_interactive(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> No
codec="auto",
quality=None,
backend="auto",
aspect=None,
)
captured = {}

Expand Down Expand Up @@ -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 = {}

Expand All @@ -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 = {}

Expand All @@ -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"])
1 change: 1 addition & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions tests/test_core_branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading