Check Your Encode Didn't Wreck the Video: VMAF Scores with FFmpeg

You dropped CRF from 20 to 26 across a batch of 400 clips to cut storage, and the files got smaller. Nobody knows whether any of them got visibly worse, because nobody watched 400 clips. Eyeballing three of them and calling it fine is the step most pipelines skip.
Quick answer: Measure encode quality with FFmpeg VMAF by passing the distorted file first and the reference second: ffmpeg -i encoded.mp4 -i source.mp4 -lavfi libvmaf=log_fmt=json:log_path=vmaf.json -f null -. Both inputs must match in resolution, frame rate, and frame count, or the filter compares the wrong pairs of frames and still prints a confident-looking number. If you would rather not run an encode-and-verify loop at all, FFmpeg Micro applies fixed, tested presets through one API call so outputs land in the same quality band every time.The libvmaf command, and the input order that quietly ruins results
Order matters and FFmpeg will not tell you when you get it wrong. The libvmaf filter treats the first input as the distorted video and the second as the reference, which is backwards from how most people say it out loud ("compare my source against the encode"). Swap them and the command still runs, still prints a score, and that score is wrong.
First confirm your build even has the filter:
ffmpeg -hide_banner -filters | grep vmaf
No output means your binary was compiled without --enable-libvmaf. That is the same class of problem as a build missing libx264: nothing is broken, you just have the wrong FFmpeg. Homebrew, the John Van Sickle static builds, and most distro packages ship it; minimal Docker images usually do not.
The version you want in a script names its pads explicitly, so a future reader can see which file is which:
ffmpeg -i encoded.mp4 -i source.mov \
-lavfi "[0:v]setpts=PTS-STARTPTS,format=yuv420p[dist]; \
[1:v]setpts=PTS-STARTPTS,format=yuv420p[ref]; \
[dist][ref]libvmaf=model='version=vmaf_v0.6.1':\
n_threads=8:log_fmt=json:log_path=vmaf.json" \
-f null -
vmaf_v0.6.1 is the default model, trained for 1080p viewed at three screen heights. For 4K deliverables use version=vmaf_4k_v0.6.1. n_threads is the only speed knob that costs nothing. On an 8-core machine a 60-second 1080p pair takes roughly a minute; adding n_subsample=5 scores every fifth frame and cuts that to about a quarter, which is the right trade for batch QA and the wrong one for a codec bake-off.
FFmpeg 5.1 moved to the libvmaf v2 API and renamed the options. If you are pasting a command from an older post and getting Option 'model_path' not found, that is the split. Old builds also write a flat "VMAF score" key into the JSON, while v2 writes pooled_metrics.
What a VMAF score of 90 means when the destination is a phone
VMAF runs 0 to 100, where 100 means the encode is indistinguishable from the reference under the model's viewing conditions. The number people actually need to calibrate against is Netflix's own finding that roughly 6 VMAF points equals one just-noticeable difference. A 94 and a 91 are the same video to a viewer. A 94 and an 82 are not.
| Mean VMAF | Practical read |
|---|---|
| 95+ | Transparent. You are probably wasting bitrate. |
| 90 to 95 | Safe for any delivery, including 1080p on a desktop monitor. |
| 80 to 90 | Fine on a phone in a feed. Visible softness at full screen. |
| 70 to 80 | Artifacts a viewer will notice on flat gradients and fast motion. |
| Under 70 | Reject. Something in the preset is wrong, not marginal. |
For vertical social output, 90 on the mean is a reasonable pass line, because TikTok, Instagram, and YouTube re-encode your upload anyway and you want headroom before their transcode subtracts its own quality. Feeding a platform an 80 means the viewer sees whatever is left after two lossy passes.
Do not gate on the mean alone. libvmaf's JSON also carries min and harmonic_mean under pooled_metrics. A clip that averages 93 but bottoms out at 61 has one wrecked scene, usually a hard cut into motion where the rate control ran out of bits. The mean hides it. The minimum is the whole point of measuring.
Mismatched frame counts truncate the comparison instead of failing
The filter stops when the shorter input ends. If your encode dropped four frames, or your source has a 2-frame lead-in, VMAF scores the overlap it got and reports a clean number for a comparison that was never aligned. Every frame after the offset is compared against its neighbor, which reads as motion blur and drags the score down by five to fifteen points for no reason you can see in the file.
Check both files before you trust anything:
ffprobe -v error -count_frames -select_streams v:0 \
-show_entries stream=nb_read_frames,r_frame_rate,width,height \
-of default=nw=1 encoded.mp4
The frame rate line matters as much as the count. A 30 fps encode of a 29.97 fps source accumulates about one frame of drift every 33 seconds. At the start of a two-minute clip the images line up and the score looks great; by the end they are misaligned and the per-frame numbers fall off a cliff. If you see a VMAF curve that starts at 96 and slides steadily downward, you have a frame rate mismatch, not an encoder problem. Force both to the same rate with an fps filter before comparison, or fix the encode. Related failure: audio drifting out of sync has the same root cause.
Colorspace and resolution mismatches turn the score into noise
VMAF compares pixel values, so it has no idea that your two files describe color differently. An HDR10 reference against an SDR encode scores somewhere in the 60s even when the SDR conversion looks excellent, because half the difference the model measures is tone mapping, not compression. Tone map first, then compare the SDR encode against the SDR intermediate. The HDR to SDR conversion is the step that has to happen before measurement, not after.
Limited range (tv) versus full range (pc) is the subtler version. The luma values shift by about 16 across the board, the picture looks identical to you, and VMAF drops three to six points. Normalizing both legs with format=yuv420p in the filter chain, as in the command above, catches most of it. If the encode carries different color_range metadata than the source, fix that in the encoder flags rather than working around it in the measurement.
Resolution is the one case where a mismatch is legitimate. Comparing a 720p encode against a 1080p master is a real question, and the answer is to upscale the distorted leg to the reference resolution:
[0:v]scale=1920:1080:flags=bicubic,setpts=PTS-STARTPTS,format=yuv420p[dist];
Use bicubic and keep it consistent, because the scaler choice moves the score by a point or two on its own.
Turning a score into a pass/fail gate
A number in a log helps nobody. The point of measuring is a non-zero exit code that stops a bad transcode before it reaches publishing. Parse the JSON with jq and check both the mean and the minimum:
#!/usr/bin/env bash
set -euo pipefail
ffmpeg -v error -i "$1" -i "$2" \
-lavfi "[0:v]setpts=PTS-STARTPTS,format=yuv420p[d]; \
[1:v]setpts=PTS-STARTPTS,format=yuv420p[r]; \
[d][r]libvmaf=n_threads=8:n_subsample=5:log_fmt=json:log_path=/tmp/vmaf.json" \
-f null -
MEAN=$(jq '.pooled_metrics.vmaf.mean' /tmp/vmaf.json)
MIN=$(jq '.pooled_metrics.vmaf.min' /tmp/vmaf.json)
echo "mean=$MEAN min=$MIN"
awk -v m="$MEAN" -v n="$MIN" 'BEGIN { exit !(m >= 90 && n >= 75) }' \
|| { echo "FAIL: encode below threshold"; exit 1; }
Two thresholds, because they catch different failures. The mean catches a preset that is globally too aggressive. The minimum catches the one scene the rate control gave up on. If you also want PSNR in the same pass for comparison against older reports, add feature='name=psnr' to the filter and read pooled_metrics.psnr_y. Andreas Unterweger's ffmpeg-quality-metrics wraps this pattern in a Python CLI if you would rather not maintain the shell.
The honest framing: this whole loop exists because you are choosing your own encoder settings. Every clip gets encoded, then re-decoded twice for the comparison, which roughly doubles your compute per asset. If the reason you are tuning CRF by hand is that you're running FFmpeg on your own boxes, the alternative is to stop choosing. FFmpeg Micro's transcode presets are fixed and tested, so a given input class lands in the same quality band on every job and there is nothing to verify per file. The API docs show the request shape.
Pitfalls that produce a wrong number instead of an error
Most VMAF confusion comes from commands that succeed. These are the ones worth checking before you believe a result:
- Reference and distorted swapped. Run the same pair both ways once. If the two scores differ by more than a point, you now know which order your build treats as distorted.
- Comparing against a previous encode instead of the camera original. Quality is measured against a master. Score a re-encode against a re-encode and you are measuring generation loss you already accepted.
n_subsampleset high on a short clip. Sampling every tenth frame of a 90-frame clip gives you nine samples and a meaningless minimum.- Different container, same content, assumed identical. An MP4 rewrapped with the hvc1 tag is bit-identical video and should score 100. If it does not, your comparison chain is converting something.
- Trusting the score on a clip with hard scene cuts and no other checks. VMAF is a perceptual model, not a corruption detector. It will not flag a dropped audio stream or a green frame at the tail.
FAQ
Does the FFmpeg VMAF filter need a separate model file?
The FFmpeg VMAF filter ships with built-in models in libvmaf v2, so model='version=vmaf_v0.6.1' works with no files on disk. You only need an external .json model path for custom or older models, and on FFmpeg 5.1 and later the option is model='path=/path/to/model.json', not the deprecated model_path.
What VMAF score is good enough to publish?
A mean VMAF of 90 or above with a per-frame minimum of 75 or above is a safe publishing threshold for social and web delivery. Scores above 95 usually mean the encode is spending bitrate a viewer cannot see, which is a signal to raise CRF rather than a reason to celebrate.
Can I compare video quality before and after compression at different resolutions?
You can compare video quality before and after compression across resolutions by upscaling the compressed file back to the reference resolution inside the filter chain with scale=1920:1080:flags=bicubic. The resulting score answers "how good does this 720p file look on a 1080p screen," which is the question that matters for delivery.
Is VMAF better than PSNR or SSIM for checking encodes?
VMAF correlates better with human judgment than PSNR or SSIM because it was trained on subjective scores, which is why it catches banding and blocking that PSNR rates as fine. PSNR is still useful as a cheap sanity check, and libvmaf can compute both in one pass with feature='name=psnr'.
Why does my VMAF score drop steadily through the clip?
A VMAF score that declines steadily from the start of a clip is almost always frame rate drift, not a failing encoder. Check r_frame_rate on both files with ffprobe, because a 30 versus 29.97 mismatch misaligns the comparison a little more with every second of runtime.
If the reason you built this measurement loop is that your own encoder settings are the variable you don't trust, the cheaper fix is to remove the variable. Sign up free and run a clip through a fixed preset, then score that output against your master with the command above to see where the band lands.
About Javid Jamae
Founder & CEO at FFmpeg Micro
Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.
You might also like

Fix "Output File Is Empty, Nothing Was Encoded" in FFmpeg
An ffmpeg output file empty result is usually a warning, not an error: FFmpeg exits 0 with nothing encoded. Every cause, plus the ffprobe guard to catch it.

MediaRecorder WebM Isn't Broken. Convert It to MP4 Server Side
Chrome's MediaRecorder omits duration and cues, so blobs report Infinity. Convert MediaRecorder WebM to MP4 server side and get a seekable, indexed file.

YouTube Shorts Video Settings That Survive the Re-Encode
The YouTube Shorts video settings that survive re-encoding: 1080x1920, H.264 High, 8-15 Mbps, 48 kHz AAC, and one FFmpeg command for any source aspect ratio.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free