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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ I've had a good time using this command with a Batch file on Windows. Refer to t
| `-s`<br>`--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`<br>`--audio-br` | 96 | `-a 128` | Audio bitrate in kbps. Lowering this allows a slightly higher video bitrate for the same target file size. |
| `-c`<br>`--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`<br>`--resolution` | off | `-r 1280x720` | Scale the output video to a specific resolution (format: `WIDTHxHEIGHT`). |
| `-r`<br>`--resolution` | off | `-r 1280x720`<br>`-r 720`<br>`-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`<br>`--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`<br>`--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`<br>`--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. |
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions ffmpeg4discord/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+")


Expand Down Expand Up @@ -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):
Expand Down
13 changes: 9 additions & 4 deletions ffmpeg4discord/templates/web.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ <h3>Output Parameters</h3>
<label for="resolution" class="form-label">Resolution</label>
<input class="form-control" name="resolution" id="resolution" type="text" value="{{ values.resolution }}" />
<div id="resolutionFeedback" class="invalid-feedback"></div>
<div class="form-text">e.g. 1280x720, 720, or 50%</div>
</div>
<div class="col">
<label for="target_filesize" class="form-label"><span id="targetApprox">{{ "Approximate" if values.approx else "Target" }}</span> File Size (MB)</label>
Expand Down Expand Up @@ -444,11 +445,15 @@ <h4>Audio Streams</h4>

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');
Expand Down
40 changes: 31 additions & 9 deletions ffmpeg4discord/twopass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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."""

Expand All @@ -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(
Expand Down Expand Up @@ -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(
"""
Expand Down
10 changes: 9 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
],
)
Expand Down
29 changes: 28 additions & 1 deletion tests/test_twopass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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()
Expand Down Expand Up @@ -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"),
Expand All @@ -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
],
Expand Down
Loading