Skip to content

fix(engine): SDR content in HLG exports sits at reference white, not peak - #5749

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/hlg-sdr-white-reference
Oct 19, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
fix/hlg-sdr-white-reference

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

What a user can do now

Grade iPhone HLG HDR footage and export it in HDR without the bright parts blowing out. White text and other page content in an HLG export now sit at HDR reference white instead of the display's peak.

Why it failed

In an HDR export, everything the browser draws (page content, SDR clips, and graded video, since grading runs in the browser as SDR Rec.709) is converted from sRGB into the output's transfer by a lookup table. The PQ branch puts SDR white at 203 nits (ITU-R BT.2408). The HLG branch fed SDR-relative light straight into the HLG OETF. That curve's input is light relative to the HLG peak, so SDR white landed at 100% signal, and everything below it moved up as well.

A graded HLG clip is tone-mapped to SDR, graded, then converted back through that table. So any grade lifted the whole clip toward peak.

The fix

The HLG branch scales SDR-relative light by the scene light the inverse OETF gives for 75% signal, which puts SDR white at BT.2408 reference white, the same model as PQ. The reference-value generator (generate-lut-reference.py) mirrors the change, and the byte-exact probe table is regenerated from it.

Before / After

Fixture: a 1280x720 HLG clip (BT.2020, arib-std-b67, 10-bit HEVC) with flat patches at known HLG signal levels and thin 3-pixel strokes at 95% signal standing in for small bright text. Values are mean 10-bit luma code values (legal range 64 to 940).

Before (current release), default auto mode (exports HLG):

Region (source signal) Source No grade warm-daylight grade
background (25%) 284 284 431
patch (75%) 720 720 856
patch (95%) 896 896 943 (past peak)
thin strokes (95%) 894 894 942 (past peak)
white page text 943 (past peak)
white in an SDR clip 943 (past peak)

The same grade in an SDR export moves the background by 3 eight-bit steps; in the HLG export it moves by about 37.

After (this branch, same fixture, rendered by the producer in the Docker test image):

Region (source signal) Source No grade warm-daylight grade
background (25%) 284 284 253
patch (75%) 720 720 627
patch (95%) 896 896 723
thin strokes (95%) 894 894 722
white page text 724 (75%, reference white)
white in an SDR clip 724 (75%, reference white)

Ungraded HLG footage is untouched. A graded clip still passes through SDR (grading is Rec.709), so its highlights stop at reference white instead of blowing past the peak.

Tests

  • alphaBlit.test.ts: the probe table is regenerated with the generator, and sRGB 255 now maps to 49151 (75% of 65535) in HLG. The invariant test now pins both transfers below peak. Removing the reference-white scaling turns 19 of its tests red.
  • Regression goldens are unchanged on purpose. hdr-hlg-regression passes against its existing golden with this change (0 failed frames, lowest PSNR 31.2 against a floor of 28), and hdr-regression is PQ. The HLG fixture's page label does not appear in its render, so that golden holds no SDR content for this change to move; that missing label is a separate bug, fixed separately.

Not in this PR

  • An HDR clip carrying a grade still leaves the HDR range (grading is Rec.709 only): its highlights stop at reference white.
  • Studio preview is unchanged. It already grades the same tone-mapped pixels as the export; an ungraded HLG clip only looks darker there because the browser shows it through its own HDR path.

…peak

The sRGB to HLG conversion for DOM layers fed SDR-relative light straight into the HLG OETF, whose input is light relative to the HLG peak, so SDR white landed at 100% signal. Graded HLG footage (graded as SDR, then converted back) and every white overlay were pushed to peak and blew out. SDR white now sits at 75% HLG signal (BT.2408), the same model the PQ branch uses with 203 nits.
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 181 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 (0)

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review at 790a486f.

Strengths

  • alphaBlit.ts:308: the fix derives the scale from the HLG inverse OETF at 75% signal rather than hard-coding a constant. The scene light it gives is 0.26496, which is 203.15 nits on a 1000-nit display through the γ1.2 OOTF, matching BT.2408. HLG now follows the same reference-white model as the PQ branch's 203 nits.
  • The Python generator mirrors the change exactly. I ran generate-lut-reference.py --probes from this head and its output is byte-identical to the regenerated SRGB_TO_HDR_REFERENCE (sRGB 255 → 49151 = 0.75 × 65535, 128 → 27146).

Verification

  • Math: sRGB 18% grey lands at 37.8% HLG signal, which matches BT.2408's 38% grey. Reference white at 75% is 721 as a 10-bit limited-range code, consistent with the measured 724 in the body.
  • PQ and SDR paths unchanged: only the transfer === "hlg" branch of buildSrgbToSignalLut changed. Every PQ probe value in the table is identical, the srgb branch is untouched, and no encoder or service file is in the diff.
  • Colour metadata: unchanged and correct for HLG. chunkEncoder.ts:436-446 and streamingEncoder.ts:404-412 write bt2020nc / bt2020 / arib-std-b67 / tv.
  • Sibling sites: the other HLG OETF, in shaderTransitions.ts:47, round-trips pixels that are already HLG signal (signal → linear → signal). It never takes SDR-relative light, so it doesn't need the scale.
  • SDR clips: these are never in nativeHdrVideoIds (extractVideosStage.ts:437). They reach the canvas through the DOM capture and blitRgba8OverRgb48le, so the fix covers them too.
  • Tests: the byte-exact probe table plus the white, 50%-alpha, near-opaque and asymmetric-channel tests all pin 49151. The BT.2408 invariant test asserts both transfers. The goldens are unchanged, and the body explains why: the HLG fixture renders no SDR content.

Notes (non-blocking)

  1. A graded HLG clip still goes HDR → SDR (tone map) → grade → SDR → HLG, and those two conversions aren't inverses. The body's table shows warm-daylight moving the 75% patch from 720 to 627, so graded HLG clips now read darker than ungraded ones, where before they blew out. That's much better than before. A follow-up test pinning that an identity grade keeps an HLG clip within a few codes of its source would stop this drifting again.
  2. alphaBlit.ts:308 and generate-lut-reference.py:26 each compute the constant. The drift rule in the generator docstring covers that, so this is fine as it is.

CI at 790a486f: all 8 required checks are green (Build, Render on windows-latest, Semantic PR title, Test, Test: runtime contract, Tests on windows-latest, Typecheck, regression).

Verdict: APPROVE
Reasoning: The HLG mapping now matches BT.2408 reference white, which I checked numerically and confirmed with a byte-identical regenerated table. The PQ and SDR paths and the HLG colour tags are untouched, and required CI is green at this head.

— Via

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit d796846 Oct 19, 2026
75 checks passed
@miguel-heygen
miguel-heygen deleted the fix/hlg-sdr-white-reference branch October 19, 2026 15:14
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.

2 participants