An 8-bit SDR-looking H.264 file can still be HLG. ffmpeg's scale/format filters reduce bit depth without converting the transfer function, and they carry the bt2020nc/arib-std-b67 tags straight through — so the output is honest HDR that looks correct in QuickTime and washed out in Chromium. Found while cutting iPhone Dolby Vision footage into social clips through a pipeline that had only ever seen bt709 sources.
The output probes as SDR and is not
$ ffprobe -v error -select_streams v:0 \
-show_entries stream=pix_fmt,color_transfer,color_primaries -of default=nw=1 out.mp4
pix_fmt=yuv420p
color_transfer=arib-std-b67
color_primaries=bt2020pix_fmt=yuv420p is the thing you glance at, and it is 8-bit, so it reads as "converted." It wasn't. Proof, rather than assumption: extract the same frame from source and output and compare channel means. They matched within ~1%.
| YAVG | UAVG | VAVG | |
|---|---|---|---|
| source (10-bit HLG, rescaled to 8-bit range) | 127.5 | 123.8 | 133.6 |
| rendered output | 128.9 | 124.2 | 133.5 |
Identical pixel data plus preserved tags means the file is legitimately 8-bit HLG. It is not mis-tagged — which is worse than mis-tagged, because nothing is inconsistent enough to error.
Why it hides
Tag-honouring players (QuickTime, Safari, iOS) apply the HLG transfer and look right. Tag-ignoring consumers read HLG code values as bt709 and show a pale, flat, low-contrast image. Anything that reviews on a phone and renders in headless Chromium therefore passes review and ships washed. And a timeline mixing one HDR clip with SDR clips is guaranteed wrong for one of the two, whichever way the renderer resolves it.
Detection is one probe: treat color_transfer in {arib-std-b67, smpte2084} as HDR. Treat absent as SDR — an untagged clip is far more likely ordinary bt709 than unmarked HDR.
Gotcha 1: homebrew-core ffmpeg has no zscale
zscale needs --enable-libzimg, which the homebrew-core ffmpeg bottle does not carry — verified on both 8.1.1 and 9.0.1, so upgrading does not fix it:
[AVFilterGraph] No such filter: 'zscale'libplacebo is the other route and is equally absent; even when present it wants a Vulkan device, which macOS lacks by default (VK_ERROR_INCOMPATIBLE_DRIVER). The colorspace filter is not a substitute — it has no HDR transfer function at all:
itrc=arib-std-b67 -> Undefined constant or missing '(' in 'arib-std-b67'
iall=bt2020-10 -> Value -2.000000 for parameter 'iall' out of range [0 - 8]The fix on macOS is the ffmpeg-full formula, which carries both and is keg-only, so it never shadows the plain binary. Resolve the tonemapping binary separately from your default one rather than putting it on PATH.
If you are stuck on a build with neither filter, a generated 33³ lut3d cube does work: swscale converts YUV→RGB using the tagged matrix but does not apply the transfer function, so lut3d receives HLG-encoded RGB and a cube can do inverse-OETF → OOTF → gamut → tonemap → bt709 OETF in one hop. It is strictly worse than zscale; keep it as an escape hatch.
Gotcha 2: -color_trc silently does nothing in ffmpeg 8+
This produced correct pixels still tagged HLG:
ffmpeg -i in.mov -vf "...tonemap chain..." \
-color_primaries bt709 -color_trc bt709 -colorspace bt709 out.mp4
# -> color_space=bt709 but color_transfer=arib-std-b67, color_primaries=bt2020Frame-side colour properties propagate through the filter graph and override those output options. Note -colorspace appeared to take while the other two didn't, which makes it look like a typo rather than a systemic issue. Pin them in the graph instead:
...,format=yuv420p,setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709Always verify tags on the output file. Never assume the flags applied.
Gotcha 3: the metrics pick the wrong tone curve
The working chain, and the constants matter:
zscale=t=linear:npl=203,tonemap=tonemap=hable:desat=0,
zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p,
setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709npl=203 is the BT.2408 diffuse-white reference. Working the HLG inverse-OETF plus OOTF by hand confirms it exactly: signal level 0.75 lands on 0.2031 of nominal peak, i.e. 203 nits. npl=1000 is HLG's true nominal peak and is the technically-correct-sounding answer; it renders muddy, because it normalizes peak to 1.0 and leaves diffuse white down at 0.2.
The trap: judged on measurements alone you pick wrong. The winning npl=203 hable output measured darker (YAVG 102.8) than the broken untonemapped one (128.9), because the whole failure mode is a washed-out image with an inflated mean. Render a labelled contact sheet of the candidates and look at it. hable held highlight detail; reinhard pushed skin orange and mobius ran hot.
Two implementation notes worth stealing
Apply the tonemap once after concat, not per trimmed range — N ranges otherwise means N redundant colour conversions. And when a fallback path builds per-segment commands, remember ffmpeg accepts a single -filter:v: if you already emit setpts for a speed change, the tonemap has to be chained into that same string, not added as a second flag.
Keep the SDR path byte-identical (return an empty filter string and the default binary) so introducing this cannot regress the 99% case. And when the capable binary is missing, warn loudly and pass through rather than failing the render — a watchable file with bad tags beats no file, as long as something says so.