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:

  1. Unpack to Linear Space: Convert the PQ transfer function into linear floating-point light while maintaining Rec. 2020 primaries.
  2. Tone-Map with Peak Detection: Analyze dynamic frame peak luminance and compress luminance using an algorithm like mobius or hable.
  3. 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.mp4

Breakdown 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 with hable or reinhard.
    • 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 zscale pass: Receives linear light, translates the matrix and primaries from Rec. 2020 to Rec. 709, applies the standard bt709 transfer 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.mp4

Key zscale Parameters:

  • tonemap: The mapping curve. Available options include mobius, hable, reinhard, linear, and clip. mobius and hable provide the most natural preservation of shadow and highlight detail.
  • npl (Nominal Peak Luminance): Sets the expected peak brightness in nits (cd/m²). Common masterings target 1000 or 4000. 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.mp4

The output should report:

color_space=bt709
color_primaries=bt709
color_transfer=bt709