DEV Community

Voor AI
Voor AI

Posted on Fully Autonomous

How to Compress a Video With FFmpeg

To compress a video with FFmpeg, re-encode it to H.264 with a constant rate factor: ffmpeg -i in.mp4 -c:v libx264 -crf 28 -preset medium -c:a aac -b:a 128k -movflags +faststart out.mp4. CRF picks the quality and lets the size float. If you need a hard size limit instead (an upload cap, an email attachment), use a two-pass encode at a computed bitrate, then measure the file and retry, because the math alone overshot in my tests.

The surprise: the "visually lossless" CRF 18 that many guides recommend made every one of my five test clips bigger than the original, by 11–30%. I measured size and SSIM for every setting below on real 6-second 1080p clips, and the code ships with 26 pytest checks.

Two charts of output size and SSIM against CRF 18 to 38 for five real LTX 2.5 clips re-encoded with libx264; CRF 18 makes every clip larger than its source

The test clips

Five public example clips from LTX 2.5 (Lightricks' video model, as published on Voor's model page): about 6.1 s each, H.264 + AAC, 25 fps, three at 1920×1080, one at 1280×720 and one portrait 1080×1920, 2.0–4.8 MB. Generated clips like these are already fairly well compressed, which is exactly the case where naive settings backfire.

ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate:format=duration,size \
  -of compact l-lighthouse-dolly.mp4
# stream|codec_name=h264|width=1920|height=1080|r_frame_rate=25/1
# stream|codec_name=aac
# format|duration=6.120000|size=2322248
Enter fullscreen mode Exit fullscreen mode

All numbers are from FFmpeg 9.0.1 with libx264, on a laptop.

1. Quality-targeted: pick a CRF

ffmpeg -i in.mp4 -c:v libx264 -preset medium -crf 28 -pix_fmt yuv420p \
  -c:a aac -b:a 128k -movflags +faststart out.mp4
Enter fullscreen mode Exit fullscreen mode

Lower CRF = higher quality and a larger file; on these clips each +5 cut the size by roughly 35–45%. Here is what each CRF did to the size (percent of the original file) and to SSIM against the original (1.0 = identical):

Clip CRF 18 CRF 23 CRF 28 CRF 33 CRF 38
lighthouse dolly 1080p 123.5% · 0.9965 76.6% · 0.9942 46.6% · 0.9904 29.3% · 0.9841 19.7% · 0.9737
lighthouse jib 1080p 130.2% · 0.9978 82.6% · 0.9963 49.1% · 0.9939 31.0% · 0.9900 20.8% · 0.9837
speaker 1080p 114.8% · 0.9972 74.5% · 0.9950 46.6% · 0.9914 30.1% · 0.9855 20.1% · 0.9764
cabin 720p 127.6% · 0.9933 73.8% · 0.9874 41.0% · 0.9730 23.6% · 0.9493 14.7% · 0.9134
dandelion portrait 110.7% · 0.9961 69.1% · 0.9927 40.1% · 0.9872 23.7% · 0.9783 14.5% · 0.9638

What this tells you:

  • CRF 18 is not a compression setting for files that are already H.264. It spends more bits than the source did, so the "compressed" file grows. Re-encoding never restores detail that is already gone.
  • CRF 23 (the libx264 default) saved 17–31%.
  • CRF 28 roughly halved every clip (40–49%) and stayed at SSIM 0.973–0.994. This is a good starting point for sharing.
  • CRF 38 is about a fifth of the size, and you can see it.

The same frame of the LTX 2.5 lighthouse dolly clip at source quality, CRF 28 and CRF 38, cropped to surf detail at 2x with file size and SSIM for each

Look at the spray: CRF 28 is hard to tell from the source at 2× zoom; CRF 38 flattens the foam texture. SSIM agrees with the eye here, and the cabin clip (lowest scores) is the one with the most fine texture per pixel.

Presets: speed, not quality

Same clip, CRF 23, only the preset changed:

Preset Size Encode time SSIM
veryfast 1.63 MB 0.66 s 0.9930
medium 1.78 MB 1.51 s 0.9942
slow 1.69 MB 2.11 s 0.9941

At a fixed CRF, a slower preset does not reliably give a smaller file: here veryfast produced the smallest file but also the lowest SSIM. The preset trades encode time for compression efficiency, so compare presets at a fixed size or quality, not a fixed CRF. For a one-off clip, medium is fine; for batch jobs, veryfast was 2.3× faster.

2. Size-targeted: two-pass to a byte budget

When the limit is "under 1 MB", compute the bitrate from the budget:

video kb/s = budget_bytes × 8 / 1000 / duration × (1 − overhead) − audio kb/s
Enter fullscreen mode Exit fullscreen mode
# 1 MB budget for a 6.12 s clip with 96 kb/s audio -> about 1,159 kb/s video
ffmpeg -y -i in.mp4 -c:v libx264 -preset medium -b:v 1159k -pass 1 -an -f mp4 /dev/null
ffmpeg -i in.mp4 -c:v libx264 -preset medium -b:v 1159k -pass 2 \
  -c:a aac -b:a 96k -movflags +faststart out.mp4
Enter fullscreen mode Exit fullscreen mode

The catch: on a 6-second clip, the 0.5 MB first try came out at 522,119 bytes, over the budget. Rate control on very short clips does not hit the average exactly, so the code measures the result and, if it is over, scales the bitrate down by the overshoot (plus 2%) and encodes again:

Budget Result Attempts Video kb/s SSIM
0.5 MB 496,676 bytes 2 498 0.9771
1 MB 980,973 bytes 2 1131 0.9896
2 MB 1,963,793 bytes 1 2413 0.9949

The test suite asserts each result is under the budget and uses at least 80% of it, so the retry can't just throw quality away.

3. Downscale when the screen is small

ffmpeg -i in.mp4 -vf "scale=-2:'min(720,ih)'" -c:v libx264 -crf 28 \
  -c:a aac -b:a 128k -movflags +faststart out-720p.mp4
Enter fullscreen mode Exit fullscreen mode

-2 keeps the aspect ratio and forces an even width (yuv420p needs even dimensions). min(720,ih) means a clip that is already smaller is never upscaled. The 1080p lighthouse clip at 720p was 899,750 bytes, and when scaled back up and compared against the original 1080p clip it still scored SSIM 0.9904.

4. +faststart for anything on the web

-movflags +faststart moves the moov index in front of the media data, so a browser can start playing before the download finishes. A plain ffmpeg -i in.mp4 -c copy out.mp4 writes moov at the end. The checker in the code walks the top-level MP4 boxes to prove which one comes first.

The code

import json, os, re, subprocess, tempfile
from pathlib import Path

def probe(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-show_format", "-show_streams",
                          "-of", "json", str(path)], check=True, capture_output=True, text=True).stdout
    d = json.loads(out)
    v = next(s for s in d["streams"] if s["codec_type"] == "video")
    a = next((s for s in d["streams"] if s["codec_type"] == "audio"), None)
    return {"duration": float(d["format"]["duration"]), "size": int(d["format"]["size"]),
            "width": int(v["width"]), "height": int(v["height"]), "vcodec": v["codec_name"],
            "pix_fmt": v.get("pix_fmt"), "acodec": a["codec_name"] if a else None}

def _scale(max_height):
    return ["-vf", f"scale=-2:'min({max_height},ih)'"] if max_height else []

def compress_crf(src, dst, crf=23, preset="medium", max_height=None, audio_kbps=128):
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(src), *_scale(max_height),
                    "-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p",
                    "-c:a", "aac", "-b:a", f"{audio_kbps}k", "-movflags", "+faststart", str(dst)],
                   check=True)
    return probe(dst)

def video_kbps_for_budget(target_bytes, duration, audio_kbps=96, overhead=0.04):
    video = target_bytes * 8 / 1000 / duration * (1 - overhead) - audio_kbps
    if video < 50:
        raise ValueError(f"budget too small: {video:.0f} kb/s left for video")
    return int(video)

def _two_pass(src, dst, vk, preset, scale, audio_kbps):
    with tempfile.TemporaryDirectory() as tmp:
        common = [*scale, "-c:v", "libx264", "-preset", preset, "-b:v", f"{vk}k",
                  "-pix_fmt", "yuv420p", "-passlogfile", str(Path(tmp) / "pass")]
        subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(src), *common,
                        "-pass", "1", "-an", "-f", "mp4", os.devnull], check=True)
        subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(src), *common,
                        "-pass", "2", "-c:a", "aac", "-b:a", f"{audio_kbps}k",
                        "-movflags", "+faststart", str(dst)], check=True)

def compress_to_size(src, dst, target_bytes, preset="medium", max_height=None,
                     audio_kbps=96, max_attempts=3):
    vk = video_kbps_for_budget(target_bytes, probe(src)["duration"], audio_kbps)
    for attempt in range(1, max_attempts + 1):
        _two_pass(src, dst, vk, preset, _scale(max_height), audio_kbps)
        info = probe(dst)
        if info["size"] <= target_bytes:
            return {**info, "video_kbps": vk, "attempts": attempt}
        vk = int(vk * target_bytes / info["size"] * 0.98)   # shrink by the overshoot + 2%
    raise RuntimeError(f"still {info['size']} > {target_bytes} after {max_attempts} attempts")

def ssim(reference, distorted):
    ref = probe(reference)
    r = subprocess.run(["ffmpeg", "-i", str(distorted), "-i", str(reference), "-lavfi",
                        f"[0:v]scale={ref['width']}:{ref['height']}:flags=bicubic[d];[d][1:v]ssim",
                        "-f", "null", "-"], capture_output=True, text=True, check=True)
    return float(re.findall(r"All:([0-9.]+)", r.stderr)[-1])

def moov_before_mdat(path):
    with open(path, "rb") as f:
        pos, total = 0, os.path.getsize(path)
        while pos < total:
            f.seek(pos); hdr = f.read(16)
            if len(hdr) < 8:
                break
            size, kind = int.from_bytes(hdr[:4], "big"), hdr[4:8].decode("latin-1")
            if size == 1:
                size = int.from_bytes(hdr[8:16], "big")
            if kind in ("moov", "mdat"):
                return kind == "moov"
            if size == 0:
                break
            pos += size
    return False
Enter fullscreen mode Exit fullscreen mode

The tests

A few of the 26 checks (pytest, run against all five clips where parametrized):

@pytest.mark.parametrize("clip", CLIPS, ids=lambda p: p.stem)
def test_crf23_is_smaller_and_still_close(clip, out):
    dst = out / f"{clip.stem}-crf23.mp4"
    info, src = compress_crf(clip, dst, crf=23), probe(clip)
    assert info["size"] < src["size"]
    assert abs(info["duration"] - src["duration"]) < 0.1
    assert (info["width"], info["height"]) == (src["width"], src["height"])
    assert ssim(clip, dst) > 0.95

@pytest.mark.parametrize("budget_mb", [0.5, 1.0, 2.0])
def test_two_pass_lands_under_budget(budget_mb, out):
    budget = int(budget_mb * 1_000_000)
    info = compress_to_size(LANDSCAPE, out / f"budget-{budget_mb}.mp4", budget)
    assert info["size"] <= budget
    assert info["size"] >= 0.80 * budget   # don't waste the budget

def test_first_pass_estimate_can_overshoot(out):
    vk = video_kbps_for_budget(500_000, probe(LANDSCAPE)["duration"], 96)
    _two_pass(LANDSCAPE, out / "one-shot.mp4", vk, "medium", [], 96)
    assert probe(out / "one-shot.mp4")["size"] > 500_000   # why the retry loop exists

def test_downscale_never_upscales(out):
    info = compress_crf(FIX / "l-cabin-i2v.mp4", out / "c.mp4", crf=26, max_height=1080)
    assert (info["width"], info["height"]) == (1280, 720)

def test_budget_math():
    # 8 MB over 60 s = 1,066.7 kb/s; minus 4% overhead and 96 kb/s audio -> 928 kb/s video
    assert video_kbps_for_budget(8_000_000, 60.0, 96) == 928
Enter fullscreen mode Exit fullscreen mode

The others check that higher CRF always means a smaller file, that SSIM drops as CRF rises, that every output is H.264 yuv420p + AAC with moov first, that a plain stream copy is detected as not faststart, and that portrait downscales keep the aspect ratio with an even width.

$ pytest -q test_compress_video.py
26 passed in 22.09s
Enter fullscreen mode Exit fullscreen mode

Checklist

  • Sharing a clip? -crf 28 -preset medium, AAC 128k, +faststart. Check the result is actually smaller.
  • Source already H.264? Don't use CRF 18 to "compress" it; it will probably grow.
  • Hard size cap? Two-pass at the computed bitrate, then measure and retry.
  • Small screen? Downscale with scale=-2:'min(720,ih)' before reaching for a higher CRF.
  • Comparing presets? Hold size or quality fixed, not CRF.

Where the clips came from

The test clips are the published examples on Voor's LTX 2.5 page. LTX 2.5 renders picture and sound in one pass, with camera presets such as dolly in and jib up, up to 4K or 20 seconds (not both); the page lists the 6-second 1080p, 25 fps dolly-in clip with audio at 272 credits on Fast. Clips straight from a generator are big and already H.264, which is exactly where the CRF 18 trap shows up. You can try LTX 2.5 on Voor and run the compression tests on your own output, or open the generator when you need a fresh test clip at a known resolution and frame rate.

This article was written with AI assistance; every number in it was measured by the code shown.

Top comments (0)