From b52a1673ba523d134d69b4dd098260ca0852e52d Mon Sep 17 00:00:00 2001 From: Zach Fleeman Date: Fri, 6 Nov 2026 09:45:24 -0700 Subject: [PATCH 1/2] Let --resolution take a height or a percentage --- README.md | 4 ++-- ffmpeg4discord/__main__.py | 6 ++--- ffmpeg4discord/templates/web.html | 13 ++++++---- ffmpeg4discord/twopass.py | 40 ++++++++++++++++++++++++------- tests/test_main.py | 10 +++++++- tests/test_twopass.py | 28 +++++++++++++++++++++- 6 files changed, 81 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 66f298c..6be346d 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ I've had a good time using this command with a Batch file on Windows. Refer to t | `-s`
`--target-filesize` | 10 | `-s 50` | Target output file size in MiB. The encoder will iterate until it gets under this size (unless `--approx` is used). | | `-a`
`--audio-br` | 96 | `-a 128` | Audio bitrate in kbps. Lowering this allows a slightly higher video bitrate for the same target file size. | | `-c`
`--codec` | x264 | `-c hevc_nvenc` | Video "codec profile". These aren't 1:1 titles with the FFmpeg codec choices, because modern codecs require more tweaking for encoding performance, so I've setup a profile for `vp9` and `av1` speed. Options are ordered from "most compatible" to "least compatible": `x264`, `h264_nvenc`, `h264_videotoolbox`, `x265`, `hevc_nvenc`, `hevc_videotoolbox`, `vp9`, `av1`. The VideoToolbox codecs are only offered on macOS. See [Notes on Codec Selection](#notes-on-codec-selection) for more information. | -| `-r`
`--resolution` | off | `-r 1280x720` | Scale the output video to a specific resolution (format: `WIDTHxHEIGHT`). | +| `-r`
`--resolution` | off | `-r 1280x720`
`-r 720`
`-r 50%` | Scale the output video. Give `WIDTHxHEIGHT`, just a height (`720`), or a percentage of the height (`50%`). A height or percentage keeps the aspect ratio and picks the width for you. With `--crop`, both apply to the cropped area. | | `-x`
`--crop` | No default | `-x 440x0x2560x1440` | Crop the input before encoding. The format is four numbers joined by `x`: the left offset, top offset, width, and height of the area to keep, in pixels. The crop runs before `--resolution`. See the [Detailed Example](#detailed-example). | | `-f`
`--framerate` | off | `-f 30` | Output frame rate (FPS). If you specify a value higher than the input video’s FPS, the original FPS will be kept. | | `--from` | No default | `--from 00:01:00`
`--from 75%` | Start time for trimming the input, as a timestamp (`HH:MM:SS` or `MM:SS`), seconds (`90`), or a percentage of the video's length. Timestamps and seconds can include milliseconds, e.g. `00:01:30.250` or `90.25`. Percentages work well in a config file because they adjust to each clip. | @@ -146,7 +146,7 @@ This example takes a 3440x1440 ultrawide recording and turns it into a normal 16 - **Trim:** It keeps 00:00:50 to 00:01:45, read from the [file name](#file-name-formatting) because of `--filename-times`. - **Crop (`-x`):** It keeps a 2560x1440 area from the middle of the frame. 2560x1440 is the widest 16:9 area that fits in 1440 pixels of height. To center it, the left offset is the leftover width split in half: (3440 − 2560) / 2 = 440. The top offset is 0 because the crop uses the full height. -- **Scale (`-r`):** It shrinks the cropped 2560x1440 area to 1920x1080. Both are 16:9, so nothing gets stretched. +- **Scale (`-r`):** It shrinks the cropped 2560x1440 area to 1920x1080. Both are 16:9, so nothing gets stretched. `-r 1080` or `-r 75%` would do the same job, since both keep the crop's aspect ratio. - **Size and audio:** The output will be 50MB (`-s`) with 48k audio (`-a`), which will probably sound terrible. The file is saved in `D:/shadowplay/` (`-o`). ![A 3440x1440 frame with a centered 2560x1440 crop and 440 pixels cut off each side, scaled down to 1920x1080](https://raw.githubusercontent.com/zfleeman/ffmpeg4discord/main/docs/crop-example.svg) diff --git a/ffmpeg4discord/__main__.py b/ffmpeg4discord/__main__.py index f487905..c7fd5b9 100644 --- a/ffmpeg4discord/__main__.py +++ b/ffmpeg4discord/__main__.py @@ -24,8 +24,8 @@ # smallest fraction the target size is cut by on each retry MIN_RETRY_STEP = 0.02 -# the formats the web UI accepts, e.g. 1280x720 and 255x0x1410x1080 -RESOLUTION_PATTERN = re.compile(r"\d+x\d+") +# the formats the web UI accepts, e.g. 1280x720, 720, or 50% for a resolution, and 255x0x1410x1080 for a crop +RESOLUTION_PATTERN = re.compile(r"\d+x\d+|\d+|\d+(\.\d+)?%") CROP_PATTERN = re.compile(r"\d+x\d+x\d+x\d+") @@ -341,7 +341,7 @@ def parse_encode_form(form, twopass: TwoPass, codecs: list[str]) -> tuple[Encode resolution = form.get("resolution", "").strip() if resolution and not RESOLUTION_PATTERN.fullmatch(resolution): - errors.append("The Resolution must be width x height, e.g. 1280x720, or left blank.") + errors.append("The Resolution must be width x height, a height, or a percentage, e.g. 1280x720, 720, or 50%.") crop = form.get("crop", "").strip() if crop and not CROP_PATTERN.fullmatch(crop): diff --git a/ffmpeg4discord/templates/web.html b/ffmpeg4discord/templates/web.html index 2136d12..2fe4be1 100644 --- a/ffmpeg4discord/templates/web.html +++ b/ffmpeg4discord/templates/web.html @@ -105,6 +105,7 @@

Output Parameters

+
e.g. 1280x720, 720, or 50%
@@ -444,11 +445,15 @@

Audio Streams

const codec = document.getElementById('codec').value; const resolution = document.getElementById('resolution').value.trim(); - const size = resolution.match(/^(\d+)x(\d+)$/); - if (resolution && !size) { - errors.resolution = 'Use width x height, e.g. 1280x720, or leave it blank.'; + // width x height, a height (the width follows), or a percentage of the height + const size = resolution.match(/^(\d+)x(\d+)$/) || resolution.match(/^()(\d+)$/); + const percent = resolution.match(/^(\d+(\.\d+)?)%$/); + if (resolution && !size && !percent) { + errors.resolution = 'Use width x height, a height, or a percentage, e.g. 1280x720, 720, or 50%, or leave it blank.'; + } else if ((size && Number(size[2]) === 0) || (percent && Number(percent[1]) === 0)) { + errors.resolution = 'The Resolution must be above 0.'; } else if (size && ['x264', 'x265'].includes(codec) && (size[1] % 2 || size[2] % 2)) { - errors.resolution = codec + ' needs an even width and height, e.g. 1280x720.'; + errors.resolution = codec + ' needs an even width and height, e.g. 1280x720 or 720.'; } const audioKbps = readNumber('audio_br'); diff --git a/ffmpeg4discord/twopass.py b/ffmpeg4discord/twopass.py index 7e2383b..5b0bb8b 100644 --- a/ffmpeg4discord/twopass.py +++ b/ffmpeg4discord/twopass.py @@ -214,7 +214,8 @@ class EncodeSettings: audio_br (float): Audio bitrate in kbps. None means the input's audio bitrate. codec (str): A key of `CODEC_ENCODERS`, e.g. "x264". crop (str): x offset, y offset, width, and height joined by x, e.g. "255x0x1410x1080". - resolution (str): Output width and height, e.g. "1280x720". + resolution (str): Output width and height, a height, or a percentage of the height, e.g. "1280x720", + "720", or "50%". The last two keep the aspect ratio. framerate (int): Output frames per second. None keeps the input's frame rate. no_audio (bool): Leave audio out of the output. amix (bool): Mix the selected audio tracks into one. @@ -504,6 +505,25 @@ def _bitrate_targets(self, settings: EncodeSettings, length: float) -> dict: "bufsize": br * 2, } + def _scale_size(self, settings: EncodeSettings) -> tuple[int, int]: + """ + The width and height for ffmpeg's scale filter from `settings.resolution`: "1280x720", "720", or "50%". + A height or percentage gives a width of -2, which tells ffmpeg to keep the aspect ratio with an even width. + """ + resolution = settings.resolution + if size := re.fullmatch(r"(\d+)x(\d+)", resolution): + return int(size[1]), int(size[2]) + if re.fullmatch(r"\d+", resolution) and int(resolution) > 0: + return -2, int(resolution) + if (percent := re.fullmatch(r"(\d+(?:\.\d+)?)%", resolution)) and float(percent[1]) > 0: + # a percentage of the cropped height, or the input's height without a crop, rounded to an even number + base = int(settings.crop.split("x")[3]) if settings.crop else self.height + return -2, max(2, round(base * float(percent[1]) / 200) * 2) + raise ValueError( + f"Invalid Resolution (-r) {resolution!r}. Use width x height, a height, or a percentage of the height, " + "e.g. 1280x720, 720, or 50%." + ) + def _check_video_size(self, settings: EncodeSettings) -> None: """Raise a ValueError for a crop or resolution that ffmpeg would reject or quietly change.""" @@ -516,15 +536,18 @@ def _check_video_size(self, settings: EncodeSettings) -> None: f"Keep x offset + width at most {self.width} and y offset + height at most {self.height}." ) + if settings.resolution: + self._scale_size(settings) # raises for a Resolution in the wrong format + # x264 and x265 only encode even sizes. The output size is the resolution, or the crop without one. if settings.codec not in ("x264", "x265"): return if settings.resolution: - width, height = (int(n) for n in settings.resolution.split("x")) - if width % 2 or height % 2: + width, height = self._scale_size(settings) + if width % 2 or height % 2: # -2, the width ffmpeg picks, is even raise ValueError( f"{settings.codec} needs an even width and height, but the Resolution (-r) is " - f"{settings.resolution}. Use even numbers, e.g. 1280x720." + f"{settings.resolution}. Use even numbers, e.g. 1280x720 or 720." ) elif settings.crop and (crop_width % 2 or crop_height % 2): raise ValueError( @@ -588,12 +611,11 @@ def _apply_video_filters(self, video, settings: EncodeSettings): ratio = int(crop[2]) / int(crop[3]) if settings.resolution: - video = video.filter("scale", settings.resolution) - x = int(settings.resolution.split("x")[0]) - y = int(settings.resolution.split("x")[1]) - outputratio = x / y + width, height = self._scale_size(settings) + video = video.filter("scale", width, height) - if ratio != outputratio: + # A height or percentage (width -2) always keeps the aspect ratio + if width != -2 and ratio != width / height: logging.warning( red( """ diff --git a/tests/test_main.py b/tests/test_main.py index 8ae0e21..72525cb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -429,7 +429,7 @@ def test_encode_audio_stream_selection(web, astreams, expected): ({"target_filesize": "nan"}, b"File Size must be a number above 0"), ({"target_filesize": "60"}, b"larger than the input file (50.54 MiB)"), ({"codec": "not_a_codec"}, b"Pick a Video Codec from the list"), - ({"resolution": "1280"}, b"Resolution must be width x height"), + ({"resolution": "720p"}, b"Resolution must be width x height, a height, or a percentage"), ({"crop": "0x0x100"}, b"Crop must be four numbers joined by x"), ({"output": "missing/out.mp4"}, b"The output folder missing"), ({"framerate": "29.97"}, b"Frame Rate must be a whole number above 0"), @@ -488,10 +488,18 @@ def test_encode_settings_that_cannot_work_fail_before_starting(web): web.twopass_loop.assert_not_called() +@pytest.mark.parametrize("resolution", ["1280x720", "720", "50%", "33.3%"]) +def test_encode_accepts_each_resolution_form(web, resolution): + response = web.client.post("/encode", data=encode_form(resolution=resolution)) + assert response.status_code != 400 + assert posted_settings(web).resolution == resolution + + @pytest.mark.parametrize( "form, message", [ ({"resolution": "1281x720"}, b"x264 needs an even width and height"), + ({"resolution": "0%"}, b"Invalid Resolution"), ({"crop": "1000x0x1280x720"}, b"goes past the edge of the 1920x1080 video"), ], ) diff --git a/tests/test_twopass.py b/tests/test_twopass.py index 33de1d2..d2ebece 100644 --- a/tests/test_twopass.py +++ b/tests/test_twopass.py @@ -312,7 +312,7 @@ def test_apply_video_filters_crop_and_resolution(make_twopass): video.filter.return_value = video make_twopass()._apply_video_filters(video, make_settings(crop="10x20x640x360", resolution="1280x720")) video.crop.assert_called_once_with(x="10", y="20", width="640", height="360") - video.filter.assert_called_once_with("scale", "1280x720") + video.filter.assert_called_once_with("scale", 1280, 720) def test_apply_video_filters_warns_on_aspect_ratio_mismatch(make_twopass, caplog): @@ -323,6 +323,26 @@ def test_apply_video_filters_warns_on_aspect_ratio_mismatch(make_twopass, caplog assert "aspect ratio does not match" in caplog.text.lower() +@pytest.mark.parametrize( + "settings, scale", + [ + ({"resolution": "1280x720"}, "scale=1280:720"), + ({"resolution": "720"}, "scale=-2:720"), + ({"resolution": "50%"}, "scale=-2:540"), + ({"resolution": "33%"}, "scale=-2:356"), # 356.4 rounds to an even height + ({"resolution": "50%", "crop": "0x0x1280x720"}, "scale=-2:360"), # a percentage of the cropped height + ], +) +def test_run_scales_to_the_resolution(make_twopass, passes, settings, scale): + make_twopass().run(make_settings(**settings)) + assert scale in " ".join(passes[-1].args) + + +def test_height_only_resolution_does_not_warn_about_aspect_ratio(make_twopass, passes, caplog): + make_twopass().run(make_settings(crop="0x0x1440x1080", resolution="720")) + assert "aspect ratio does not match" not in caplog.text.lower() + + def test_crop_does_not_change_the_next_encodes_aspect_ratio(make_twopass, caplog): # A 4:3 crop once overwrote the source's ratio, so a later 16:9 resize without a crop warned. tp = make_twopass() @@ -578,6 +598,11 @@ def test_dry_run_saves_commands_without_running_ffmpeg(make_twopass, passes): "settings, message", [ ({"resolution": "1280x721"}, "x264 needs an even width and height, but the Resolution"), + ({"resolution": "721"}, "x264 needs an even width and height, but the Resolution"), + ({"codec": "x265", "resolution": "721"}, "x265 needs an even width and height, but the Resolution"), + ({"resolution": "720p"}, "Invalid Resolution"), + ({"codec": "vp9", "resolution": "0"}, "Invalid Resolution"), + ({"resolution": "0%"}, "Invalid Resolution"), ({"codec": "x265", "crop": "0x0x641x360"}, "x265 needs an even width and height, but the Crop"), ({"crop": "0x0x1921x1080"}, "goes past the edge of the 1920x1080 video"), ({"crop": "0x100x1920x1080", "codec": "vp9"}, "goes past the edge"), @@ -592,6 +617,7 @@ def test_run_rejects_sizes_ffmpeg_cannot_use(make_twopass, passes, settings, mes "settings", [ {"codec": "vp9", "resolution": "1281x721"}, # only x264 and x265 need even sizes + {"codec": "vp9", "resolution": "721"}, {"crop": "0x0x641x360", "resolution": "640x360"}, # the resolution sets the output size {"crop": "1280x0x640x1080"}, # touches the right edge, but stays inside ], From 77293a30cca2b80e25a9739ca2c257e0402949fd Mon Sep 17 00:00:00 2001 From: Zach Fleeman Date: Fri, 6 Nov 2026 10:12:23 -0700 Subject: [PATCH 2/2] Accept a number for resolution in a config file --- ffmpeg4discord/twopass.py | 2 +- tests/test_twopass.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ffmpeg4discord/twopass.py b/ffmpeg4discord/twopass.py index 5b0bb8b..7451eb0 100644 --- a/ffmpeg4discord/twopass.py +++ b/ffmpeg4discord/twopass.py @@ -510,7 +510,7 @@ def _scale_size(self, settings: EncodeSettings) -> tuple[int, int]: The width and height for ffmpeg's scale filter from `settings.resolution`: "1280x720", "720", or "50%". A height or percentage gives a width of -2, which tells ffmpeg to keep the aspect ratio with an even width. """ - resolution = settings.resolution + resolution = str(settings.resolution) # a config file can give a number, e.g. "resolution": 720 if size := re.fullmatch(r"(\d+)x(\d+)", resolution): return int(size[1]), int(size[2]) if re.fullmatch(r"\d+", resolution) and int(resolution) > 0: diff --git a/tests/test_twopass.py b/tests/test_twopass.py index d2ebece..9a81fbd 100644 --- a/tests/test_twopass.py +++ b/tests/test_twopass.py @@ -331,6 +331,7 @@ def test_apply_video_filters_warns_on_aspect_ratio_mismatch(make_twopass, caplog ({"resolution": "50%"}, "scale=-2:540"), ({"resolution": "33%"}, "scale=-2:356"), # 356.4 rounds to an even height ({"resolution": "50%", "crop": "0x0x1280x720"}, "scale=-2:360"), # a percentage of the cropped height + ({"resolution": 720}, "scale=-2:720"), # a config file can give a number ], ) def test_run_scales_to_the_resolution(make_twopass, passes, settings, scale):