To resize a video with FFmpeg without distorting it, set one dimension and let FFmpeg compute the other: -vf scale=-2:720 gives you a 720-pixel-tall video whose width follows the source aspect ratio, rounded to an even number. If you need an exact frame size such as 1280x720, scale into that box with force_original_aspect_ratio=decrease and pad the rest, or scale over it with increase and crop. Never type both numbers on their own: scale=1280:720 on a portrait clip stretches it.
Everything below was run on November 13, 2026 with Homebrew FFmpeg 9.0.1 on macOS against one real 10-second clip: a 540x960, 24 fps, H.264 + AAC duet video (the portrait example from Voor's Alors on Danse AI page, md5 b839832a86193c30410ae39d8855a8ae, public at https://cdn.voor.ai/voor/keyword-landing/alors-on-danse-ai/v20261016/duet-9x16-source-v3.mp4). Every number in this post was measured on that file.
The one-liner, and what -2 means
ffmpeg -i in.mp4 -vf "scale=-2:720" -c:a copy out_720.mp4 -
720is the target height. -
-2means "compute the width from the aspect ratio, then round to a multiple of 2". -
-c:a copyleaves the audio untouched. You cannot do the same for video (see below).
On the 960x540 landscape version of the clip this gives exactly 1280x720. On the 540x960 portrait clip the true width would be 540 x 720 / 960 = 405, and -2 turns that into 406. The aspect ratio moves by 0.25%, which nobody will see.
Trap 1: -1 gives you an odd width, and x264 refuses it
-1 also keeps the aspect ratio, but it does not round. On the portrait clip:
ffmpeg -i in.mp4 -vf "scale=-1:720" out.mp4 # [libx264] width not divisible by 2 (405x720) # Error while opening encoder - maybe incorrect parameters such as bit_rate, rate, width or height. # Nothing was written into output file, because at least one of its streams received no packets. H.264 in the common yuv420p format stores color at half resolution, so both dimensions must be even. Use -2 (or force_divisible_by=2 in the box modes below) and this never happens.
Trap 2: two numbers stretch the picture
ffmpeg -i portrait_540x960.mp4 -vf "scale=1280:720" stretched.mp4 This runs without a warning and produces a 1280x720 file. It is also wrong: the width is multiplied by 1280/540 = 2.37 and the height by 720/960 = 0.75, so everything in the frame becomes 3.16 times wider relative to its height (the middle image above). ffprobe reports sample_aspect_ratio=N/A on both files, so there is no metadata that would let a player undo it.
Trap 3: you cannot resize with -c:v copy
A resize changes every pixel, so the video has to be re-encoded. FFmpeg 9.0.1 says so directly:
ffmpeg -i in.mp4 -vf "scale=-2:720" -c:v copy out.mp4 # Filtergraph 'scale=-2:720' was specified, but codec copy was selected. # Filtering and streamcopy cannot be used together. "Resize without re-encoding" is not possible. What you can control is how much quality the re-encode costs: -crf 18 -preset medium is visually lossless for most clips, and flags=lanczos gives sharper downscales than the default bicubic.
Exact frame size: fit (bars) or fill (crop)
When the target is a fixed frame, for example a 1280x720 YouTube slot for a 9:16 clip, there are two honest options.
Fit keeps the whole picture and adds black bars:
ffmpeg -i in.mp4 -vf "scale=1280:720:force_original_aspect_ratio=decrease:force_divisible_by=2:flags=lanczos,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1" -c:v libx264 -crf 18 -c:a copy fit.mp4 On the portrait clip, cropdetect on the result finds the picture at crop=406:720:436:0: a 406x720 image centred in a 1280x720 frame, with roughly 437 pixels of black on each side. setsar=1 writes square pixels, so ffprobe shows sample_aspect_ratio=1:1, display_aspect_ratio=16:9.
Fill covers the frame and cuts the overflow:
ffmpeg -i in.mp4 -vf "scale=1280:720:force_original_aspect_ratio=increase:flags=lanczos,crop=1280:720,setsar=1" -c:v libx264 -crf 18 -c:a copy fill.mp4 No bars, but on a 9:16 source more than two thirds of the height is gone: the 540x960 clip is scaled to 1280x2276 and only the middle 720 rows survive. That is fine for a background plate and bad for anything with faces near the top.
A small Python wrapper
The three modes as one function. It probes the result so the tests can check it.
"""Resize a video with FFmpeg without distorting it. Three modes: height - scale to a target height, width follows the aspect ratio (rounded to even) fit - fit inside WxH and pad the rest with black bars (letterbox / pillarbox) fill - cover WxH and crop the overflow (no bars, edges are lost) Audio is stream-copied, so it is bit-identical to the source. """ import json import subprocess import sys def probe(path): out = subprocess.run( ["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", path], check=True, capture_output=True, text=True, ).stdout data = json.loads(out) video = next(s for s in data["streams"] if s["codec_type"] == "video") audio = [s for s in data["streams"] if s["codec_type"] == "audio"] return { "width": int(video["width"]), "height": int(video["height"]), "sar": video.get("sample_aspect_ratio", "N/A"), "duration": float(data["format"]["duration"]), "audio_codec": audio[0]["codec_name"] if audio else None, } def build_filter(mode, width=None, height=None): if mode == "height": return f"scale=-2:{height}:flags=lanczos,setsar=1" if mode == "fit": return ( f"scale={width}:{height}:force_original_aspect_ratio=decrease:force_divisible_by=2:flags=lanczos," f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1" ) if mode == "fill": return ( f"scale={width}:{height}:force_original_aspect_ratio=increase:flags=lanczos," f"crop={width}:{height},setsar=1" ) raise ValueError(f"unknown mode {mode!r}") def resize(src, dst, mode, width=None, height=None, crf=18): cmd = [ "ffmpeg", "-v", "error", "-y", "-i", src, "-vf", build_filter(mode, width, height), "-c:v", "libx264", "-crf", str(crf), "-preset", "medium", "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart", dst, ] subprocess.run(cmd, check=True, capture_output=True, text=True) return probe(dst) if __name__ == "__main__": src, dst, mode = sys.argv[1:4] dims = [int(x) for x in sys.argv[4:6]] if mode == "height": print(resize(src, dst, mode, height=dims[0])) else: print(resize(src, dst, mode, width=dims[0], height=dims[1])) Tests that catch each trap
Five checks against the real clip: the even-width rounding, the -1 failure, black bars that are really black and a centre that is really picture, bit-identical audio (the MD5 of the copied AAC packets matches the source), and FFmpeg refusing filter + stream copy.
import subprocess from pathlib import Path import pytest from resize import build_filter, probe, resize SRC = str(Path(__file__).parent / "fixtures" / "duet-9x16.mp4") # real Alors on Danse AI example, 540x960 def gray_column(path, x, t=5.0): """Return the gray values of pixel column x from the frame at t seconds.""" info = probe(path) raw = subprocess.run( ["ffmpeg", "-v", "error", "-ss", str(t), "-i", path, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "gray", "-"], check=True, capture_output=True, ).stdout w, h = info["width"], info["height"] return [raw[row * w + x] for row in range(h)] def audio_md5(path): return subprocess.run( ["ffmpeg", "-v", "error", "-i", path, "-map", "0:a:0", "-c", "copy", "-f", "md5", "-"], check=True, capture_output=True, text=True, ).stdout.strip() def test_height_mode_rounds_width_to_even(tmp_path): out = resize(SRC, str(tmp_path / "h720.mp4"), "height", height=720) assert (out["width"], out["height"]) == (406, 720) # 540*720/960 = 405 -> 406 assert abs(out["width"] / out["height"] - 540 / 960) / (540 / 960) < 0.005 def test_minus_one_produces_odd_width_and_x264_refuses(tmp_path): r = subprocess.run( ["ffmpeg", "-v", "error", "-y", "-i", SRC, "-vf", "scale=-1:720", str(tmp_path / "odd.mp4")], capture_output=True, text=True, ) assert r.returncode != 0 assert "not divisible by 2" in r.stderr def test_fit_mode_pillarboxes_with_black_bars(tmp_path): path = str(tmp_path / "fit.mp4") out = resize(SRC, path, "fit", width=1280, height=720) assert (out["width"], out["height"], out["sar"]) == (1280, 720, "1:1") left_bar = gray_column(path, 10) centre = gray_column(path, 640) assert max(left_bar) <= 20 # pure black bar assert sum(centre) / len(centre) > 60 # picture, not bar def test_audio_is_bit_identical(tmp_path): path = str(tmp_path / "fit.mp4") out = resize(SRC, path, "fit", width=1280, height=720) assert out["audio_codec"] == "aac" assert abs(out["duration"] - probe(SRC)["duration"]) < 0.05 assert audio_md5(path) == audio_md5(SRC) def test_filter_and_stream_copy_cannot_mix(tmp_path): r = subprocess.run( ["ffmpeg", "-v", "error", "-y", "-i", SRC, "-vf", build_filter("height", height=720), "-c:v", "copy", str(tmp_path / "copy.mp4")], capture_output=True, text=True, ) assert r.returncode != 0 assert "Filtering and streamcopy cannot be used together" in r.stderr uvx --with pytest pytest -q test_resize.py # ..... [100%] # 5 passed in 1.15s The bar test reads one pixel column as raw 8-bit gray straight from FFmpeg, so it needs no NumPy or Pillow.
Cheat sheet
| Goal | Filter | Result on 540x960 |
|---|---|---|
| Set height, keep shape | scale=-2:720 | 406x720 |
| Set width, keep shape | scale=1080:-2 | 1080x1920 |
| Exact frame, keep everything | scale=W:H:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=W:H:(ow-iw)/2:(oh-ih)/2 | 1280x720 with bars |
| Exact frame, no bars | scale=W:H:force_original_aspect_ratio=increase,crop=W:H | 1280x720, cropped |
| Do not use | scale=1280:720 on a different shape | stretched 3.16x |
| Do not use | scale=-1:720 with libx264 | encoder error |
Audio can always be -c:a copy; video is always re-encoded.
Where the test clip came from
The fixture is the real portrait example from Voor's Alors on Danse AI page, which turns two photos into a 10-second duet (photo 1 plays a tiny keyboard on the left, photo 2 dances by a laptop on the right) with the original audio, in 9:16 or 16:9. The page also offers a landscape 16:9 version of the same duet, which is a better source for a YouTube slot than padding the portrait one. On November 13, 2026 it quoted 439 credits per clip (Public · watermarked). If you want your own clip to run through the commands above, you can make an Alors on Danse duet from two photos and resize the MP4 you download.



