TL;DR
Converting HDR10 to SDR with a naive FFmpeg command gives you grey, washed-out video. The fix is tone mapping. We will detect HDR with
ffprobe, run two working tone-map chains (zscaleon CPU,libplaceboon GPU) in FFmpeg 8.0, compare operators, and batch it. Test the commands on your own build before shipping.📦 Code: github.com/USER/hdr-to-sdr, replace before publishing
If you have ever run an HDR clip through your normal pipeline and gotten back something flat and foggy, this post is for you. The bug is that HDR and SDR are different color systems, and "just converting" reinterprets one as the other. We will use FFmpeg 8.0 "Huffman" (8.0.2 is current as of May 2026).
Why naive conversion fails
| HDR10 | SDR | |
|---|---|---|
| Transfer function | PQ (SMPTE ST 2084) | gamma 2.4 / BT.1886 |
| Color primaries | Rec.2020 (wide) | BT.709 (narrow) |
| Peak luminance | ~1,000 to 4,000 nits | ~100 nits |
A command that ends in -pix_fmt yuv420p with no tone mapping reads PQ-encoded, Rec.2020 values as if they were SDR. The gamut gets crushed with no intelligence and the brightness curve is misread. Hence the fog.
1. Detect whether a file is even HDR 🔍
Do not tone-map SDR files. Check first:
# detect transfer characteristics and primaries
ffprobe -v error -select_streams v:0 \
-show_entries stream=color_transfer,color_primaries,color_space \
-of default=noprint_wrappers=1 input.mkv
HDR10 content reports something like:
color_space=bt2020nc
color_transfer=smpte2084
color_primaries=bt2020
If color_transfer is smpte2084 (PQ) or arib-std-b67 (HLG), you have HDR and you need to tone-map. If it says bt709, leave it alone.
2. The libplacebo path (GPU, my default) 🚀
libplacebo is the Vulkan-accelerated filter in FFmpeg 8.0. It follows the ITU tone-mapping recommendations and handles the color conversions internally, so the command is short:
ffmpeg -i input.mkv \
-vf "libplacebo=tonemapping=bt.2390:colorspace=bt709:color_primaries=bt709:color_trc=bt709:format=yuv420p" \
-c:v libx264 -crf 20 -c:a copy \
output_sdr.mp4
bt.2390 is the ITU standard operator and a safe broadcast default.
⚠️ Note: libplacebo needs a working Vulkan setup. If you get
Cannot load Vulkan libraryorNo Vulkan device found, your box is missing the runtime or a usable GPU. Fall back to the zscale path below.
3. The zscale path (CPU, runs everywhere) 🛠️
When there is no GPU, use zscale plus the tonemap filter. It is slower but portable. The chain converts to linear light, tone-maps, then converts to BT.709:
ffmpeg -i input.mkv -vf "\
zscale=transfer=linear:npl=100,\
format=gbrpf32le,\
zscale=primaries=bt709,\
tonemap=tonemap=hable:desat=0,\
zscale=transfer=bt709:matrix=bt709:range=tv,\
format=yuv420p" \
-c:v libx264 -crf 20 -c:a copy \
output_sdr.mp4
Each step matters: zscale=transfer=linear linearizes the PQ signal, tonemap does the dynamic-range compression, and the final zscale retags everything as BT.709 SDR.
💡 Tip: if your FFmpeg build lacks zimg,
zscalewill be missing. Check withffmpeg -filters | grep zscale. If it is absent, use libplacebo or install a build with--enable-libzimg.
4. The operator choice changes everything
This is the part people skip and then wonder why their output looks off. Try a few on a short sample:
| Operator | Filter | When to use |
|---|---|---|
bt.2390 |
libplacebo | ITU standard, broadcast-safe default |
hable |
zscale tonemap | filmic curve, holds shadow + highlight detail |
reinhard |
zscale tonemap | brighter output if hable is too dark |
st2094-40 |
libplacebo | HDR10+ with dynamic per-scene metadata |
Cut a 10-second test and eyeball it instead of guessing:
# 10s sample with hable
ffmpeg -ss 60 -t 10 -i input.mkv -vf "\
zscale=transfer=linear:npl=100,format=gbrpf32le,zscale=primaries=bt709,\
tonemap=tonemap=hable:desat=0,zscale=transfer=bt709:matrix=bt709:range=tv,format=yuv420p" \
-c:v libx264 -crf 20 sample_hable.mp4
Swap hable for reinhard, render again, and compare on a normal SDR monitor. There is no correct answer in the abstract; a moody trailer and a bright cooking video want different curves.
5. Dolby Vision reality check
Phone footage is full of Dolby Vision now. libplacebo handles Profile 5 and 8.x with Dolby Vision processing enabled, which covers most single-layer phone clips:
ffmpeg -i dv_input.mp4 \
-vf "libplacebo=apply_dolbyvision=true:tonemapping=bt.2390:colorspace=bt709:color_primaries=bt709:color_trc=bt709:format=yuv420p" \
-c:v libx264 -crf 20 dv_output_sdr.mp4
Profile 7 (dual-layer) is not fully supported. Detect it and route those files to a different tool rather than shipping a broken convert.
6. Batch it
#!/usr/bin/env bash
# tonemap_dir.sh: tone-map every .mkv in a folder
set -euo pipefail
for f in *.mkv; do
out="sdr_${f%.mkv}.mp4"
echo "tone-mapping $f -> $out"
ffmpeg -y -i "$f" \
-vf "libplacebo=tonemapping=bt.2390:colorspace=bt709:color_primaries=bt709:color_trc=bt709:format=yuv420p" \
-c:v libx264 -crf 20 -c:a copy "$out"
done
7. Verify the output is actually SDR ✅
Do not trust your eyes alone in CI. Probe the result and assert the tags flipped to BT.709:
ffprobe -v error -select_streams v:0 \
-show_entries stream=color_transfer,color_primaries,color_space \
-of default=noprint_wrappers=1 output_sdr.mp4
A correct SDR output reports:
color_space=bt709
color_transfer=bt709
color_primaries=bt709
If color_transfer still says smpte2084, the tone map did not take and you shipped HDR with an SDR label, which is worse than the original problem. Gate on this:
# fail the build if output is not tagged bt709
trc=$(ffprobe -v error -select_streams v:0 -show_entries stream=color_transfer \
-of default=nw=1:nk=1 output_sdr.mp4)
[ "$trc" = "bt709" ] || { echo "ERROR: output not SDR (trc=$trc)"; exit 1; }
Common errors and fixes
These are the ones you will actually hit:
| Error | Cause | Fix |
|---|---|---|
No filter named 'libplacebo' |
build without libplacebo | use the zscale chain, or get a build with --enable-libplacebo
|
Cannot load Vulkan library |
no Vulkan runtime/GPU | fall back to zscale, or install Vulkan drivers |
No filter named 'zscale' |
build without zimg | use libplacebo, or --enable-libzimg
|
| Output still looks washed out | tone map skipped or wrong tags | re-check section 7; confirm the filter chain actually ran |
| Output too dark |
hable on dark source |
switch to reinhard, or raise npl
|
| Colors oversaturated/neon | gamut not converted | ensure the final zscale=primaries=bt709 (or libplacebo color_primaries=bt709) is present |
💡 Tip: run
ffmpeg -hide_banner -filters | grep -E "libplacebo|zscale"once on each box to know which path is even available before you write the pipeline.
What's next
- Add the
ffprobeHDR check as a gate in your pipeline so SDR files skip tone mapping entirely. - Measure quality instead of vibes: render with VMAF against an SDR reference if you have one.
- Wire the Profile 7 detection into a fallback path.
- If you do high volume, benchmark libplacebo on a GPU box versus zscale on CPU and pick per workload.
Pull a clip off your own phone, run your current pipeline, and look at it on a plain monitor. If it is foggy, you just found a bug your users have been seeing for months.
Top comments (0)