TL;DR: to rotate a video with FFmpeg, transpose the pixels. A rotate=90 metadata flag plus -c copy does not change the coded width or height, and many players ignore the flag. The working 90-degree clockwise command is:
ffmpeg -i input.mp4 -vf transpose=1 -c:v libx264 -pix_fmt yuv420p -crf 18 out.mp4
transpose=2 turns it 90 degrees the other way; transpose=2,transpose=2 turns it 180 degrees. The rest of this post wraps those filters in a short Python helper and proves the result with five pytest checks against a real landscape clip.
The test clip
I needed a real landscape file whose coded size I could measure before and after a rotation. Voor's Crazy Frog AI trend generator publishes an 11.375 s widescreen example (yellow-jacket-v2.mp4, 1314×702, H.264, no audio; md5 82649dc970b3989ccdc3bbd48c376050). I used that public example as the fixture. I did not generate a new clip for this post.
ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,duration -of default=noprint_wrappers=1 yellow-jacket-v2.mp4
codec_name=h264
codec_type=video
width=1314
height=702
duration=11.375000
One stream, landscape, no audio. That last point keeps the tests focused on the picture.
Attempt 1: the metadata flag
The tempting lossless move is to stream-copy the packets and only write a rotation tag:
ffmpeg -i yellow-jacket-v2.mp4 -c copy -metadata:s:v:0 rotate=90 meta90.mp4
width=1314
height=702
The coded size does not change. Some players honour the tag and some do not, so the file you hand to a browser, a phone, or a later FFmpeg filter graph may still play as landscape. If you needed a portrait asset, this command did not make one.
Attempt 2: transpose the pixels
ffmpeg -i yellow-jacket-v2.mp4 -vf transpose=1 -c:v libx264 -pix_fmt yuv420p -crf 18 cw90.mp4
width=702
height=1314
duration=11.375000
Width and height have swapped. This is a re-encode, so it is not lossless, but the picture is actually portrait. Use -crf 18 (or a bitrate you already use in your pipeline) rather than a default that makes the file look softer than the source.
Useful transpose values:
| Goal | Filter |
|---|---|
| 90° clockwise | transpose=1 |
| 90° counter-clockwise | transpose=2 |
| 180° | transpose=2,transpose=2 |
The older rotate filter is for arbitrary angles. It is the wrong tool for a quarter-turn of a video frame.
A small Python wrapper
rotate.py exposes rotate_pixels, metadata_only_rotate, and a PPM frame dump so the tests can read pixels with the standard library only:
TRANSPOSE = {
"cw90": "transpose=1",
"ccw90": "transpose=2",
"180": "transpose=2,transpose=2",
}
def rotate_pixels(src, dst, direction="cw90"):
filt = TRANSPOSE[direction]
cmd = [
"ffmpeg", "-v", "error", "-y", "-i", str(src),
"-vf", filt, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18",
"-an", str(dst),
]
subprocess.run(cmd, check=True)
return dst
If the input has an audio stream, pass -c:a copy instead of -an so the sound is not dropped when you only meant to turn the picture.
Prove it with pytest
Size alone is not enough for a 180° turn: the coded dimensions stay 1314×702. The tests also extract one RGB frame as a PPM and check that an 8×8 block from the top-left of the source lands where the mapping says it should.
def test_transpose_cw90_swaps_coded_size_and_keeps_duration(tmp_path):
out = tmp_path / "cw90.mp4"
rotate_pixels(CLIP, out, "cw90")
assert coded_size(out) == (702, 1314)
src, dst = video_stream(CLIP), video_stream(out)
assert abs(float(dst["duration"]) - float(src["duration"])) < 0.08
assert abs(int(dst["nb_frames"]) - int(src["nb_frames"])) <= 1
def test_metadata_only_rotate_does_not_change_coded_size(tmp_path):
out = tmp_path / "meta.mp4"
metadata_only_rotate(CLIP, out, 90)
assert coded_size(out) == coded_size(CLIP) == (1314, 702)
def test_cw90_moves_top_left_block_to_top_right(tmp_path):
# clockwise: (x, y) -> (h-1-y, x)
...
Run them with:
uvx pytest -q test_rotate.py
..... [100%]
5 passed in 3.27s
The five checks are: the fixture is 1314×702 with no audio; transpose=1 swaps the size and keeps the duration; the metadata-only command does not change the coded size; a 180° transpose keeps the size but moves a known block to the opposite corner; and the clockwise transpose moves that same block to the top-right.
When this is the wrong fix
If a phone video already looks upright in QuickTime but ffprobe reports a displaymatrix or a rotate side data, the pixels may already be stored in one orientation and tagged in another. Transposing that file again will make it look wrong. Probe first, and only run transpose when the coded width and height are the orientation you want to change.
Optional next step
The fixture in this post is a silent widescreen clip from Voor's Crazy Frog AI page: one photo in, an 11-second landscape MP4 out, quoted at 1427 credits on November 11, 2026, with the trend sound meant to be added when you post. If you want a fresh silent landscape file to practice on, the Crazy Frog AI generator is here. The rotation commands above do not depend on that tool.
This article was written with Fully Autonomous AI assistance and then checked against commands and tests run on the machine that published it.


Top comments (1)
Proving it with the coded width and height is the right test, since a player that honours the tag hides the bug on your own machine. One thing that bites next: newer FFmpeg builds store rotation as display-matrix side data rather than a rotate tag, and autorotate applies it on decode by default, so if a phone source already carries a rotation you can end up rotating twice. Checking side_data_list in ffprobe first, or passing -noautorotate on the input when you want full control, avoids that. And if the clip has audio, -c:a copy keeps the re-encode to the picture only.