Convert HDR10 to Rec. 709 SDR Using FFmpeg zscale
This guide explains how to convert 10-bit Rec. 2020 HDR10 video to
standard dynamic range (SDR) Rec. 709 using FFmpeg's zscale
filter. By combining zscale (powered by the
zimg library) with linear light tone-mapping and peak
detection, you can compress high-dynamic-range highlights into standard
dynamic range without clipping bright details or washing out colors.
Understanding the Tone-Mapping Pipeline
Directly mapping Rec. 2020 color primaries and PQ (SMPTE ST 2084) curves to Rec. 709 leads to blown-out highlights and desaturated colors.
A high-quality conversion requires three steps:
- Unpack to Linear Space: Convert the PQ transfer function into linear floating-point light while maintaining Rec. 2020 primaries.
- Tone-Map with Peak Detection: Analyze dynamic frame
peak luminance and compress luminance using an algorithm like
mobiusorhable. - Gamut and Transfer Mapping: Convert the color primaries and matrix from Rec. 2020 to Rec. 709, apply the BT.709 transfer characteristics, and dither down to standard 8-bit YUV 4:2:0.
While zscale handles high-precision color space
transforms, dynamic frame-by-frame peak detection in FFmpeg is handled
by passing linear data through the tonemap filter, or by
letting zscale tone-map against dynamic metadata.
Method
1: Dynamic Peak Detection Using zscale and
tonemap
The standard and most accurate approach to achieve automatic peak
detection is passing linear floating-point data (gbrpf32le)
between two zscale stages, utilizing FFmpeg's
tonemap filter with peak=0 (dynamic peak
detection).
ffmpeg -i input_hdr.mkv -vf \
"zscale=tin=smpte2084:min=bt2020nc:pin=bt2020:rin=limited:t=linear:m=bt2020nc:p=bt2020:r=limited, \
format=gbrpf32le, \
tonemap=tonemap=mobius:peak=0:desat=0.5, \
zscale=tin=linear:min=bt2020nc:pin=bt2020:rin=limited:t=bt709:m=bt709:p=bt709:r=limited:dither=error_diffusion, \
format=yuv420p" \
-c:v libx264 -crf 18 -preset medium -c:a copy output_sdr.mp4Breakdown of the Filter Chain:
zscale=tin=...:t=linear: Interprets the input transfer (tin) as SMPTE 2084 (PQ), the matrix (min) as BT.2020 non-constant luminance, and the primaries (pin) as BT.2020. It outputs linear light (t=linear).format=gbrpf32le: Forces the pixel format to 32-bit floating-point planar RGB. This prevents precision loss and banding during tone curve operations.tonemap=tonemap=mobius:peak=0:desat=0.5:mobius: Preserves contrast in midtones while smoothly rolling off highlights. You can replace this withhableorreinhard.peak=0: Enables automatic peak detection. FFmpeg dynamically computes the maximum signal level of the current scene instead of relying on static header metadata.desat=0.5: Applies desaturation to extreme highlights to prevent out-of-gamut clipping in SDR.
- Second
zscalepass: Receives linear light, translates the matrix and primaries from Rec. 2020 to Rec. 709, applies the standardbt709transfer curve, and applies dithering (dither=error_diffusion) to eliminate banding when moving to 8-bit. format=yuv420p: Packs the final signal into 8-bit YUV 4:2:0 for standard hardware SDR compatibility.
Method 2: All-in-One
zscale Tone Mapping
If you want zscale to handle both the tone mapping and
color space transformation in a single step, you can use its built-in
tone mapper by setting the nominal peak luminance
(npl):
ffmpeg -i input_hdr.mkv -vf \
"zscale=tin=smpte2084:min=bt2020nc:pin=bt2020:rin=limited:\
t=bt709:m=bt709:p=bt709:r=limited:\
tonemap=mobius:npl=1000, \
format=yuv420p" \
-c:v libx264 -crf 18 -preset medium -c:a copy output_sdr.mp4Key zscale Parameters:
tonemap: The mapping curve. Available options includemobius,hable,reinhard,linear, andclip.mobiusandhableprovide the most natural preservation of shadow and highlight detail.npl(Nominal Peak Luminance): Sets the expected peak brightness in nits (cd/m²). Common masterings target1000or4000. Adjust this parameter to match your source's MaxCLL (Maximum Content Light Level) if you do not use dynamic peak detection.
Verifying the Output
To verify that the color properties of your converted file correctly
reflect Rec. 709, inspect the stream metadata with
ffprobe:
ffprobe -v error -select_streams v:0 -show_entries stream=color_space,color_primaries,color_transfer output_sdr.mp4The output should report:
color_space=bt709
color_primaries=bt709
color_transfer=bt709