Skip to content

fix(core): hlg footage tone-maps to sdr at the bt.2408 reference white - #7234

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/hdr-reference-white
Nov 5, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
fix/hdr-reference-white

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

What

HLG footage forced to SDR (Desktop's export, snapshot, Studio's preview proxy) now maps its mid-tones where Apple's own SDR conversion does. The tone map takes BT.2408's 203-nit reference white for a stream whose every SPS is HLG. PQ, SDR and mixed streams keep today's 100 nits.

Why

iPhone video is HLG (often with Dolby Vision 8.4 on top). Exported to SDR, it came out about 30% brighter in the mid-tones than Apple's own conversion of the same clip, so footage looked washed out next to Photos or QuickTime.

Measured on a GitHub macos-latest runner with the ffmpeg Desktop ships, against avconvert --preset PresetHighestQuality of the same clips. The score is CIEDE2000 on fixed patches, the worst patch reported:

source 100 nits (now) 203 nits
a real iPhone HLG clip with Dolby Vision 8.4 (FFmpeg's public hevc/dv84.mov sample) 12.8 1.7
the same clip with its Dolby Vision metadata stripped 12.8 1.7
ten 10-bit HLG grey steps 13.0 3.9
ten 10-bit PQ grey steps 8.8 19.5

Dolby Vision metadata changes nothing in Apple's result, so the gap is the HLG curve. On the grey steps, 50% signal was 105 from Apple and 142 from us. Other curves (mobius, reinhard, clip) and converting primaries before the tone map all scored worse or the same. PQ gets worse at 203, so it stays at 100. Its own gap to Apple is separate and not changed here.

Related work

None open on this path.

How

  • core/src/hdrToneMap.ts owns the decision: toneMapReferenceNits(keyframeTransfers) is 203 only when every transfer read is HLG, else 100. hdrToSdrToneMapFilter takes the transfers with the first frame's tags.
  • One zscale serves a whole stream, while each frame of a mixed HLG and PQ stream is tone-mapped by its own transfer. A per-stream white cannot hold for both, so a mixed stream keeps 100 and its PQ frames match a PQ-only file, as the existing tests require.
  • keyframeTransferArgs reads the transfer of every SPS, in the file header and in keyframes, without decoding a picture: -discard nokey, and for H.264 and HEVC only SPS units (filter_units), into trace_headers. A codec it cannot trace, or a failed read, gives 100 nits.
  • An SPS that leaves the transfer unspecified takes the stream's tag, as its frames do through setparams, so an HLG clip whose stream VUI says "unspecified" (only the container tags HLG) still gets 203, like its fully tagged copy.
  • It runs once per source, and only when the first frame is HLG or has no known transfer: in the engine's cached first-frame probe, snapshot's colour read and the preview proxy's. Per file rather than per extraction window keeps preview and export identical and the probe-once rule. It is also stricter: a mixed file never gets 203.
  • measure probe --normalize takes the same white from its probed transfer.

Time it adds

On the same Mac with the app's ffmpeg, a 3-minute 4K 10-bit HLG clip with a keyframe every second (180 keyframes):

read 3 min 4K 10 min (linear)
this PR: SPS only, no decode 0.09-0.12 s ~0.4 s
keyframe packets, all headers traced 0.33-0.38 s ~1.2 s
decoding each keyframe in VideoToolbox 1.7 s ~5.7 s
decoding each keyframe in software 6.3 s ~21 s

For scale, extracting a 40-second 1080p HLG clip for an --sdr export took 43.3 s on that runner. The same read on a stream that switches from HLG to PQ reported both transfers, matching what the decoded frames carry.

Size

One decision, used by every caller that tone-maps HDR to SDR.

Test plan

  • Unit tests added/updated

  • Manual testing performed

  • Documentation updated (if applicable)

  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

  • fails without the fix: with HLG's white at 100, hdrToSdrToneMapFilter on HLG grey > puts mid grey and reference white where Apple's SDR conversion does fails (ffmpeg tone-maps two 10-bit HLG greys; Apple's are 105 and 161, ours were 142 and 216), and so does the engine's real-file forced-SDR HDR extraction > matches Studio's HLG tone map. Both pass here.

  • Pinned: an all-HLG stream gets 203. HLG with PQ, HLG with BT.709, PQ alone, no reading and a failed read all keep 100. An unspecified SPS transfer follows the stream: 203 under HLG, 100 under PQ. A failed keyframe read is not cached: the next extraction reads again (reads an HLG source's keyframes again after that read failed fails without it). The engine's two real mixed HLG and PQ tests still pass unchanged: each PQ frame equals the PQ-only file's.

  • 3 runs in a row on Linux: core/src/hdrToneMap.test.ts (7), engine/.../videoFrameExtractor.test.ts and .concurrency.test.ts (235), the Studio proxy and media metadata tests (90). Once: cli snapshot and measure unit tests (187), all of core (5811) and Studio's helpers (646). With the app's own Linux ffmpeg 8.1.2, the engine's forced-SDR tests (13, the untagged-transfer HLG case included, which skips on older ffprobe) and the core tests pass. The grey test is skipped below ffmpeg 8, whose zimg puts HLG white near 174. tsc is clean for core, engine and studio-server, and so are oxfmt and oxlint on the changed files.

  • Not covered: Apple's PQ conversion (left as is), VP9 or AV1 HLG (no traced SPS transfer, so they keep 100), and a real 10-minute iPhone clip (the 10-minute figures scale the 3-minute measurement).

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 5aa9358b. No blockers.

What I checked (CI's pinned ffmpeg n8.0.1 BtbN build, sha-verified):

  • Fails without the fix: I set BT2408_HLG_REFERENCE_WHITE_NITS back to 100 and rebuilt core. hdrToneMap.test.ts > puts mid grey and reference white where Apple's SDR conversion does and the 203-nit pin test both fail. The engine's matches Studio's HLG tone map and isolates transformed cache entries fails too, with a pixel buffer mismatch. All pass at this head.
  • Suites at this head: core hdrToneMap.test.ts 7/7, engine videoFrameExtractor.test.ts 229/229, studio-server proxyTranscoder + mediaMetadata 90/90.
  • SPS trace on real files: I encoded HLG HEVC (.mov, hvc1) and HLG H.264 (.mp4) and ran keyframeTransferArgs. Each printed transfer_characteristics … = 18 once, from extradata, which parses to arib-std-b67. The decision logic is right: only all-HLG gets 203, and empty, failed or mixed reads get 100. Only an HLG first frame pays for the extra read, and the nested inExtractionSlot is safe because the first slot is released in finally before the .then runs.

Non-blocking:

  1. A failed keyframe read is cached in the engine. withKeyframeTransfers turns a failed read into keyframeTransfers: [], and that resolves the cached probe. So a transient failure pins the file to 100 nits for the rest of the process. The first-frame read deliberately drops failures from the cache. Studio's proxy re-probes on each call, so after one bad read, preview (203) and export (100) can differ, which is what this PR set out to prevent. Consider evicting the entry when !result.success, as the first read does.
  2. The grey test depends on the ffmpeg/zimg version. On Debian's ffmpeg 5.1 with the fix, white comes out at 174 against Apple's 161, so it fails the ±8 tolerance (mid grey is exact at 105). It passes on the pinned 8.0 (165). CI and the shipped binaries use 8.x, so this only affects contributors running against a system ffmpeg. A comment or a version guard would save them a confusing red test.
  3. Stacked JSDoc: /** The first shown frame's colour tags … */ now sits above withKeyframeTransfers, on top of that function's own doc. It belongs on readFirstFrameColour.
  4. Comments is red: comment-ratchet reports core/src/hdrToneMap.ts going from 6.8% to 7.4% (3 comment lines added). It isn't on main's required list.
  5. Comment numbers: the PR text and test comment say "ours were 138 and 202" at 100 nits. Those are raw limited-range luma. After the test's own full-range conversion they are about 142 and 216. That doesn't change the conclusion.

CI (CI, regression, Windows render, CodeQL) was still running when I reviewed.

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 106 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

…LG's white

An SPS with an unspecified transfer takes the stream's tag, as its frames do; a failed keyframe read is not cached; the grey test needs ffmpeg 8.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at e6c6d0c0. All five of my earlier notes are addressed, and I found no blockers.

Checked against my notes:

  1. Failed keyframe read: readKeyframeTransfers returns null on failure, and forget() drops the cached probe, the same way the first-frame read does. The new reads an HLG source's keyframes again after that read failed test pins it with { firstFrame: 2, keyframes: 2 }.
  2. Grey test on older ffmpeg: it now skips below ffmpeg 8. My Debian 5.1.9 parses as major 5, so it skips there.
  3. Stacked JSDoc: fixed; the doc is back on readFirstFrameColour.
  4. Comments: the extra doc line on ToneMapFrameColour is gone.
  5. Comment numbers: now 142 and 216.

New behaviour, tested with CI's pinned ffmpeg 8.0.1:

  • readsKeyframeTransfers now also reads keyframes when the first frame's transfer is missing or unknown. An SPS reporting transfer 2 (unspecified) now takes the stream's tag. On a real file (x265 colour description with transfer unspecified, probed arib-std-b67), the SPS read gives ['unspecified'], and the filter maps it at npl=203.
  • Suites at this head: core hdrToneMap 7/7; engine videoFrameExtractor.test.ts + .concurrency.test.ts 235/235 (the 8 + 2 * 2 count passes); studio-server proxyTranscoder + mediaMetadata 90/90.

Non-blocking:

  • SPS with no colour description: an HEVC whose SPS has no VUI colour description prints no transfer_characteristics line, so keyframeTransfers comes back [] and the file stays at 100. That holds even when the probed stream says HLG and the first frame is untagged. I couldn't get ffmpeg 8 to write a container-only colr HLG tag with -c copy to confirm such files occur in practice. If they do, treating an empty read as the stream's tag (when the probed tag is HLG) would cover them.
  • Brightness can change mid-export: with failures now uncached, a transient failure in the first-frame or keyframe read tone-maps that cut at 100. Later cuts re-probe and may get 203, so one export could change brightness between cuts. It needs a local ffmpeg header read to fail, so it's rare. It's the trade-off of the retry I suggested, and I'm noting it here rather than asking for a change.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 5ee9e92 Nov 5, 2026
143 checks passed
@miguel-heygen
miguel-heygen deleted the fix/hdr-reference-white branch November 5, 2026 13:21
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 6, 2026
…eview

Adopts or partially adopts 11 groups: multi-worker tween replay behind
the timeline stand-in (heygen-com#7231), template entries in the shared compile
stage (heygen-com#7217), HYPERFRAMES_CACHE_DIR and HYPERFRAMES_CONFIG_DIR for
every per-user cache and settings file (heygen-com#7224, heygen-com#7229; HeyGen
credentials stay under HEYGEN_CONFIG_DIR), WebKit audio clock (heygen-com#7225),
packet-based video length (heygen-com#7233), HLG tone mapping (heygen-com#7234), fetch
network cause (heygen-com#7236), .hyperframesignore in lint (heygen-com#7239), info
--media (heygen-com#7235), lint priority (heygen-com#7244), check --budget (heygen-com#7245) and the
unknown-command answer (heygen-com#7247). bun.lock gains only the lint `ignore`
edge from heygen-com#7239, already resolved. Watermark advances to 0a6746e.

Co-authored-by: Jerrai <[email protected]>
Co-authored-by: Miguel Ángel <[email protected]>
Co-authored-by: WaterrrForever <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant