DEV Community

Mason K
Mason K

Posted on

FFmpeg HDR to SDR tone mapping that doesn't look washed out (2026)

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 (zscale on CPU, libplacebo on 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
Enter fullscreen mode Exit fullscreen mode

HDR10 content reports something like:

color_space=bt2020nc
color_transfer=smpte2084
color_primaries=bt2020
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 library or No 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
Enter fullscreen mode Exit fullscreen mode

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, zscale will be missing. Check with ffmpeg -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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A correct SDR output reports:

color_space=bt709
color_transfer=bt709
color_primaries=bt709
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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 ffprobe HDR 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)