Ffmpeg Quality
Context: $ARGUMENTS
Reference-vs-distorted (full-reference) objective quality metrics with ffmpeg filters. VMAF is the gold standard for perceptual quality; PSNR and SSIM are older but universally understood. All full-reference filters require the two inputs to be pixel-aligned: same resolution, same frame rate, same pixel format, same length.
Quick start
- Score a transcode vs its source (VMAF): → Step 2 (VMAF recipe)
- Quick PSNR/SSIM number: → Step 2 (PSNR or SSIM recipe)
- Find best CRF for a target VMAF: → Step 3 (rate-distortion sweep)
- Single-file QC (no reference): → Step 2 (blockdetect / blurdetect)
- Parse per-frame results: → Step 3 (JSON/log parsing)
When to use
- QA a transcoded / streamed / compressed file against the original.
- Benchmark encoders (x264 vs x265 vs svt-av1 vs vp9) on the same source.
- Pick an optimal CRF / bitrate for a target VMAF (e.g. 93 for near-transparent, 80 for "good enough" mobile).
- Validate a live encoder ladder rung-by-rung.
- Single-input block/blur QC when no reference exists (encoder output spot-check).
Step 1 — Ensure alignment (prerequisite)
Full-reference metrics (VMAF, PSNR, SSIM) require both inputs to match exactly on:
- Resolution — scale both to a target res with
scale=W:H:flags=bicubic. - Frame rate — resample with
fps=R(orminterpolatefor fractional conversions). - Pixel format — convert with
format=yuv420p(oryuv422p10le, matching the model bit-depth). - Length — pad the shorter with
tpad, or truncate with-t. - Pixel offset — if cropped, pre-crop or the metric will collapse (esp. PSNR, 1-pixel shift wrecks it).
Canonical prefilter (both upscaled to 1080p, 24 fps, yuv420p):
[0:v]scale=1920:1080:flags=bicubic,fps=24,format=yuv420p[dist];
[1:v]scale=1920:1080:flags=bicubic,fps=24,format=yuv420p[ref];
Check alignment first: ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate,pix_fmt,nb_frames <file>.
Step 2 — Pick a metric and run
VMAF (preferred; perceptual, 0–100)
Default model (vmaf_v0.6.1) — 1080p desktop viewing:
ffmpeg -i distorted.mp4 -i reference.mp4 -lavfi \
"[0:v]scale=1920:1080:flags=bicubic[dist];[1:v]scale=1920:1080:flags=bicubic[ref];[dist][ref]libvmaf=model=version=vmaf_v0.6.1:log_path=vmaf.json:log_fmt=json:n_threads=8" \
-f null -
Order matters: [distorted][reference] — distorted first.
Model selection:
vmaf_v0.6.1— default, 1080p desktop.vmaf_v0.6.1neg— "no enhancement gain"; penalizes over-sharpening. Use when encoder might cheat with unsharp masks.vmaf_4k_v0.6.1— 4K displays, larger viewing distance assumed.vmaf_float_v0.6.1— higher-precision float model.- Phone model:
libvmaf=model=version=vmaf_v0.6.1:phone_model=1(legacy flag; newer builds usemodel=path=/path/to/vmaf_v0.6.1.json).
PSNR (simple dB; sensitive to any pixel shift)
ffmpeg -i distorted.mp4 -i reference.mp4 -lavfi "[0:v][1:v]psnr=stats_file=psnr.log" -f null -
Stats file per-frame: n:X mse_avg:Y mse_y:A mse_u:B mse_v:C psnr_avg:Z psnr_y:... psnr_u:... psnr_v:....
SSIM (structural similarity, 0–1)
ffmpeg -i distorted.mp4 -i reference.mp4 -lavfi "[0:v][1:v]ssim=stats_file=ssim.log" -f null -
Stats file per-frame: n:X Y:a U:b V:c All:d (dB).
Single-input QC (no reference)
ffmpeg -i in.mp4 -vf "blockdetect=period_min=3:period_max=24:planes=7" -f null -
ffmpeg -i in.mp4 -vf "blurdetect" -f null -
Both emit per-frame metadata; higher lavfi.block / lavfi.blur means more artifacts.
Other full-reference filters
vmafmotion— motion-complexity only (one of VMAF's sub-features, isolated).identity— detects whether two streams are byte-identical.msad— Mean Sum of Absolute Differences (cheap proxy for PSNR).
Step 3 — Parse and aggregate
VMAF JSON summary (pooled mean):
jq '.pooled_metrics.vmaf.mean' vmaf.json
jq '.pooled_metrics.vmaf | {min, max, mean, harmonic_mean}' vmaf.json
jq '.frames[] | {n: .frameNum, vmaf: .metrics.vmaf}' vmaf.json | head
PSNR / SSIM averages print to stderr at the end:
[Parsed_psnr_0 @ ...] PSNR y:42.13 u:45.22 v:45.88 average:43.21
[Parsed_ssim_0 @ ...] SSIM Y:0.973 U:0.988 V:0.989 All:0.978 (16.54dB)
Rate-distortion sweep (use scripts/quality.py sweep, Step 4):
- Encode source at CRF 18, 22, 26, 30, 34.
- Compute VMAF of each encode vs source.
- Build a CRF → VMAF → filesize table; pick the knee.
Step 4 — Interpret
VMAF (0–100):
< 70— visibly degraded.70–80— watchable, noticeable compression.80–90— good; streaming ladder "main" tiers.90–95— excellent.95+— near-transparent; reserved for archive/mastering.~100— identical (within float noise).
PSNR (dB): content-dependent ceiling. Rough bands:
< 30— poor.30–35— low.35–40— moderate.40–45— high.45+— excellent / near-lossless.
SSIM (0–1):
< 0.90— visible degradation.0.90–0.95— acceptable.0.95–0.98— good.0.98+— excellent.
Per-frame dips matter more than the mean — sort frames ascending and look at the 1st percentile for worst-case scenes.
Available scripts
scripts/quality.py— subcommandsvmaf,psnr,ssim,sweep. Stdlib-only,--dry-run,--verbose.
Examples:
uv run ${CLAUDE_SKILL_DIR}/scripts/quality.py vmaf --reference ref.mp4 --distorted enc.mp4 --model vmaf_v0.6.1 --threads 8 --log-path vmaf.json
uv run ${CLAUDE_SKILL_DIR}/scripts/quality.py psnr --reference ref.mp4 --distorted enc.mp4 --stats-file psnr.log
uv run ${CLAUDE_SKILL_DIR}/scripts/quality.py ssim --reference ref.mp4 --distorted enc.mp4 --stats-file ssim.log
uv run ${CLAUDE_SKILL_DIR}/scripts/quality.py sweep --reference ref.mp4 --crfs 18,22,26,30 --encoder libx264 --preset medium
Reference docs
- Read
references/metrics.mdfor the full option tables, model catalog, scoring bands, scaling recipes, and rate-distortion methodology.
Gotchas
- Inputs must match. ffmpeg does NOT auto-align — mismatched resolution or framerate silently produces garbage scores. Always prefilter both streams with
scale+fps+format. - Input order for VMAF is
[distorted][reference](distorted first). Swapping does not crash but the result is meaningless. - PSNR and SSIM take both inputs but the meaning of "first" vs "second" is symmetric for the scalar score; the stats_file labels which is which via
[0:v]vs[1:v]. libvmafrequires ffmpeg built with--enable-libvmaf. Check withffmpeg -filters | grep vmaf. Homebrew ffmpeg generally includes it; minimal static builds often do not.- VMAF model files are distributed separately for ffmpeg < 4.4. Newer builds (>= 4.4) bundle the default model. If you see "unable to load model," download the
.jsonmodel file and usemodel=path=/absolute/path.json. n_threadsdefaults to 1 — VMAF is CPU-bound; set it tonprocor at least 4 or it crawls.- VMAF requires >= 13 frames to compute motion sub-features; very short clips return NaN.
- PSNR is brittle to 1-pixel shifts. If the distorted has been cropped/padded vs reference, PSNR collapses while SSIM / VMAF degrade more gracefully. Always align pre-crop.
- SSIM is less sensitive to brightness / contrast changes than PSNR — good for luma-graded comparisons.
vmaf_v0.6.1negis the right choice when you suspect the encoder is doing enhancement (sharpening / detail synthesis). Default model rewards sharpening,negdoes not.- Bit depth: if your source is 10-bit (yuv420p10le), use a float model and match pix_fmt; downconverting to 8-bit before scoring loses precision.
- No libvmaf available? Fall back to SSIM; SSIM correlates better with VMAF than PSNR does.
- Don't trust the mean alone. Look at 1st-percentile (harmonic mean is a reasonable proxy, and
.pooled_metrics.vmaf.harmonic_meanis in the JSON).
Examples
Example 1: VMAF a 4K encode
ffmpeg -i encoded_4k.mp4 -i source_4k.mov -lavfi \
"[0:v]scale=3840:2160:flags=bicubic,format=yuv420p[d];[1:v]scale=3840:2160:flags=bicubic,format=yuv420p[r];[d][r]libvmaf=model=version=vmaf_4k_v0.6.1:log_path=vmaf_4k.json:log_fmt=json:n_threads=16" \
-f null -
jq '.pooled_metrics.vmaf.mean' vmaf_4k.json
Example 2: Find best CRF for VMAF >= 93
uv run ${CLAUDE_SKILL_DIR}/scripts/quality.py sweep --reference source.mov --crfs 18,20,22,24,26 --encoder libx264 --preset slow
# Review the printed table; pick the highest CRF whose VMAF mean still >= 93.
Example 3: Per-frame PSNR to spot worst frames
ffmpeg -i enc.mp4 -i ref.mov -lavfi "[0:v][1:v]psnr=stats_file=psnr.log" -f null -
sort -t: -k7 -n psnr.log | head -20 # 20 worst frames by psnr_avg
Troubleshooting
Error: No such filter: 'libvmaf'
Cause: ffmpeg not built with libvmaf support.
Solution: reinstall a full build (brew reinstall ffmpeg on macOS, or build with --enable-libvmaf). Verify with ffmpeg -filters | grep vmaf.
Error: Could not parse framerate / scores look absurd
Cause: resolution or framerate mismatch between inputs.
Solution: add explicit scale=W:H,fps=R,format=yuv420p to both inputs before the metric filter.
Error: VMAF value is NaN
Cause: clip too short (< 13 frames) or all-black frames confusing motion features.
Solution: score a longer range; trim out leading/trailing black with select='gt(scene\,0.001)' or preroll.
Error: unable to load model
Cause: older ffmpeg build expecting an external .json model.
Solution: download the model from the Netflix/vmaf repo and pass model=path=/abs/path/vmaf_v0.6.1.json.
PSNR is 20 dB lower than expected
Cause: sub-pixel or 1-pixel spatial offset between inputs.
Solution: verify ffprobe dimensions on both files; ensure no crop applied to one side; rerun with matched scale.