To compress a video with ffmpeg: ffmpeg -i in.mp4 -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k out.mp4. -c:v libx264 re-encodes the picture as H.264. -crf 23 sets a constant quality instead of a bitrate: a lower CRF keeps more detail and writes a bigger file, a higher one throws more away. -preset medium sets how hard the encoder searches for a smaller way to store the same quality: slower presets make smaller files at the same CRF. -c:a aac -b:a 128k re-encodes the sound as AAC at 128 kbit/s. On the 4 s 1080x1920 test clip below, CRF 23 wrote 3,391,300 bytes, about a fifth of the lossless original, with an SSIM of 0.995787 against it.

ffmpeg -i in.mp4 -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k out.mp4

The numbers on this page come from a synthetic test pattern, not camera footage, and the section What these numbers do and do not predict says what that means for your own files.

The test clip

Every command on this page ran with ffmpeg 4.4.2, the version Ubuntu 22.04 ships, in an empty directory, one after another in the same shell. The clip is made by ffmpeg itself, so you can rebuild it: 4 s of the testsrc2 pattern at 1080x1920 and 30 frames per second, with a 440 Hz tone. No downloaded media and no font is used. It is encoded with -qp 0, which makes libx264 lossless, so in.mp4 is both the input to every encode and the reference each one is scored against.

-threads 4 appears in every measured encode. It is there only so the byte counts repeat exactly on another machine: libx264 splits its work between threads, its output depends on how many there are, and without the option ffmpeg picks the number from the machine’s processor count. You do not need it in your own commands.

# A 4 s 1080x1920 clip at 30 fps (ffmpeg's testsrc2 pattern) with a 440 Hz tone.
# -qp 0 makes libx264 lossless, so in.mp4 is the reference every encode is scored against.
# -threads 4, here and below, only makes the byte counts repeat exactly on any machine.
ffmpeg -v error -f lavfi -i "testsrc2=s=1080x1920:r=30:d=4" \
  -f lavfi -i "sine=frequency=440:sample_rate=48000:duration=4" \
  -c:v libx264 -threads 4 -qp 0 -preset ultrafast -pix_fmt yuv420p \
  -c:a aac -b:a 128k in.mp4
echo "in.mp4 $(stat -c %s in.mp4) bytes"
ffprobe -v error -show_entries format=duration -of default=nw=1 in.mp4
in.mp4 17183329 bytes
duration=4.000000

CRF 18, 23, 28 and 33: bytes, SSIM and PSNR

The loop encodes the clip at four CRF values with everything else fixed, then scores each result against the lossless in.mp4 with ffmpeg’s own ssim and psnr filters. In both filters the first input is the “main” source and the second is used as a “reference” video, so the encode goes first. SSIM is a similarity score where 1 means identical. PSNR is in decibels, and higher is closer. Both average every frame of the clip.

# One encode per CRF, then SSIM and PSNR of each against the lossless in.mp4
for crf in 18 23 28 33; do
  ffmpeg -v error -i in.mp4 -c:v libx264 -threads 4 -crf $crf -preset medium \
    -c:a aac -b:a 128k crf$crf.mp4
  ssim=$(ffmpeg -i crf$crf.mp4 -i in.mp4 -lavfi ssim -f null - 2>&1 | grep -o 'All:[0-9.]*')
  psnr=$(ffmpeg -i crf$crf.mp4 -i in.mp4 -lavfi psnr -f null - 2>&1 | grep -o 'average:[0-9.]*')
  echo "crf$crf.mp4 $(stat -c %s crf$crf.mp4) bytes $ssim $psnr"
done
crf18.mp4 5228913 bytes All:0.998444 average:49.619096
crf23.mp4 3391300 bytes All:0.995787 average:44.513153
crf28.mp4 1624254 bytes All:0.987563 average:39.802867
crf33.mp4 836944 bytes All:0.979876 average:37.048963
CRFBytesSize vs the CRF 5 lowerSSIMPSNR (dB)
185,228,9130.99844449.619096
233,391,30065%0.99578744.513153
281,624,25448%0.98756339.802867
33836,94452%0.97987637.048963

Each step of 5 cut the file to between 48% and 65% of the step before, and every step lowered both scores. On this clip, going from CRF 18 to 33 made the file 6.2 times smaller. Every file includes the same 4 s of 128 kbit/s AAC, so the sound is a fixed part of each byte count.

The FFmpeg wiki’s H.264 guide describes the scale this way: “The range of the CRF scale is 0–51, where 0 is lossless (for 8 bit only, for 10 bit use -qp 0), 23 is the default, and 51 is worst quality possible.” It also says “Consider 17 or 18 to be visually lossless or nearly so”. That is a judgement about how video looks to people, and SSIM and PSNR do not measure it, so this page does not confirm or refute it.

Here is the same patch of the frame at 2 s from three files: the lossless original, CRF 23 and CRF 33. The patch is 160x160 pixels, enlarged 2x with no smoothing so single pixels stay sharp. The command that made it is the last one on this page.

Three square patches of the same video frame side by side, each showing a blue and magenta checkerboard whose squares are filled with teal and grey pixel noise, crossed by a thin purple diagonal line. The left patch is the lossless original and the middle one is CRF 23: they look almost the same, every noise pixel sharp. In the right patch, CRF 33, the noise inside the squares is smeared into soft blotches and the diagonal line has blurred edges.

What happens when you leave -crf and -preset out? The ffmpeg 4.4.2 help for libx264 lists both CRF and preset defaults, but its CRF default is -1, which means ffmpeg passes nothing and libx264 uses its own value. So the encode was run again with neither option set:

# Leave out -crf and -preset entirely: are the defaults 23 and medium?
ffmpeg -v error -i in.mp4 -c:v libx264 -threads 4 -c:a aac -b:a 128k defaults.mp4
cmp defaults.mp4 crf23.mp4 && echo "defaults.mp4 is byte-identical to crf23.mp4"
defaults.mp4 is byte-identical to crf23.mp4

So with ffmpeg 4.4.2 and its libx264, -crf 23 -preset medium in the first command spells out the defaults. They are written there anyway so you can see which numbers to change.

Presets at CRF 23: bytes, SSIM and encode time

Same CRF, three presets. The time is wall-clock time for the encode only, on the machine that ran this page (4 processing units). It changes from run to run and from machine to machine, so treat it as a ratio, not a number to plan with.

# The same CRF 23 at three presets; the time is this machine's wall-clock time
for p in ultrafast medium slow; do
  start=$(date +%s.%N)
  ffmpeg -v error -i in.mp4 -c:v libx264 -threads 4 -crf 23 -preset $p \
    -c:a aac -b:a 128k $p.mp4
  end=$(date +%s.%N)
  ssim=$(ffmpeg -i $p.mp4 -i in.mp4 -lavfi ssim -f null - 2>&1 | grep -o 'All:[0-9.]*')
  secs=$(echo "$start $end" | awk '{ printf "%.1f", $2 - $1 }')
  echo "$p.mp4 $(stat -c %s $p.mp4) bytes $ssim encode ${secs} s"
done
ultrafast.mp4 7931605 bytes All:0.998830 encode 0.6 s
medium.mp4 3391300 bytes All:0.995787 encode 2.9 s
slow.mp4 3303230 bytes All:0.995852 encode 4.9 s
PresetBytesSSIMEncode time on this machine
ultrafast7,931,6050.9988300.6 s
medium3,391,3000.9957872.9 s
slow3,303,2300.9958524.9 s

medium.mp4 is the same size as crf23.mp4 above, as it should be: it is the same encode. Two results are worth knowing:

  • The same CRF does not mean the same file across presets. ultrafast wrote a file 2.3 times the size of medium, and its SSIM is higher, not lower. The fast preset switches off much of the encoder’s work, so at the same CRF it spends more bytes and keeps more detail. If you change the preset, check the size again; do not assume the CRF fixes it.
  • slow was 2.6% smaller than medium, with a slightly higher SSIM, and took longer. On this clip the extra time bought little.

H.265 (libx265) against H.264

libx265 writes H.265 (HEVC) and takes -crf and -preset the same way. Its ffmpeg 4.4.2 help also shows a CRF default of -1. The FFmpeg wiki’s H.265 guide gives the value: “The default is 28, and it should visually correspond to libx264 video at CRF 23, but result in about half the file size.” The block encodes with no -crf, then with -crf 28, and checks whether the two files are the same:

# H.265 with no -crf (its default), with -crf 28 spelled out, then both codecs scored.
# pools=4 pins x265's thread pool for the same reason as -threads 4; log-level quiets it.
X265="-c:v libx265 -preset medium -x265-params pools=4:log-level=error"
ffmpeg -v error -i in.mp4 $X265 -c:a aac -b:a 128k x265-default.mp4
ffmpeg -v error -i in.mp4 $X265 -crf 28 -c:a aac -b:a 128k x265-crf28.mp4
cmp x265-default.mp4 x265-crf28.mp4 && echo "x265-default.mp4 is byte-identical to x265-crf28.mp4"
for f in crf23.mp4 x265-crf28.mp4; do
  codec=$(ffprobe -v error -select_streams v -show_entries stream=codec_name -of csv=p=0 $f)
  ssim=$(ffmpeg -i $f -i in.mp4 -lavfi ssim -f null - 2>&1 | grep -o 'All:[0-9.]*')
  psnr=$(ffmpeg -i $f -i in.mp4 -lavfi psnr -f null - 2>&1 | grep -o 'average:[0-9.]*')
  echo "$f $codec $(stat -c %s $f) bytes $ssim $psnr"
done
x265-default.mp4 is byte-identical to x265-crf28.mp4
crf23.mp4 h264 3391300 bytes All:0.995787 average:44.513153
x265-crf28.mp4 hevc 2501841 bytes All:0.991936 average:41.586922
EncodeBytesSSIMPSNR (dB)
libx264, CRF 233,391,3000.99578744.513153
libx265, CRF 28 (its default)2,501,8410.99193641.586922

The default is 28: leaving -crf out gave the same bytes. The H.265 file is 74% of the H.264 one, 26% smaller, not about half. Both of its scores are lower, so on these two measures the two files are not equal quality either. The wiki’s claim is about how the video looks, which this test does not measure, and one synthetic clip cannot settle it either way. What the run does show is that x265 at CRF 28 and x264 at CRF 23 are not an exact exchange rate. If size matters, encode a sample of your own footage both ways and compare.

Hitting a target file size with two-pass

CRF fixes the quality and lets the size fall where it falls. To hit a size, you set an average bitrate instead, and two-pass encoding lets the encoder see the whole clip before it decides where to spend those bits. The arithmetic for a 10 MB file:

  1. Decide what MB means. Here 10 MB is 10,000,000 bytes (10^6 bytes per MB). If the limit you are aiming at is 10 MiB (2^20 bytes each), that is 10,485,760 bytes. Check which one your target uses.
  2. Turn bytes into kbit/s. Multiply by 8 for bits, divide by the duration in seconds, and divide by 1000, because ffmpeg’s k is 1000. For a 20 s clip: 10,000,000 × 8 / 20 / 1000 = 4000 kbit/s for the whole file.
  3. Subtract the audio. With -b:a 128k, the video gets 4000 - 128 = 3872 kbit/s.

For a 60 s video the same steps give 1333 kbit/s in total and 1205 kbit/s for the video. With 10 MiB on the 20 s clip they give 4194 and 4066. Get the duration of your own file with the ffprobe line from the first block.

The first pass only analyses: it writes statistics, no audio, and a null output. The second pass reads them and writes the file. In the ffmpeg docs, -pass records statistics “into a log file” in the first pass, “and in the second pass that log file is used to generate the video at the exact requested bitrate.” The log goes in the current directory, named ffmpeg2pass-0.log, unless you set -passlogfile.

This block uses a 20 s clip of the same pattern, made on the fly, because 10 MB is far more than the 4 s clip needs:

# Target 10 MB for a 20 s clip. MB here is 10^6 bytes (10000000), not MiB (10485760).
# Video kbit/s = target bits / seconds / 1000 - audio kbit/s (a kbit is 1000 bits).
SRC="testsrc2=s=1080x1920:r=30:d=20"
total=$(awk 'BEGIN { printf "%d", 10000000 * 8 / 20 / 1000 }')
video=$((total - 128))
echo "total ${total}k, video ${video}k"
ffmpeg -v error -y -f lavfi -i "$SRC" -c:v libx264 -threads 4 -preset medium \
  -b:v ${video}k -pass 1 -an -f null /dev/null
ffmpeg -v error -f lavfi -i "$SRC" -f lavfi -i "sine=frequency=440:duration=20" \
  -c:v libx264 -threads 4 -preset medium -b:v ${video}k -pass 2 \
  -c:a aac -b:a 128k target.mp4
echo "target.mp4 $(stat -c %s target.mp4) bytes"
ffprobe -v error -show_entries stream=codec_type,bit_rate -of csv=p=0 target.mp4
ffmpeg -i target.mp4 -f lavfi -i "$SRC" -lavfi ssim -f null - 2>&1 | grep -o 'All:[0-9.]*'
ls ffmpeg2pass*
total 4000k, video 3872k
target.mp4 9914553 bytes
video,3873036
audio,83879
All:0.988485
ffmpeg2pass-0.log
ffmpeg2pass-0.log.mbtree

The file came out at 9,914,553 bytes, 85,447 bytes (0.85%) under the 10,000,000 target. The video landed on its bitrate: 3,873,036 bit/s against the 3,872,000 asked for, 1036 bit/s over. The margin came from the sound. The AAC encoder used 83,879 bit/s, about 84 kbit/s, on a plain tone instead of the 128 kbit/s it was given. Speech and music will use more of it. If you must stay under a hard limit, leave some room: at its full 128 kbit/s the sound would be 320,000 bytes, and with the video’s 9,682,590 bytes that makes 10,002,590 before the container adds its own, over the target. The SSIM of the result against the source pattern is 0.988485.

Two-pass costs a second encode. If you do not need a size, CRF is the simpler choice: one pass, and the quality you asked for.

-movflags +faststart

An MP4 keeps its index, the moov atom, at the end of the file by default. The ffmpeg docs describe faststart as: “Run a second pass moving the index (moov atom) to the beginning of the file.” The FFmpeg wiki says this will “allow the video to begin playing before it is completely downloaded by the viewer”. It does not touch the picture or the sound, so you can apply it to a file you already have with -c copy:

# Where the index (moov) sits before and after +faststart; -c copy re-muxes, no re-encode
ffmpeg -v error -i crf23.mp4 -c copy -movflags +faststart crf23-fast.mp4
for f in crf23.mp4 crf23-fast.mp4; do
  echo "$f $(stat -c %s $f) bytes"
  ffprobe -v trace $f 2>&1 | grep -oE "type:'(ftyp|free|mdat|moov)' parent:'root' sz: [0-9]+ [0-9]+"
done
crf23.mp4 3391300 bytes
type:'ftyp' parent:'root' sz: 32 8
type:'free' parent:'root' sz: 8 40
type:'mdat' parent:'root' sz: 3385674 48
type:'moov' parent:'root' sz: 5586 3385722
crf23-fast.mp4 3391308 bytes
type:'ftyp' parent:'root' sz: 32 8
type:'moov' parent:'root' sz: 5594 40
type:'free' parent:'root' sz: 8 5634
type:'mdat' parent:'root' sz: 3385674 5642

Each line is a top-level box with its size and where it starts. Before, moov started at byte 3385722, after all the media data (mdat). After, it starts at byte 40, before the media. The media data is the same 3,385,674 bytes in both files. The file grew by 8 bytes.

-pix_fmt yuv420p

-pix_fmt sets the pixel format the encoder receives. The ffmpeg docs say: “If the selected pixel format can not be selected, ffmpeg will print a warning and select the best pixel format supported by the encoder.” With an RGB source, such as a screen capture or a rendered image, libx264 chooses on its own. Here testsrc, which is RGB (unlike testsrc2 above), is encoded with and without the option:

# An RGB source (testsrc is RGB, like a screen capture or a rendered PNG), without and
# with -pix_fmt yuv420p
ffmpeg -v error -f lavfi -i "testsrc=s=1080x1920:r=30:d=1" -c:v libx264 -threads 4 rgb-auto.mp4
ffmpeg -v error -f lavfi -i "testsrc=s=1080x1920:r=30:d=1" -c:v libx264 -threads 4 \
  -pix_fmt yuv420p rgb-420.mp4
for f in rgb-auto.mp4 rgb-420.mp4; do
  echo "$f $(ffprobe -v error -select_streams v -show_entries stream=profile,pix_fmt -of csv=p=0 $f)"
done
rgb-auto.mp4 High 4:4:4 Predictive,yuv444p
rgb-420.mp4 High,yuv420p

Without the option ffmpeg gave no warning. It wrote yuv444p, which keeps full colour resolution, and the file uses the H.264 “High 4:4:4 Predictive” profile. With it, the file is yuv420p in the High profile. The FFmpeg wiki gives the reason to care: “These players only support the YUV planar color space with 4:2:0 chroma subsampling for H.264 video.” I did not test any player for this page. If your source is already yuv420p, as testsrc2 is, the option changes nothing, so it is safe to leave it in.

What these numbers do and do not predict

The clip is a synthetic test pattern, and it compresses differently from camera footage. Most of each frame is flat colour bars, which cost almost nothing. The rest is one noisy checkerboard and a few moving lines, which cost a lot. Camera footage has sensor noise everywhere, real motion, grain and lighting changes. So:

  • The direction carries over. A lower CRF makes a bigger file with higher scores, slower presets make smaller files at the same CRF, +faststart moves the index without touching the media, and leaving out -pix_fmt lets an RGB source become 4:4:4.
  • The byte counts and ratios do not. How much CRF 28 saves over CRF 23 on your footage, or H.265 over H.264, depends on the footage. This page makes no claim about typical savings. Run the CRF loop on 10 seconds of your own video to find out.
  • SSIM and PSNR are measurements of difference, not of how the video looks. They let you compare encodes of the same clip. They do not say whether a viewer would notice.
  • Upload limits are not covered. I opened no Discord, email or YouTube page about upload size limits for this post, so it states none. The 10 MB above is an example target.

Where this fits in a Shorts pipeline

The Raspberry Pi pipeline that makes YouTube Shorts writes its final 1080x1920 file with H.264 at CRF 18, the top row of the CRF table above. When the Shorts ffmpeg command builder re-encodes a clip, it writes the same kind of line, -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p, with -movflags +faststart at the end, so both options from this page are in the command it gives you.

The last block made the picture in the CRF section above:

# The same 160x160 patch of the frame at 2 s, enlarged 2x with no smoothing:
# lossless, CRF 23, CRF 33, side by side
C="crop=160:160:640:1250,scale=320:320:flags=neighbor"
ffmpeg -v error -ss 2 -i in.mp4 -ss 2 -i crf23.mp4 -ss 2 -i crf33.mp4 -filter_complex \
  "[0:v]$C[a];[1:v]$C[b];[2:v]$C[c];[a][b][c]hstack=inputs=3" -frames:v 1 crf-crops.png
sha256sum crf-crops.png
4634fc77755066cf4f66e2e64d2f6e4f6d84dc4b5778d393c93b90a708678cea  crf-crops.png

← Back to all posts

Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.

Back to the top