HDR vs SDR
| Property | SDR (Standard Dynamic Range) | HDR (High Dynamic Range) |
|---|---|---|
| Color space | BT.709 | BT.2020 |
| Transfer function | BT.709 (gamma) | PQ (HDR10) / HLG |
| Bit depth | 8-bit | 10-bit |
| Luminance range | 100 nit | 1,000–10,000 nit |
The transfer function is the curve that turns the stored values into brightness. If you play HDR video on an SDR display without converting it, the picture looks washed out or the colors shift. Tone mapping fixes this: it squeezes HDR’s much wider brightness range into the SDR range so that the result looks natural.
Step 1 — Inspect the Input File
ffprobe -v quiet -select_streams v:0 \
-show_entries stream=color_space,color_transfer,color_primaries,pix_fmt \
-of default=noprint_wrappers=1 input.mp4
HDR10 example output:
pix_fmt=yuv420p10le
color_space=bt2020nc
color_transfer=smpte2084
color_primaries=bt2020
HLG example output:
pix_fmt=yuv420p10le
color_space=bt2020nc
color_transfer=arib-std-b67
color_primaries=bt2020
color_transfer tells you the kind of HDR: smpte2084 is HDR10 (PQ) and arib-std-b67 is HLG.
Basic Command — HDR10 → SDR (BT.709)
Using zscale (Recommended)
The zscale filter gives the best-quality conversion. It needs an FFmpeg build with libzimg.
ffmpeg -i input_hdr.mp4 \
-vf "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p" \
-c:v libx264 -crf 18 -preset slow \
-c:a copy \
output_sdr.mp4
What each step does:
zscale=t=linear:npl=100— converts PQ to linear light, where the values are proportional to real brightnessformat=gbrpf32le— switches to 32-bit floating point so the math stays precisezscale=p=bt709— converts the BT.2020 primaries (the range of colors) to BT.709tonemap=hable— applies Hable tone mapping, which compresses the highlightszscale=t=bt709:m=bt709:r=tv— applies the BT.709 gamma and matrix, and TV rangeformat=yuv420p— converts to 8-bit YUV 4:2:0, the format players expect
Tone Mapping Algorithm Comparison
# Hable (film look, recommended)
tonemap=hable
# Reinhard (keeps overall brightness)
tonemap=reinhard
# Mobius (keeps in-range colors and contrast)
tonemap=mobius
# Clip (fast, may clip highlights)
tonemap=clip
| Algorithm | Characteristics | Best for |
|---|---|---|
| hable | Keeps shadow and highlight detail better than reinhard; darkens everything slightly | When detail matters most |
| reinhard | Keeps overall brightness; flattens detail and loses color accuracy | When brightness matters most |
| mobius | Smoothly compresses out-of-range values while keeping in-range colors and contrast | When color accuracy matters most |
| clip | Fast, simple cutoff | Testing |
HLG → SDR Conversion
ffmpeg -i input_hlg.mp4 \
-vf "zscale=t=linear,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p" \
-c:v libx264 -crf 18 -preset slow \
-c:a copy \
output_sdr.mp4
This is the HDR10 command without npl=100. zscale uses 100 when npl is not set, so adding npl=100 gives the same output.
The colorspace Filter Cannot Convert HDR
The colorspace filter cannot handle the PQ or HLG transfer functions. On an HDR10 file it stops with Unsupported input transfer characteristics 16 (smpte2084). If you force the input to BT.2020 with iall=bt2020, it runs, but nothing is tone mapped and the picture comes out dull and low in contrast. If your FFmpeg has no zscale, install a build that includes libzimg.
YouTube Upload Settings
ffmpeg -i input_hdr.mp4 \
-vf "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p" \
-c:v libx264 -crf 18 -preset slow \
-pix_fmt yuv420p \
-color_primaries bt709 \
-color_trc bt709 \
-colorspace bt709 \
-c:a aac -b:a 256k \
output_youtube_sdr.mp4
The output is tagged as BT.709, so players and platforms treat it as SDR. zscale sets these tags when it converts. The three -color_* options ask for the same values; with FFmpeg 8.1 the output file is identical without them.
Batch Conversion Script
The script converts only HDR files. SDR files would go wrong: an untagged one makes zscale fail and leaves an empty output file, and one tagged BT.709 gets tone mapped and comes out darker. So the script reads color_transfer with ffprobe and skips any file that is not HDR10 (smpte2084) or HLG (arib-std-b67).
#!/bin/bash
for f in *.mp4; do
# skip anything that is not HDR10 (smpte2084) or HLG (arib-std-b67)
if ! ffprobe -v error -select_streams v:0 -show_entries stream=color_transfer \
-of default=noprint_wrappers=1:nokey=1 "$f" | grep -qE 'smpte2084|arib-std-b67'; then
echo "skip: $f"
continue
fi
ffmpeg -i "$f" \
-vf "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p" \
-c:v libx264 -crf 18 -preset slow \
-pix_fmt yuv420p \
-color_primaries bt709 -color_trc bt709 -colorspace bt709 \
-c:a copy \
"sdr_${f}"
done
Troubleshooting
No such filter: 'zscale'
Your FFmpeg was built without libzimg. Install a build that includes it:
# macOS (Homebrew's ffmpeg formula has no zimg; ffmpeg-full does)
brew install ffmpeg-full # keg-only: add it to PATH as brew info ffmpeg-full explains
# Ubuntu
sudo apt install ffmpeg
zscale stops with no path between colorspaces
A color tag of the input is unknown, so zscale does not know what to convert from. FFmpeg stops with code 3074: no path between colorspaces and leaves an empty output file. Check color_transfer, color_primaries and color_space with ffprobe (Step 1). If only color_transfer is unknown, tell the first zscale the input transfer with tin: tin=smpte2084 for HDR10, tin=arib-std-b67 for HLG. For example: zscale=tin=smpte2084:t=linear:npl=100. (t sets the output transfer, not the input.) If color_primaries or color_space is unknown too, tin is not enough. Set all three with setparams at the start of the chain: setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc,zscale=t=linear:npl=100,... (for HLG, color_trc=arib-std-b67). A wrong color_transfer gives no error, only the wrong brightness; tin fixes that too.
Video is too dark / blown out
Change npl (nominal peak luminance), or try another tone mapping method: reinhard keeps more of the overall brightness than hable. A lower npl gives a brighter picture. A higher one darkens it, which helps when the highlights are blown out (pure white with no detail).
# Brighter output: lower npl
-vf "zscale=t=linear:npl=50,..."
Output comes out as 10-bit 4:4:4
If the filter chain does not end with format=yuv420p, there is no error. FFmpeg picks yuv444p10le, and libx264 encodes it as High 4:4:4 Predictive. QuickTime and most other players cannot play that; they need 4:2:0 H.264. Always end the chain with format=yuv420p.
Frequently Asked Questions
What is the simplest HDR-to-SDR command?
Use the basic command above: ffmpeg -i hdr.mp4 -vf "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p" -c:v libx264 -crf 18 sdr.mp4. Avoid the shorter chain zscale=transfer=linear,tonemap=hable,zscale=transfer=bt709,format=yuv420p: it leaves the primaries and matrix at BT.2020.
Why does my SDR output look washed out?
It looks washed out when nothing was tone mapped (for example, after the colorspace filter) or when the primaries were left at BT.2020 (no zscale=p=bt709). Also check that the input transfer is detected correctly. If you set the peak yourself, tonemap’s peak counts in units of the first zscale’s npl. With npl=100, use peak=10 for 1000-nit content (with npl=50, peak=20). peak=1000 makes the picture darker.
Hable vs mobius vs reinhard — which tonemap?
Hable keeps detail in the shadows and highlights but makes the whole picture slightly darker. Mobius keeps the colors and contrast of everything already inside the SDR range as intact as possible. Reinhard keeps the overall brightness but flattens detail and loses color accuracy. Try hable first, and switch to mobius when color accuracy matters more than detail.
Do I need a GPU for HDR-to-SDR conversion?
No. zscale and tonemap run on the CPU. For tone mapping on the GPU, there are the tonemap_opencl (OpenCL) and libplacebo (Vulkan) filters. Builds such as the gyan.dev full build include them.
Will tonemapping affect file size?
It can, and whether the file grows or shrinks depends on the footage. To make it smaller, raise the CRF by 1–2.
Related Articles
Primary source: ffmpeg.org/ffmpeg-filters.html#zscale