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.
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
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
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.
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
# 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
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
-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
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
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
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)