TL;DR
Banding arguments go in circles because everyone tests on different footage. We are going to generate a synthetic gradient, encode it four ways (naive 8-bit, 8-bit debanded, 10-bit filter graph with explicit dither, and 10-bit AV1), and build a small harness so you can run it on your own ladder. Everything runs locally with FFmpeg, no assets to download.
Written against FFmpeg 7.x/8.x. Check yours with ffmpeg -version.
1. Generate a worst-case clip 🎨
Real footage has sensor noise, and that noise dithers away banding by accident. To see the problem clearly you want a clip with none:
# 10 seconds of a slow horizontal gradient, 1080p, no noise
ffmpeg -f lavfi -i "gradients=s=1920x1080:c0=0x101020:c1=0x404860:\
type=linear:nb_colors=2:speed=0.01:d=10" \
-pix_fmt yuv420p10le -c:v ffv1 \
master.mkv
# what you should see
Output #0, matroska, to 'master.mkv':
Stream #0:0: Video: ffv1, yuv420p10le, 1920x1080, 25 fps
We keep the master lossless and 10-bit so every later step is the only lossy thing happening. Dark blues are deliberate: banding is worst in the low end, which is also where nobody looks.
💡 Tip: keep a second master made from real footage (a sunset, a studio backdrop, a fade to black). The synthetic clip finds the problem; real footage tells you whether the fix survives contact with grain.
2. Encode it four ways 🛠️
A. The naive 8-bit encode
ffmpeg -i master.mkv \
-vf "format=yuv420p" \
-c:v libx264 -profile:v high -preset medium -crf 23 \
-pix_fmt yuv420p out_a_naive.mp4
This is what most ladders do. It is the baseline.
B. Debanding in an 8-bit graph
ffmpeg -i master.mkv \
-vf "format=yuv420p,gradfun=strength=1.2:radius=16" \
-c:v libx264 -profile:v high -preset medium -crf 23 \
-pix_fmt yuv420p out_b_gradfun8.mp4
gradfun fits the gradient that should be in the flat region and dithers it back in. strength is both the maximum change per pixel and the flat-region detection threshold (range 0.51 to 64, default 1.2). radius is the neighbourhood used for the fit (range 8 to 32, default 16); larger is smoother but the filter gets more reluctant near detail.
⚠️ FFmpeg's own docs say gradfun is not recommended before lossy compression, because the compression loses the dither and the bands come back. That is exactly what this command does, on purpose, so you can see it happen.
C. 10-bit filter graph with an explicit dither down
ffmpeg -i master.mkv \
-vf "format=yuv420p10le,gradfun=strength=1.5:radius=24,\
format=yuv420p" \
-sws_dither error_diffusion \
-c:v libx264 -profile:v high -preset medium -crf 23 \
-tune grain \
-x264-params aq-mode=3:aq-strength=1.0 \
-pix_fmt yuv420p out_c_10bitchain.mp4
Three separate things are doing work here:
- The filter graph runs at 10 bits, so gradfun has somewhere to put intermediate values instead of re-quantising at every stage.
- The step back to 8 bits is deliberate, with
error_diffusiondithering, rather than an implicit truncation. -
-tune grainplus a higheraq-modestops x264 from smoothing away the low-amplitude noise you just paid for. This is where most of the win is, and it is the step people skip.
D. 10-bit AV1
ffmpeg -i master.mkv \
-vf "format=yuv420p10le" \
-c:v libsvtav1 -preset 6 -crf 32 \
-pix_fmt yuv420p10le out_d_av1_10bit.mp4
No debanding filter at all. This removes the quantisation problem instead of concealing it.
3. Compare them without squinting 📊
Eyeballing gradients is unreliable. Amplify the differences instead:
# blow up the low end so steps become obvious
ffmpeg -i out_a_naive.mp4 \
-vf "eq=contrast=6.0,format=rgb24" \
-frames:v 1 vis_a.png
for f in out_a_naive out_b_gradfun8 out_c_10bitchain out_d_av1_10bit; do
ffmpeg -y -i "$f.mp4" -vf "eq=contrast=6.0" -frames:v 1 "vis_${f}.png"
done
Then stack them for a single side-by-side:
ffmpeg -i vis_out_a_naive.png -i vis_out_b_gradfun8.png \
-i vis_out_c_10bitchain.png -i vis_out_d_av1_10bit.png \
-filter_complex "[0][1]hstack[t];[2][3]hstack[b];[t][b]vstack" \
comparison.png
And check what each one cost you:
for f in out_*.mp4; do
printf "%-28s %8s KiB\n" "$f" "$(( $(stat -c%s "$f" 2>/dev/null || stat -f%z "$f") / 1024 ))"
done
# example shape of the output (your numbers will differ)
out_a_naive.mp4 ...... KiB
out_b_gradfun8.mp4 ...... KiB
out_c_10bitchain.mp4 ...... KiB
out_d_av1_10bit.mp4 ...... KiB
I am not printing my numbers here on purpose. They depend on your FFmpeg build, your CRF, and your source, and a number copied from someone else's blog post is how bad ladder decisions get made. Run it on your own footage.
4. The 10-bit trap on the web 🚧
Reasonable instinct, wrong execution:
# DO NOT ship this to a browser audience
ffmpeg -i master.mkv -c:v libx264 -pix_fmt yuv420p10le out_high10.mp4
That produces H.264 High 10. It is a real profile and x264 will make it happily, and much of the installed base cannot hardware-decode it, if it plays at all. Mass-market H.264 means 8-bit High profile. Shipping High 10 turns a cosmetic complaint into a playback failure plus a hot phone.
| Codec | 10-bit in a mainstream profile | Safe for web delivery at 10-bit |
|---|---|---|
| H.264 / AVC | High 10 (niche) | ❌ |
| HEVC | Main 10 | ✅ where HEVC is supported |
| VP9 | Profile 2 | ✅ |
| AV1 | Main (8/10-bit) | ✅ practical open-web route |
If you already ship an AV1 rendition for bitrate reasons, making it the 10-bit one is close to free.
How much of your audience that covers is a measurement, not an assumption. One published dataset (WebCodecs Fundamentals, over a million devices, 2026) puts AV1 and HEVC decode between them at 99.73% of sessions. Treat that as their number, check it against your own player telemetry before planning a ladder around it.
5. Point the harness at your real ladder
The useful output of all this is not "which encode looked best on a synthetic gradient." It is which rung of your ladder breaks first. Loop your own ladder over the master:
# ladder.txt: one "height crf maxrate bufsize" per line
while read -r h crf maxrate bufsize; do
ffmpeg -y -i master.mkv \
-vf "scale=-2:${h},format=yuv420p" \
-c:v libx264 -preset medium -crf "$crf" \
-maxrate "$maxrate" -bufsize "$bufsize" \
-pix_fmt yuv420p "rung_${h}.mp4"
done < ladder.txt
It is almost always a lower rung: a large flat sky at a bitrate that was chosen while looking at a scene with motion in it. That rung is where your complaint came from, and it is the cheapest one to fix, usually by moving -tune grain and a higher aq-strength onto just those renditions rather than raising bitrate across the ladder.
Wrapping up
Four things to take away:
- Banding is a bit depth problem that lossy encoding exposes, not a bitrate problem.
- A debanding filter in an 8-bit graph before a lossy encode mostly gets undone.
- High bit depth in the filter graph plus an explicit dither plus grain-preserving encoder settings is the combination that survives.
- 10-bit H.264 is not a web delivery format. AV1 is.
What's next: wire the comparison step into CI so a ladder change that reintroduces banding fails the build, and add a real-footage master alongside the synthetic one. If you want to go further on the filter side, the FFmpeg filter docs cover deband (which averages detected flat pixels, detection range defaults to 16) as an alternative to gradfun, and it behaves differently enough on real grain to be worth a fifth column in your comparison.
Top comments (0)