How to compress video with FFmpeg
To reduce a video’s file size for the web, start with H.264 video and AAC audio in an MP4 container.
This command creates output.mp4 from a local input.mp4, using quality-based encoding:
if [ -e "./output.mp4" ] || [ -L "./output.mp4" ]; then
printf '%s\n' 'Choose a new filename: ./output.mp4 already exists.' >&2
false
else
ffmpeg -n -nostdin -xerror -i "./input.mp4" \
-map 0:V:0 -map '0:a:0?' \
-c:v libx264 -crf 23 -preset medium -pix_fmt yuv420p \
-c:a aac -b:a 128k -ac 2 -movflags +faststart "./output.mp4"
fi
Lossy compression discards detail. The result may be smaller, but an already efficient source can grow when re-encoded. Keep the original and compare the output before choosing settings for other videos. CRF controls quality; it does not guarantee a particular file size.
The command selects the first video stream other than cover art and the first audio track, if one
exists. It produces stereo AAC when there is audio, and a silent video otherwise. Other audio tracks,
subtitle streams, and data streams are omitted. The optional audio mapping and -n overwrite refusal
are documented in FFmpeg’s command reference.
-nostdin disables interactive input, and -xerror stops on errors FFmpeg detects. An error can
leave an incomplete output; do not publish it or treat its small size as successful compression.
Check the prerequisites
Use Bash on Linux with ffmpeg and ffprobe on your PATH. These examples were tested with FFmpeg
6.1.1 on Ubuntu 24.04 and FFmpeg 9.0.1 on Linux. They need the libx264 video encoder, the native
aac audio encoder, and the MP4 muxer. Check your build:
ffmpeg -version &&
ffprobe -version &&
ffmpeg -hide_banner -h encoder=libx264 &&
ffmpeg -hide_banner -h encoder=aac
The help output should describe both encoders; an “unknown” or “not recognized” message means the build is unsuitable, even if the help command exits successfully. If you need FFmpeg, its download page links to Linux packages and macOS and Windows builds. The shell examples here are for Linux.
Use a progressive, standard dynamic range (SDR) clip with square pixels and even width and height.
These commands do not resize the video or perform HDR tone mapping. yuv420p selects 8-bit 4:2:0
video; it does not turn HDR colors into correct SDR colors. Keep filenames quoted, including the
./ prefix for names that begin with a hyphen. Each encode refuses to replace an existing MP4, so
choose a new destination when comparing settings. The shell check also returns a failing status for
an existing destination: FFmpeg’s own overwrite refusal can exit successfully in some versions.
Choose CRF and preset separately
For libx264, start at CRF 23. Try 20 if small text or fine detail looks damaged, or 26 if reducing
bytes matters more. Lower CRF values retain more detail and usually produce larger files; higher
values trade more detail for fewer bytes. Re-encode from the original for each comparison.
The preset controls how much encoding work x264 does. medium is a reasonable starting point;
slow spends more time looking for efficient ways to encode the video. It can improve compression
efficiency, but it does not promise a fixed percentage saving or identical quality at the same CRF.
See the x264 encoder options for the
separate quality and preset controls.
CRF values and preset names belong to an encoder. CRF 23 in x264 is not a quality equivalence for
x265, VP9, or AV1, and slow is not a shared quality scale across encoders. If the source resolution
exceeds what you display, resizing may help more than further increasing CRF, but inspect small text
and other detail at the intended display size before discarding pixels.
Comparing quality and file size
Inspect the source and output with ffprobe. This prints stream properties, duration in seconds, and file size in bytes:
ffprobe -v error -show_format -show_streams -of json "./input.mp4" &&
ffprobe -v error -show_format -show_streams -of json "./output.mp4"
Look for codec_name: "h264" and pix_fmt: "yuv420p" on the video stream. For a source without
rotation metadata, width and height should match. An audio stream should report aac with two
channels if the input had audio. Compare format.size and format.duration with the source. Small
audio padding differences are normal; losing seconds of content is not.
Decode the complete result to catch errors beyond its metadata:
ffmpeg -v error -nostdin -xerror -i "./output.mp4" \
-map 0:V:0 -map '0:a:0?' -f null -
Successful decoding produces no error output here. It does not measure visual quality. Play both files at the intended display size and inspect motion, gradients, small text, and audio sync. A smaller file is useful only if its picture and sound remain acceptable for your page.
Use two passes for a size budget
When you have an upload limit, choose an average bitrate instead of CRF. A starting calculation is
video bits/s = (target bytes × 8 ÷ duration seconds) − audio bits/s. Reserve some of the total
budget for the container and bitrate variation, then measure the completed file. Two-pass encoding
aims at an average video bitrate; it is not an exact byte limit.
For example, a 60-second clip under 10 MB (10,000,000 bytes) has about 1,333 kbit/s available in
total. Reserving 5% and allowing 128 kbit/s for audio leaves about 1,139 kbit/s for video. Round down
to 1100k for an initial attempt. Use your source’s actual duration from ffprobe and recalculate
for a different limit. With no audio, this example leaves the audio allowance unused.
Run this complete block in Bash. It creates target.mp4 independently of the earlier CRF output:
(
if [ -e "./target.mp4" ] || [ -L "./target.mp4" ]; then
printf '%s\n' 'Choose a new filename: ./target.mp4 already exists.' >&2
exit 1
fi
pass_dir=$(mktemp -d) || exit 1
trap 'rm -rf -- "$pass_dir"' EXIT
ffmpeg -y -nostdin -xerror -i "./input.mp4" \
-map 0:V:0 -c:v libx264 -b:v 1100k -preset medium -pix_fmt yuv420p \
-pass 1 -passlogfile "$pass_dir/stats" -an -f null /dev/null &&
ffmpeg -n -nostdin -xerror -i "./input.mp4" \
-map 0:V:0 -map '0:a:0?' \
-c:v libx264 -b:v 1100k -preset medium -pix_fmt yuv420p \
-pass 2 -passlogfile "$pass_dir/stats" \
-c:a aac -b:a 128k -ac 2 -movflags +faststart "./target.mp4"
)
The first pass gathers statistics; && prevents the second pass from running if the first fails.
The temporary directory keeps pass logs separate
between runs and is removed when the block exits. The first pass’s -y applies only to discarded
output at /dev/null; the second pass uses -n to protect target.mp4. Keep the input, bitrate,
preset, and any video filters consistent between passes.
Repeat the inspection and decode commands with target.mp4 in place of output.mp4. If it exceeds
your limit, lower the video bitrate and run both passes again with a new output filename. If the
picture becomes unacceptable before the file fits, reconsider the duration or resolution. Merely
changing codecs cannot guarantee an acceptable result within every budget.
Browser compatibility
H.264 is the video codec; MP4 is the container holding that video and its AAC audio. This is a practical starting combination for broad web playback, but support also depends on the browser, operating system, codec profile and level, resolution, and device decoder. For example, Firefox can depend on operating system codecs for H.264. Check the MDN video codec guide and test the actual file on the browsers and devices you serve. HEVC, VP9, and AV1 are alternatives to evaluate for that audience, not universally better or universally supported replacements.
-movflags +faststart places the MP4 index before the media data, allowing a supporting player to
begin playback without first downloading the end of the file. It does not reduce encoded video
size or create adaptive streaming renditions. The
MP4 muxer documentation explains
this extra indexing step. A successful local decode still needs a playback check through your actual
web server and player before release.
Reuse settings carefully
Try your chosen settings on representative clips before encoding a collection. A screen recording with small text and a handheld camera clip may need different CRF values. Keep a separate destination for each source and check each command’s status before inspecting or publishing its output. After a failed encode, select a fresh output name or remove only the incomplete file you just created; overwrite refusal also applies to partial files left by earlier runs.
