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`).

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..7451eb0 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 = 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:
+ 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..9a81fbd 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,27 @@ 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
+ ({"resolution": 720}, "scale=-2:720"), # a config file can give a number
+ ],
+)
+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 +599,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 +618,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
],