diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -5,7 +5,7 @@
 
 **Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ProRes alpha final-run conformance and native macOS validation) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (HLG → SDR conversion uses BT.2446 A; no readiness estimate changed) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
 
 Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
 [docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
@@ -107,9 +107,17 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
 
 ## Progress log
 
+- **2026-10-25 (HLG → SDR):** iPhone HLG base-layer media uses BT.2446-1 Method A (1000 → 100 nits,
+  with perceptual chroma correction before the gamut matrix) for Source, automatic SDR-sequence
+  input, HLG sequence monitors and SDR export. CPU/GPU math, independent reference values and
+  procedural patch export regression coverage; existing automatic HLG conversions adopt the
+  new appearance. PQ conversion and HDR export are unchanged. SDR monitors still do not render
+  Dolby Vision dynamic metadata or HDR/EDR; no parity/readiness percentage changed (#3305).
+
 - **2026-10-22:** ProRes 4444 / XQ alpha slices include the final singleton run code required by RDD 36:2022. Six HD fixtures rejected by native macOS decoding now pass; syntax regression tests cover final runs and slice widths ([details](crates/prores/README.md#alpha-bitstream-conformance), EffectCraft #293).
 
 - **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088)
+
 - **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`).
 - **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)).
 - **2026-10-10:** ~75 community PRs merged: GPU export rendering (Auto) with Vignette, Video Limiter and Lumetri basic / creative / vignette on the GPU (#421); Linux NVDEC H.264 / HEVC decode (#549); text-to-speech narrations with Kokoro-82M (#278); Japanese for the whole interface (#449) and Ukrainian menus (#407); ALSA default-device fallback (#254, fixes #23); drop import into the timeline (#517); system appearance (#505); timecode scrub editing (#576); NVENC RGBA input (#486); marker CSV export (#454); many fixes (#526–#575).
diff --git a/contributors/people.toml b/contributors/people.toml
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -88,3 +88,6 @@ sync_github_name = false
 
 [people.webdevtodayjason]
 display_name = "Jason Brashear"
+
+[people.justacalico]
+sync_github_name = true
diff --git a/crates/color/README.md b/crates/color/README.md
--- a/crates/color/README.md
+++ b/crates/color/README.md
@@ -9,7 +9,7 @@ gamut mapping, and LUT files.
 | `lib.rs` | `Matrix`/`Transfer`/`Primaries`/`Range`/`ColorInfo` (stream metadata), Y'CbCr↔R'G'B', sRGB, PQ (ST 2084), HLG (ARIB STD-B67), HSL, hex colours |
 | `log` | camera log curves (encode/decode), their signal domains |
 | `spaces` | gamuts and 3×3 matrices (SMPTE RP 177), `ColorSpace` (Interpret Footage choices), `WorkingSpace`, `ColorPipeline` |
-| `transform` | `InputTransform` (source → working), `OutputTransform` (working → monitor / export), BT.2390 tone mapping, gamut compression |
+| `transform` | `InputTransform` (source → working), `OutputTransform` (working → monitor / export), BT.2446 A (HLG → SDR), BT.2390 (PQ → SDR), gamut compression |
 | `lut` | `.cube` (1D, 3D, shaper + 3D) and `.3dl` parse/write, tetrahedral interpolation |
 
 ## Working units
@@ -73,8 +73,24 @@ the result against the published BT.709 RGB→XYZ and BT.2087 BT.709→BT.2020 m
 
 ## Tone mapping (HDR/log → SDR)
 
-When an HDR or log source enters a Rec. 709 working space with **Auto Tone Map Media** on (the
-default), and on the monitors of an HDR sequence, FilmCraft applies the **ITU-R BT.2390 EETF**:
+**HLG → SDR** uses [ITU-R BT.2446-1 (03/2021), §4.1 Method A, tables 2–3](https://www.itu.int/pub/R-REP-BT.2446-1-2021).
+After the HLG OOTF, BT.2020 working light (203 cd/m² = 1) is normalized to the method's 1000-nit
+HDR peak. Method A maps to a 100-nit SDR peak, with its gamma-2.4 perceptual luma knee and chroma
+correction (including the red-dependent luma adjustment). Its output is decoded back to relative
+SDR linear light, then mapped from BT.2020 to the working/monitor gamut; monitors encode it as
+sRGB. The method must run **before** the gamut matrix because its luma/chroma coefficients are
+BT.2020. Negative and above-peak input components are bounded to its normalized domain, NaN
+contributes black, and reconstructed SDR super-whites survive until display encoding.
+
+Source, HLG media in an SDR sequence with **Auto Tone Map Media** on (the default), HLG sequence
+monitors and SDR exports use this same conversion. HLG 50 % maps to ≈ 0.128 SDR linear (39 % sRGB),
+and 75 % to ≈ 0.406 (67 % sRGB), instead of the former brighter BT.2390 mapping. Existing projects
+using automatic HLG → SDR conversion get the new appearance without a schema migration. Auto
+Tone Map Off, HDR working values/reference white and HDR export are unchanged. This is HLG
+base-layer processing, not Dolby Vision dynamic-metadata rendering; monitors are still SDR, and
+their appearance is not guaranteed to match QuickTime's HDR/EDR playback.
+
+**PQ → SDR** retains the **ITU-R BT.2390 EETF**:
 
 1. m = max(R, G, B) in working units, converted to cd/m² (×203) and PQ-encoded, normalised by the
    PQ value of the source peak;
@@ -82,10 +98,9 @@ default), and on the monitors of an HDR sequence, FilmCraft applies the **ITU-R
    unchanged; above it a Hermite spline maps the rest of the range onto [KS, maxLum];
 3. RGB is scaled by m'/m, which keeps hue and saturation.
 
-Source peak: the content's mastering peak for PQ when known (default 1000 cd/m²), 1000 cd/m² for
-HLG, and for log media the curve's own peak capped at 4000 cd/m² (so the knee starts above mid
-grey). Target peak: 203 cd/m² = SDR white. HDR reference white then lands at ≈ 0.79 linear (≈ 90 %
-on the sRGB curve).
+Source peak: the content's mastering peak for PQ when known (default 1000 cd/m²). Target peak:
+203 cd/m² = SDR white. PQ HDR reference white then lands at ≈ 0.79 linear (≈ 90 % on the sRGB
+curve). Camera-log conversion is unchanged.
 
 ## Grading signal (`grade`)
 
@@ -142,4 +157,9 @@ the full signal range in both file ranges), published reference points (S-Log3 9
 128/433/602, LogC3 18 % → 0.391, LogC4 18 % → 0.2784, Canon Log 2/3 18 % → 39.2 %/34.3 % IRE, Apple
 Log 18 % → 0.488, D-Log 0 → 0.0929), PQ/HLG reference white, EETF continuity/monotonicity and peak,
 hue preservation, gamut compression, BT.709/BT.2087 matrices, LUT interpolation and file round
-trips.
+trips. Method A has independent f64 reference values for neutral/coloured pixels, black/peak,
+hostile values, ramp monotonicity and sub-code knee steps; direct HLG → SDR agrees with HLG →
+HDR working → SDR monitoring. `filmcraft-gpu` compares limited/full-range HLG CPU/GPU outputs
+within two 8-bit codes, including prepared uploads. `filmcraft-export --test hlg_sdr` checks
+procedural 10-bit patches through Source, Program, layer plans, PNG (pixel-exact) and H.264 /
+ProRes SDR exports (patch interiors within three codes; BT.709 signalling; ffmpeg decode).
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -20,7 +20,7 @@ pub use grade::GradeSpace;
 pub use log::LogCurve;
 pub use lut::{Lut, Lut1d, Lut3d, LutFormat};
 pub use spaces::{ColorPipeline, ColorSpace, Curve, Gamut, WorkingSpace};
-pub use transform::{DecodeTable, GpuCurve, GpuInputParams, InputTransform, OutputTransform, REFERENCE_WHITE_NITS, ToneMap};
+pub use transform::{DecodeTable, GpuCurve, GpuInputParams, GpuToneMap, InputTransform, OutputTransform, REFERENCE_WHITE_NITS, ToneMap};
 
 /// YUV ↔ RGB matrix coefficients.
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
diff --git a/crates/color/src/transform.rs b/crates/color/src/transform.rs
--- a/crates/color/src/transform.rs
+++ b/crates/color/src/transform.rs
@@ -6,8 +6,8 @@
 //! simply values above 1.0. Log media decodes to scene-linear reflectance (18 % grey = 0.18).
 //!
 //! **Input** ([`InputTransform`]): signal → per-channel decode table (curve) → HLG OOTF (BT.2100,
-//! 1000 cd/m² nominal peak, γ = 1.2) → 3×3 gamut matrix into the working primaries → tone mapping
-//! (only when an HDR or log source enters an SDR working space and Auto Tone Map is on) → gamut
+//! 1000 cd/m² nominal peak, γ = 1.2) → HLG-to-SDR tone mapping → 3×3 gamut matrix into the working
+//! primaries → PQ tone mapping (when HDR enters SDR and Auto Tone Map is on) → gamut
 //! compression (only when the source gamut is wider than the working gamut).
 //!
 //! **Output** ([`OutputTransform`]): working linear → display-referred SDR BT.709 for monitors
@@ -16,8 +16,10 @@
 //! just enough to fit ([`desaturate_into_gamut`]), so in-gamut colours (e.g. BT.709 content in a
 //! BT.2020 working space) come back exactly.
 //!
-//! **Tone mapping** is the ITU-R BT.2390 EETF (Hermite knee in the PQ domain) applied to
-//! max(R,G,B) and used as a ratio on RGB, so hue and saturation are preserved. **Gamut
+//! **Tone mapping** uses ITU-R BT.2446-1 Method A for HLG → SDR (1000 → 100 cd/m²,
+//! including its perceptual chroma correction, in BT.2020 before the gamut matrix).
+//! PQ uses the ITU-R BT.2390 EETF (Hermite knee in the PQ domain) applied to max(R,G,B).
+//! **Gamut
 //! compression** pulls out-of-gamut colours (negative components after the matrix) towards the
 //! achromatic axis with a per-channel distance compression after the ACES Reference Gamut
 //! Compression (threshold 0.8, limit 1.25, power 1.2); colours inside 80 % of the gamut boundary
@@ -160,6 +162,65 @@ impl ToneMap {
     }
 }
 
+/// ITU-R BT.2446-1 (03/2021), §4.1 tables 2–3: 1000-nit BT.2020 HDR display light
+/// → 100-nit BT.2020 SDR display light. Input uses HDR working units (203 nits = 1);
+/// output uses relative SDR linear light (SDR peak = 1), ready for a gamut matrix and
+/// sRGB monitor encoding. The method's intermediate R'G'B' uses gamma 2.4, not sRGB.
+/// Negative/above-peak HDR components are bounded to the method's normalized domain;
+/// NaN contributes black. Reconstructed SDR super-whites survive until display encoding.
+#[inline]
+pub fn bt2446a(c: [f32; 3]) -> [f32; 3] {
+    let rgb = c.map(|v| {
+        let normalized = if v.is_nan() { 0.0 } else { v * (REFERENCE_WHITE_NITS / HDR_PEAK_NITS) as f32 };
+        normalized.clamp(0.0, 1.0).powf(1.0 / 2.4)
+    });
+    let y = 0.2627 * rgb[0] + 0.6780 * rgb[1] + 0.0593 * rgb[2];
+    if y <= 0.0 {
+        return [0.0; 3];
+    }
+    // rho = 1 + 32 * (peak / 10000)^(1/2.4), at peaks 1000 and 100 cd/m².
+    // Rounded f32 constants also used by the shader; no per-pixel peak exponentiation.
+    let yp = (1.0 + 12.259_798 * y).ln() / 2.584_736_8;
+    let yc = if yp <= 0.7399 {
+        1.0770 * yp
+    } else if yp < 0.9909 {
+        -1.1510 * yp * yp + 2.7811 * yp - 0.6302
+    } else {
+        0.5 * yp + 0.5
+    };
+    let sdr = ((yc * 1.739_932_3).exp() - 1.0) / 4.696_958;
+    let scale = sdr / (1.1 * y);
+    let cb = scale * (rgb[2] - y) / 1.8814;
+    let cr = scale * (rgb[0] - y) / 1.4746;
+    let luma = sdr - (0.1 * cr).max(0.0);
+    let r = luma + 1.4746 * cr;
+    let b = luma + 1.8814 * cb;
+    let g = (luma - 0.2627 * r - 0.0593 * b) / 0.6780;
+    [r, g, b].map(|v| v.max(0.0).powf(2.4))
+}
+
+#[derive(Clone, Debug)]
+enum SdrToneMap {
+    Bt2390(ToneMap),
+    Bt2446A,
+}
+
+impl SdrToneMap {
+    fn apply(&self, c: [f32; 3]) -> [f32; 3] {
+        match self {
+            Self::Bt2390(t) => t.apply(c),
+            Self::Bt2446A => bt2446a(c),
+        }
+    }
+
+    fn gpu_params(&self) -> GpuToneMap {
+        match self {
+            Self::Bt2390(t) => GpuToneMap::Bt2390 { source_peak_nits: t.src_peak_nits as f32, target_peak_nits: t.dst_peak_nits as f32 },
+            Self::Bt2446A => GpuToneMap::Bt2446A,
+        }
+    }
+}
+
 /// Compress out-of-gamut colours towards the achromatic axis (see module docs). Used on input,
 /// where camera gamuts extend well beyond the working gamut.
 #[inline]
@@ -193,7 +254,7 @@ pub struct InputTransform {
     identity_matrix: bool,
     /// HLG OOTF luma weights (BT.2020) and the scale to working units.
     hlg: Option<([f32; 3], f32)>,
-    tone: Option<ToneMap>,
+    tone: Option<SdrToneMap>,
     compress: bool,
     /// Display-referred wide-gamut media (BT.2020 / P3 / HDR): desaturate into the working gamut
     /// with these luma weights instead of the camera-gamut compression.
@@ -217,7 +278,8 @@ impl InputTransform {
             Curve::Log(c) => (c.peak_linear() * REFERENCE_WHITE_NITS).clamp(REFERENCE_WHITE_NITS, LOG_PEAK_NITS),
             _ => REFERENCE_WHITE_NITS,
         };
-        let tone = (src.is_hdr() && !pipe.working.is_hdr() && pipe.auto_tone_map).then(|| ToneMap::new(peak, REFERENCE_WHITE_NITS));
+        let tone = (src.is_hdr() && !pipe.working.is_hdr() && pipe.auto_tone_map)
+            .then(|| if curve == Curve::Hlg { SdrToneMap::Bt2446A } else { SdrToneMap::Bt2390(ToneMap::new(peak, REFERENCE_WHITE_NITS)) });
         let wider = !identity_matrix && !gamut_within(src.gamut(), wg);
         // camera gamuts (log media) get the soft compression; display gamuts keep their in-gamut
         // colours exactly (BT.709 content in a BT.2020 file comes back unchanged)
@@ -240,10 +302,14 @@ impl InputTransform {
             let g = k * ys.powf((HLG_GAMMA - 1.0) as f32);
             c = c.map(|v| v * g);
         }
+        // Method A's luma/chroma coefficients are BT.2020: run before the gamut matrix.
+        if matches!(self.tone, Some(SdrToneMap::Bt2446A)) {
+            c = bt2446a(c);
+        }
         if !self.identity_matrix {
             c = crate::spaces::apply3(&self.matrix, c);
         }
-        if let Some(t) = &self.tone {
+        if let Some(SdrToneMap::Bt2390(t)) = &self.tone {
             c = t.apply(c);
         }
         if self.compress {
@@ -260,8 +326,8 @@ impl InputTransform {
         self.apply(rgb.map(|v| self.table.lookup(v)))
     }
 
-    /// The transform as plain numbers for a shader, which evaluates the curve and the BT.2390
-    /// EETF directly instead of sampling the tables. `None` for what a shader does not do: log
+    /// The transform as plain numbers for a shader, which evaluates the curve and the tone
+    /// mapping directly instead of sampling the tables. `None` for what a shader does not do: log
     /// curves and the camera-gamut compression.
     pub fn gpu_params(&self) -> Option<GpuInputParams> {
         let curve = match self.source.curve() {
@@ -278,7 +344,7 @@ impl InputTransform {
             curve,
             hlg: self.hlg,
             matrix: (!self.identity_matrix).then_some(self.matrix),
-            tone: self.tone.as_ref().map(|t| (t.src_peak_nits as f32, t.dst_peak_nits as f32)),
+            tone: self.tone.as_ref().map(SdrToneMap::gpu_params),
             desaturate: self.desaturate,
         })
     }
@@ -294,6 +360,18 @@ pub enum GpuCurve {
     Hlg,
 }
 
+/// Tone-mapping method and its stage in the colour-managed shader. Method A runs in
+/// BT.2020 before the gamut matrix; BT.2390 retains its existing post-matrix stage.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum GpuToneMap {
+    Bt2390 {
+        source_peak_nits: f32,
+        target_peak_nits: f32,
+    },
+    /// Fixed 1000-nit HDR → 100-nit SDR, BT.2446-1 §4.1.
+    Bt2446A,
+}
+
 /// [`InputTransform`] for a shader ([`InputTransform::gpu_params`]), stage by stage as
 /// [`InputTransform::apply`] runs them.
 #[derive(Clone, Copy, Debug, PartialEq)]
@@ -303,8 +381,7 @@ pub struct GpuInputParams {
     pub hlg: Option<([f32; 3], f32)>,
     /// Into the working primaries (row-major), when they differ.
     pub matrix: Option<[[f32; 3]; 3]>,
-    /// BT.2390 tone mapping (source peak, target peak) in cd/m², on max(R,G,B) as a ratio.
-    pub tone: Option<(f32, f32)>,
+    pub tone: Option<GpuToneMap>,
     /// Luma weights to desaturate into the working gamut with ([`desaturate_into_gamut`]).
     pub desaturate: Option<[f32; 3]>,
 }
@@ -337,7 +414,7 @@ pub struct OutputTransform {
     pub target: ColorSpace,
     matrix: [[f32; 3]; 3],
     identity_matrix: bool,
-    tone: Option<ToneMap>,
+    tone: Option<SdrToneMap>,
     /// Target luma weights when the working gamut is wider than the target's.
     compress: Option<[f32; 3]>,
 }
@@ -351,7 +428,13 @@ impl OutputTransform {
     pub fn new(pipe: &ColorPipeline, target: ColorSpace) -> Self {
         let wg = pipe.working_gamut();
         let tg = target.gamut();
-        let tone = (pipe.working.is_hdr() && !target.is_hdr()).then(|| ToneMap::new(HDR_PEAK_NITS, REFERENCE_WHITE_NITS));
+        let tone = (pipe.working.is_hdr() && !target.is_hdr()).then(|| {
+            if pipe.working == crate::WorkingSpace::Rec2100Hlg {
+                SdrToneMap::Bt2446A
+            } else {
+                SdrToneMap::Bt2390(ToneMap::new(HDR_PEAK_NITS, REFERENCE_WHITE_NITS))
+            }
+        });
         let l = tg.luma();
         let compress = (!gamut_within(wg, tg)).then(|| [l[0] as f32, l[1] as f32, l[2] as f32]);
         OutputTransform { target, matrix: to_f32(&gamut_matrix(wg, tg)), identity_matrix: wg == tg, tone, compress }
@@ -449,6 +532,79 @@ mod tests {
         assert!((o.encode([1.0; 3])[1] as f64 - pq203).abs() < 1e-4);
     }
 
+    #[test]
+    fn hlg_sdr_matches_bt2446a_reference_values() {
+        // Independent double-precision evaluation of BT.2446-1 §4.1 tables 2–3,
+        // after the BT.2100 HLG OOTF (1000 nits, gamma 1.2). These are SDR linear,
+        // not the HLG signal or the HDR working values (203 nits = 1).
+        let t = InputTransform::new(ColorSpace::Rec2100Hlg, Range::Full, &ColorPipeline::REC709, None);
+        for (signal, expected) in [(0.0, 0.0), (0.25, 0.03021365), (0.5, 0.12797941), (0.75, 0.40618720), (1.0, 1.0)] {
+            let got = t.convert([signal; 3]);
+            assert!(got.iter().all(|v| (f64::from(*v) - expected).abs() < 2e-5), "HLG {signal}: {got:?}, expected {expected}");
+        }
+    }
+
+    #[test]
+    fn bt2446a_colour_correction_and_hostile_values() {
+        // Independent f64 table 2–3 evaluation, in BT.2020 before gamut mapping.
+        // Coloured triples catch missing chroma scaling / red-luma adjustment.
+        let cases: [([f32; 3], [f64; 3]); 8] = [
+            ([0.01; 3], [0.007405422; 3]),
+            ([0.18; 3], [0.09680955; 3]),
+            ([1.0; 3], [0.40595344; 3]),
+            ([0.7, 0.2, 0.05], [0.31282763, 0.09613481, 0.02748413]),
+            ([1.0, 2.0, 3.0], [0.36275853, 0.67754957, 0.98389431]),
+            ([1000.0 / 203.0, 0.0, 0.0], [1.9606590, 0.0, 0.0]),
+            ([0.0, 1000.0 / 203.0, 0.0], [0.002021509, 1.5104287, 0.002021509]),
+            ([0.0, 0.0, 1000.0 / 203.0], [0.00001368824, 0.00001368824, 3.0702865]),
+        ];
+        for (input, expected) in cases {
+            let got = bt2446a(input);
+            assert!(got.iter().zip(expected).all(|(g, e)| (f64::from(*g) - e).abs() < 4e-6), "{input:?}: {got:?} != {expected:?}");
+        }
+        assert_eq!(bt2446a([0.0; 3]), [0.0; 3]);
+        assert_eq!(bt2446a([-1.0, f32::NEG_INFINITY, f32::NAN]), [0.0; 3]);
+        let peak = bt2446a([1000.0 / 203.0; 3]);
+        assert_eq!(bt2446a([f32::INFINITY, f32::MAX, 100.0]), peak);
+        for c in [[f32::NAN, 1.0, 2.0], [f32::MIN_POSITIVE; 3], [f32::MAX, -f32::MAX, 1.0]] {
+            let got = std::panic::catch_unwind(|| bt2446a(c)).expect("hostile pixels never panic");
+            assert!(got.iter().all(|v| v.is_finite() && *v >= 0.0), "{c:?}: {got:?}");
+        }
+        let mut previous = 0.0;
+        for code in 0..=4096 {
+            let c = code as f32 / 4096.0 * (1000.0 / 203.0);
+            let grey = bt2446a([c; 3]);
+            assert!(grey.iter().all(|v| v.is_finite() && *v >= previous - 2e-5 && *v <= 1.00001), "{code}: {grey:?}");
+            assert!((grey[0] - grey[1]).abs() < 1e-6 && (grey[1] - grey[2]).abs() < 1e-6, "neutral tint: {grey:?}");
+            previous = grey[0];
+        }
+        // The report's rounded knee coefficients introduce a small step. At either
+        // boundary it must stay below half an 8-bit display code (not a visible jump).
+        for yp in [0.7399f32, 0.9909] {
+            let linear = |v: f32| (((v * 2.5847368).exp() - 1.0) / 12.259798).powf(2.4) * (1000.0 / 203.0);
+            let a = bt2446a([linear(yp - 1e-6); 3])[0];
+            let b = bt2446a([linear(yp + 1e-6); 3])[0];
+            assert!((linear_to_srgb(a) - linear_to_srgb(b)).abs() * 255.0 < 0.5, "knee {yp}: {a} {b}");
+        }
+    }
+
+    #[test]
+    fn hlg_sdr_input_and_hdr_monitor_agree_without_double_mapping() {
+        let pipe = hdr(WorkingSpace::Rec2100Hlg);
+        let direct = InputTransform::new(ColorSpace::Rec2100Hlg, Range::Limited, &ColorPipeline::REC709, None);
+        let into_hdr = InputTransform::new(ColorSpace::Rec2100Hlg, Range::Limited, &pipe, None);
+        let display = OutputTransform::display(&pipe);
+        for rgb in [[0.0; 3], [0.5; 3], [0.75; 3], [1.0; 3], [0.8, 0.4, 0.2], [0.2, 0.9, 0.4]] {
+            let a = direct.convert(rgb);
+            let b = display.apply(into_hdr.convert(rgb));
+            assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 2e-5), "{rgb:?}: direct {a:?}, HDR monitor {b:?}");
+        }
+        let manual = ColorPipeline { auto_tone_map: false, ..ColorPipeline::REC709 };
+        let t = InputTransform::new(ColorSpace::Rec2100Hlg, Range::Full, &manual, None);
+        assert!(t.convert([0.75; 3])[0] > 0.99, "Auto Tone Map Off keeps HDR reference white");
+        assert!(into_hdr.convert([1.0; 3])[0] > 4.9, "working/HDR output retains highlights");
+    }
+
     #[test]
     fn eetf_properties() {
         // identity below the knee, continuous, monotone, hits the target peak
@@ -517,9 +673,12 @@ mod tests {
         let hlg = p(ColorSpace::Rec2100Hlg).unwrap();
         assert_eq!(hlg.curve, GpuCurve::Hlg);
         assert!(hlg.hlg.is_some() && hlg.matrix.is_some() && hlg.desaturate.is_some());
-        assert_eq!(hlg.tone, Some((HDR_PEAK_NITS as f32, REFERENCE_WHITE_NITS as f32)));
+        assert_eq!(hlg.tone, Some(GpuToneMap::Bt2446A));
         let pq = InputTransform::new(ColorSpace::Rec2100Pq, Range::Limited, &ColorPipeline::REC709, Some(4000.0)).gpu_params().unwrap();
-        assert_eq!((pq.curve, pq.hlg, pq.tone), (GpuCurve::Pq, None, Some((4000.0, REFERENCE_WHITE_NITS as f32))));
+        assert_eq!(
+            (pq.curve, pq.hlg, pq.tone),
+            (GpuCurve::Pq, None, Some(GpuToneMap::Bt2390 { source_peak_nits: 4000.0, target_peak_nits: REFERENCE_WHITE_NITS as f32 }))
+        );
         let sdr2020 = p(ColorSpace::Rec2020).unwrap();
         assert_eq!((sdr2020.curve, sdr2020.tone), (GpuCurve::Sdr, None));
         assert!(sdr2020.matrix.is_some() && sdr2020.desaturate.is_some());
diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs
--- a/crates/export/src/tests.rs
+++ b/crates/export/src/tests.rs
@@ -325,9 +325,25 @@ fn hdr_exports_signal_pq_and_hlg() {
         }
         // 709 red matte → BT.2020 HDR → decoded back into Rec. 709 (tone mapped) stays red
         let f = src.video_frame(FrameRequest::full(Tick::ZERO)).unwrap();
+        // The SDR policy can change while HDR export must retain the original working
+        // light. BT.2087 maps linear BT.709 red to this BT.2020 triple; allow the HDR
+        // transfer's 8-bit quantization for H.264 (ProRes is 10-bit).
+        let working = filmcraft_render::colorman::decode(&Project::new("x"), ItemId(0), &f, 1, &ColorPipeline { working: ws, ..ColorPipeline::REC709 });
+        let hdr_rgb = filmcraft_render::Image::unpremul(working.get(160, 90));
+        for (got, expected) in hdr_rgb[..3].iter().zip([0.6274, 0.0691, 0.0164]) {
+            assert!((got - expected).abs() < 0.04, "{path}: HDR working light changed: {hdr_rgb:?}");
+        }
         let back = filmcraft_render::colorman::decode(&Project::new("x"), ItemId(0), &f, 1, &ColorPipeline::REC709);
         let c = filmcraft_render::Image::unpremul(back.get(160, 90));
-        assert!(c[0] > 0.6 && c[1] < 0.05 && c[2] < 0.05, "{path}: {c:?}");
+        if transfer == Transfer::Hlg {
+            // HLG now uses the same pre-matrix Method A on input and output.
+            // PQ retains the legacy pre/post-matrix BT.2390 behaviour.
+            let preview = filmcraft_render::render_sequence(&p, seq, Tick::ZERO, Default::default(), &m);
+            let expected = filmcraft_render::Image::unpremul(preview.get(160, 90));
+            assert!(c[..3].iter().zip(&expected[..3]).all(|(a, b)| (a - b).abs() < 0.05), "{path}: SDR round trip {c:?}, monitor {expected:?}");
+        }
+        let minimum_red = if transfer == Transfer::Hlg { 0.3 } else { 0.6 };
+        assert!(c[0] > minimum_red && c[1] < 0.05 && c[2] < 0.05, "{path}: {c:?}");
         // ffprobe agrees
         let Some(ffprobe) = filmcraft_testkit::ffprobe_or_skip("hdr export signalling") else { continue };
         let out = std::process::Command::new(&ffprobe)
diff --git a/crates/export/tests/hlg_sdr.rs b/crates/export/tests/hlg_sdr.rs
new file mode 100644
--- /dev/null
+++ b/crates/export/tests/hlg_sdr.rs
@@ -0,0 +1,172 @@
+//! HLG → SDR is one conversion for Source, Program, the layer plan and exported files.
+//! Procedural 10-bit patches only; no phone recordings or reference images are shipped.
+
+use std::sync::Arc;
+
+use filmcraft_color::{ColorInfo, ColorPipeline, Matrix, Primaries, Range, Transfer, WorkingSpace};
+use filmcraft_export::{ExportSettings, Format, GpuRendering, Progress, export, rgbf_to_yuv420_10};
+use filmcraft_frame::{AudioBuffer, Chroma, PixelData, VideoFrame};
+use filmcraft_media::generators::GeneratorSource;
+use filmcraft_media::{FrameRequest, Generator, MediaInfo, MediaSource};
+use filmcraft_project::{ItemId, ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind};
+use filmcraft_render::{RenderOptions, SourceMap, render_item, render_sequence};
+use filmcraft_time::{FrameRate, Tick, TimeRange};
+
+const W: u32 = 256;
+const H: u32 = 32;
+
+struct HlgPatches {
+    info: MediaInfo,
+    frame: Arc<VideoFrame>,
+}
+
+impl MediaSource for HlgPatches {
+    fn info(&self) -> &MediaInfo {
+        &self.info
+    }
+
+    fn video_frame(&self, _req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
+        Ok(self.frame.clone())
+    }
+
+    fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
+        Err(filmcraft_media::MediaError::NoStream("audio"))
+    }
+}
+
+fn project() -> (Project, ItemId, ItemId, SourceMap) {
+    let rate = FrameRate::FPS_24;
+    let duration = rate.tick_of(3);
+    let patches = [[0.0; 3], [0.25; 3], [0.5; 3], [0.75; 3], [1.0; 3], [0.8, 0.4, 0.2], [0.2, 0.9, 0.4], [0.2, 0.4, 0.8]];
+    let rgb: Vec<f32> = (0..W * H).flat_map(|i| patches[(i % W / 32) as usize]).collect();
+    let (mut y, mut cb, mut cr) = (Vec::new(), Vec::new(), Vec::new());
+    rgbf_to_yuv420_10(&rgb, W as usize, H as usize, 0.2627, 0.0593, &mut y, &mut cb, &mut cr).unwrap();
+    let color = ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Hlg, primaries: Primaries::Bt2020, range: Range::Limited };
+    let frame = Arc::new(VideoFrame {
+        width: W,
+        height: H,
+        data: PixelData::Yuv16 { planes: [Arc::new(y), Arc::new(cb), Arc::new(cr)], bits: 10, chroma: Chroma::C420, alpha: None },
+        color,
+        par: (1, 1),
+        pts: Tick::ZERO,
+    });
+    let generator = Generator::BlackVideo;
+    let mut info = GeneratorSource::new(generator.clone(), W, H, rate, duration).info().clone();
+    info.audio_streams.clear();
+    let v = info.video.as_mut().unwrap();
+    v.color = color;
+    v.pixel_format = "YUV 4:2:0 10-bit".into();
+    let mut p = Project::new("HLG SDR regression");
+    let item = p.add_item(
+        "HLG patches",
+        Label::Iris,
+        ItemKind::Media(MediaClip {
+            media: MediaRef::Generator(generator),
+            info: info.clone(),
+            interpret: Default::default(),
+            mark_in: None,
+            mark_out: None,
+            markers: vec![],
+            offline: false,
+            proxy: None,
+            identity: None,
+        }),
+        None,
+    );
+    let seq = p.new_sequence("SDR", SequenceSettings { width: W, height: H, frame_rate: rate, ..Default::default() }, 1, 0, None);
+    let clip = p.make_track_item(item, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, duration), rate).unwrap();
+    p.sequence_mut(seq).unwrap().video_tracks[0].items.push(clip);
+    let mut map = SourceMap::default();
+    map.0.insert(item, Arc::new(HlgPatches { info, frame }));
+    (p, item, seq, map)
+}
+
+fn tmp(name: &str) -> String {
+    let dir = std::env::temp_dir().join(format!("fc-hlg-sdr-{}", std::process::id()));
+    std::fs::create_dir_all(&dir).unwrap();
+    dir.join(name).to_string_lossy().into_owned()
+}
+
+#[test]
+fn source_program_layer_plan_and_sdr_exports_agree() {
+    let (mut p, item, seq, map) = project();
+    let source = render_item(&p, item, Tick::ZERO, 1.0, &map).unwrap().over_black_rgba8();
+    let program = render_sequence(&p, seq, Tick::ZERO, RenderOptions::default(), &map).over_black_rgba8();
+    assert_eq!(source, program, "Source and Program: one SDR transform");
+    // HLG 50% / 75% map to ~39% / 67% sRGB (BT.2446 A), rather than ~54% / 90%.
+    assert!(source[(16 * W as usize + 80) * 4].abs_diff(100) <= 1);
+    assert!(source[(16 * W as usize + 112) * 4].abs_diff(171) <= 1);
+    let plan = filmcraft_render::plan::plan_frame(&p, seq, Tick::ZERO, RenderOptions::default(), &map);
+    let filmcraft_render::plan::FramePlan::Layers { layers, .. } = &plan else { panic!("HLG keeps the GPU layer path") };
+    assert!(layers[0].input.is_some());
+    assert_eq!(filmcraft_render::plan::execute_cpu(&plan).over_black_rgba8(), program, "layer plan: same conversion");
+
+    for working in [WorkingSpace::Rec709, WorkingSpace::Rec2100Hlg] {
+        p.sequence_mut(seq).unwrap().settings.color = ColorPipeline { working, ..ColorPipeline::REC709 };
+        let p = Arc::new(p.clone());
+        let preview = render_sequence(&p, seq, Tick::ZERO, RenderOptions::default(), &map).over_black_rgba8();
+        assert_eq!(preview, program, "{working:?}: working → monitor maps once");
+        if working.is_hdr() {
+            let hdr = render_sequence(&p, seq, Tick::ZERO, RenderOptions { working_output: true, ..Default::default() }, &map);
+            assert!(hdr.get(144, 16)[0] > 4.9, "HDR export keeps highlights above reference white");
+        }
+        let path = tmp(&format!("{}.png", working.id()));
+        let settings = ExportSettings { format: Format::PngSequence, path, include_audio: false, gpu_rendering: GpuRendering::Off, ..Default::default() };
+        let report = export(&p, seq, &settings, &map, &Progress::default()).unwrap();
+        assert_eq!(report.frames, 3);
+        let png = image::open(tmp(&format!("{}000.png", working.id()))).unwrap().into_rgba8();
+        assert_eq!(png.into_raw(), preview, "{working:?}: PNG export equals preview");
+
+        for (format, extension) in [(Format::ProRes, "mov"), (Format::H264, "mp4")] {
+            let path = tmp(&format!("{}-{}.{extension}", working.id(), format.id()));
+            let settings = ExportSettings {
+                format,
+                path: path.clone(),
+                sdr: true,
+                include_audio: false,
+                bitrate_kbps: 20_000,
+                gpu_rendering: GpuRendering::Off,
+                ..Default::default()
+            };
+            export(&p, seq, &settings, &map, &Progress::default()).unwrap();
+            let src = filmcraft_codecs::open_bytes(&path, std::fs::read(&path).unwrap().into()).unwrap();
+            let frame = src.video_frame(FrameRequest::full(Tick::ZERO)).unwrap();
+            // ProRes carries colour in its frame header; the container-only stream-info
+            // probe can guess SD colour from these deliberately tiny test dimensions.
+            assert_eq!(frame.color, ColorInfo::REC709, "{path}: decoded frame colour signalling");
+            let decoded = frame.to_rgba8();
+            // Sample the interiors of the constant patches to exclude lossy chroma edges.
+            for x in (16..W as usize).step_by(32) {
+                let offset = (16 * W as usize + x) * 4;
+                for k in 0..3 {
+                    assert!(decoded[offset + k].abs_diff(preview[offset + k]) <= 3, "{working:?} {format:?} patch {x}: decoded != preview");
+                }
+            }
+            if let Some(ffprobe) = filmcraft_testkit::ffprobe_or_skip("HLG SDR export") {
+                let out = std::process::Command::new(ffprobe)
+                    .args([
+                        "-v",
+                        "error",
+                        "-select_streams",
+                        "v:0",
+                        "-show_entries",
+                        "stream=color_transfer,color_primaries,color_space",
+                        "-of",
+                        "default=nw=1",
+                        &path,
+                    ])
+                    .output()
+                    .unwrap();
+                assert!(out.status.success());
+                let text = String::from_utf8_lossy(&out.stdout);
+                for tag in ["color_transfer=bt709", "color_primaries=bt709", "color_space=bt709"] {
+                    assert!(text.contains(tag), "{path}: missing {tag}: {text}");
+                }
+            }
+            if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("HLG SDR export decode") {
+                let out = std::process::Command::new(ffmpeg).args(["-v", "error", "-xerror", "-i", &path, "-f", "null", "-"]).output().unwrap();
+                assert!(out.status.success(), "{path}: {}", String::from_utf8_lossy(&out.stderr));
+            }
+        }
+    }
+}
diff --git a/crates/gpu/src/composite.wgsl b/crates/gpu/src/composite.wgsl
--- a/crates/gpu/src/composite.wgsl
+++ b/crates/gpu/src/composite.wgsl
@@ -18,7 +18,7 @@ struct U {
     // Colour management of HDR / wide-gamut Y'CbCr (`filmcraft_color::InputTransform`, #965):
     q0: vec4<f32>,   // on, curve (0 sdr, 1 linear, 2 pq, 3 hlg), HLG OOTF scale (0: none), reference white cd/m²
     q1: vec4<f32>,   // HLG OOTF luma weights, gamut matrix on
-    q2: vec4<f32>,   // gamut matrix row 0, tone mapping on
+    q2: vec4<f32>,   // gamut matrix row 0, tone mapping (0 off, 1 BT.2390, 2 BT.2446 A)
     q3: vec4<f32>,   // gamut matrix row 1, tone-mapping source peak cd/m²
     q4: vec4<f32>,   // gamut matrix row 2, tone-mapping target peak cd/m²
     q5: vec4<f32>,   // desaturation luma weights, desaturation on
@@ -122,7 +122,28 @@ fn eetf(nits: f32, s: f32, d: f32) -> f32 {
     return pq1(e2 * smax) * 10000.0;
 }
 
-// Signal → working linear: `InputTransform::convert` (curve, HLG OOTF, gamut matrix, tone
+// BT.2446-1 (03/2021) §4.1 tables 2–3: BT.2020 1000-nit display light (203 nits = 1)
+// to relative SDR linear light (100-nit peak = 1), before the gamut matrix.
+fn bt2446a(rgb: vec3<f32>) -> vec3<f32> {
+    let c = pow(clamp(rgb * (u.q0.w / 1000.0), vec3(0.0), vec3(1.0)), vec3(1.0 / 2.4));
+    let y = dot(vec3(0.2627, 0.6780, 0.0593), c);
+    if y <= 0.0 { return vec3(0.0); }
+    let yp = log(1.0 + 12.259798 * y) / 2.5847368;
+    var yc = 0.5 * yp + 0.5;
+    if yp <= 0.7399 { yc = 1.0770 * yp; }
+    else if yp < 0.9909 { yc = -1.1510 * yp * yp + 2.7811 * yp - 0.6302; }
+    let sdr = (exp(yc * 1.7399323) - 1.0) / 4.696958;
+    let scale = sdr / (1.1 * y);
+    let cb = scale * (c.b - y) / 1.8814;
+    let cr = scale * (c.r - y) / 1.4746;
+    let luma = sdr - max(0.1 * cr, 0.0);
+    let r = luma + 1.4746 * cr;
+    let b = luma + 1.8814 * cb;
+    let g = (luma - 0.2627 * r - 0.0593 * b) / 0.6780;
+    return pow(max(vec3(r, g, b), vec3(0.0)), vec3(2.4));
+}
+
+// Signal → working linear: `InputTransform::convert` (curve, HLG OOTF, HLG tone mapping, gamut matrix, PQ tone
 // mapping, desaturation into the working gamut). The CPU's decode tables clamp the signal to 0..1.
 fn managed(rgb: vec3<f32>) -> vec3<f32> {
     let v = clamp(rgb, vec3(0.0), vec3(1.0));
@@ -138,10 +159,11 @@ fn managed(rgb: vec3<f32>) -> vec3<f32> {
         if ys > 0.0 { g = u.q0.z * pow(ys, 0.2); }
         c = c * g;
     }
+    if u.q2.w > 1.5 { c = bt2446a(c); }
     if u.q1.w > 0.5 {
         c = vec3(dot(u.q2.xyz, c), dot(u.q3.xyz, c), dot(u.q4.xyz, c));
     }
-    if u.q2.w > 0.5 {
+    if u.q2.w > 0.5 && u.q2.w < 1.5 {
         let m = max(max(c.r, c.g), c.b);
         if m > 0.0 {
             let nits = m * white;
diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs
--- a/crates/gpu/src/lib.rs
+++ b/crates/gpu/src/lib.rs
@@ -3,7 +3,7 @@
 //! Executes a [`FramePlan`](filmcraft_render::plan::FramePlan) on the GPU: every layer's source is
 //! uploaded as textures (Y/Cb/Cr planes stay YUV — conversion to linear RGB happens per pixel in
 //! the shader), drawn as a transformed quad with premultiplied "over" blending into a Rgba16Float
-//! accumulator, then resolved over black into an sRGB `Rgba8UnormSrgb` texture that the UI
+//! accumulator, then resolved over black into a gamma-encoded `Rgba8Unorm` texture that the UI
 //! registers as a native texture. Uploads are cached by pixel-buffer identity, so a paused frame or
 //! a still costs nothing.
 //!
@@ -637,14 +637,18 @@ impl GpuCompositor {
             };
             let (hl, hk) = p.hlg.unwrap_or(([0.0; 3], 0.0));
             let mx = p.matrix.unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
-            let (sp, dp) = p.tone.unwrap_or((0.0, 0.0));
+            let (method, sp, dp) = match p.tone {
+                None => (0.0, 0.0, 0.0),
+                Some(filmcraft_color::GpuToneMap::Bt2390 { source_peak_nits, target_peak_nits }) => (1.0, source_peak_nits, target_peak_nits),
+                Some(filmcraft_color::GpuToneMap::Bt2446A) => (2.0, 1000.0, 100.0),
+            };
             let dl = p.desaturate.unwrap_or([0.0; 3]);
             let on = |b: bool| if b { 1.0 } else { 0.0 };
             #[rustfmt::skip]
             let q = [
                 1.0, curve, hk, filmcraft_color::REFERENCE_WHITE_NITS as f32,
                 hl[0], hl[1], hl[2], on(p.matrix.is_some()),
-                mx[0][0], mx[0][1], mx[0][2], on(p.tone.is_some()),
+                mx[0][0], mx[0][1], mx[0][2], method,
                 mx[1][0], mx[1][1], mx[1][2], sp,
                 mx[2][0], mx[2][1], mx[2][2], dp,
                 dl[0], dl[1], dl[2], on(p.desaturate.is_some()),
diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs
--- a/crates/gpu/src/tests.rs
+++ b/crates/gpu/src/tests.rs
@@ -507,7 +507,7 @@ fn fast_half_float_conversion_matches_the_reference_for_every_value() {
 }
 
 /// HDR and wide-gamut Y'CbCr on a Rec. 709 sequence (#965): the shader's colour management
-/// (curve, HLG OOTF, BT.2020 → 709 matrix, BT.2390 tone mapping, desaturation) matches the CPU
+/// (curve, HLG OOTF, BT.2446 A / BT.2390 tone mapping, BT.2020 → 709, desaturation) matches the CPU
 /// running the same `InputTransform`.
 #[test]
 fn hdr_input_gpu_matches_cpu() {
@@ -523,15 +523,22 @@ fn hdr_input_gpu_matches_cpu() {
     let y: Vec<u16> = (0..h).flat_map(|_| (0..w).map(|x| (64 + x * 876 / 255) as u16)).collect();
     let cb: Vec<u16> = (0..h).flat_map(|r| std::iter::repeat_n(chroma[r as usize].0, w as usize)).collect();
     let cr: Vec<u16> = (0..h).flat_map(|r| std::iter::repeat_n(chroma[r as usize].1, w as usize)).collect();
-    let data = PixelData::Yuv16 { planes: [Arc::new(y), Arc::new(cb), Arc::new(cr)], chroma: Chroma::C444, bits: 10, alpha: None };
+    let data = PixelData::Yuv16 { planes: [Arc::new(y), Arc::new(cb.clone()), Arc::new(cr.clone())], chroma: Chroma::C444, bits: 10, alpha: None };
     let hdr = |transfer| ColorInfo { matrix: Matrix::Bt2020Ncl, transfer, primaries: Primaries::Bt2020, range: Range::Limited };
     let cases = [
         ("HLG", hdr(Transfer::Hlg), None),
+        ("HLG full range", ColorInfo { range: Range::Full, ..hdr(Transfer::Hlg) }, None),
         ("PQ", hdr(Transfer::Pq), None),
         ("PQ 4000 cd/m²", hdr(Transfer::Pq), Some(4000.0)),
         ("BT.2020 SDR", ColorInfo { primaries: Primaries::Bt2020, ..ColorInfo::REC709 }, None),
     ];
     for (name, color, peak) in cases {
+        let data = if color.range == Range::Full {
+            let y = (0..h).flat_map(|_| (0..w).map(|x| (x * 1023 / 255) as u16)).collect();
+            PixelData::Yuv16 { planes: [Arc::new(y), Arc::new(cb.clone()), Arc::new(cr.clone())], chroma: Chroma::C444, bits: 10, alpha: None }
+        } else {
+            data.clone()
+        };
         let frame = Arc::new(VideoFrame { width: w, height: h, data: data.clone(), color, par: (1, 1), pts: Default::default() });
         let cs = ColorSpace::from_info(&color);
         let t = Arc::new(InputTransform::new(cs, color.range, &ColorPipeline::REC709, peak));
@@ -542,6 +549,8 @@ fn hdr_input_gpu_matches_cpu() {
         c.composite(&plan);
         let (_, _, gpu) = c.read_output().expect("readback");
         let error = cpu.iter().zip(&gpu).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
+        c.composite_prepared(&plan, Some(&prepare(&plan)));
+        assert_eq!(c.read_output().expect("prepared readback").2, gpu, "{name}: prepared matches inline upload");
         // and the stage does something: the default decode of the same frame is far off
         let plain = FramePlan::Layers { width: w as usize, height: h as usize, layers: vec![PlanLayer::new(frame, Affine::IDENTITY, 1.0, Blend::Normal)] };
         c.composite(&plain);
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -547,8 +547,9 @@ with different vertex counts hold). Feather, Opacity and Expansion are ordinary
 
 ```text
 frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage override, else VUI/colr/MKV Colour
-  ─► decode table (sRGB/BT.709, PQ, HLG scene light, camera log) ─► HLG OOTF ─► 3×3 to working gamut
-  ─► BT.2390 tone map (HDR/log into an SDR sequence, Auto Tone Map Media) ─► gamut compression
+  ─► decode table (sRGB/BT.709, PQ, HLG scene light, camera log) ─► HLG OOTF
+  ─► BT.2446 A (HLG into SDR, Auto Tone Map Media) ─► 3×3 to working gamut
+  ─► BT.2390 (PQ into SDR, Auto Tone Map Media) ─► gamut mapping
   ─► effects + compositing in working linear (1.0 = reference white = SDR white = 203 cd/m²)
   ─► monitors: working → SDR BT.709 (tone map from HDR, gamut map from BT.2020)
   ─► HDR export: working → PQ/HLG BT.2020 R'G'B' → Y'CbCr (BT.2020 NCL) + VUI/colr/mdcv/clli/SEI
@@ -561,9 +562,16 @@ frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage over
 - **Maths** in `filmcraft-color` (`transform`, `log`, `spaces`; formulas and sources in
   [crates/color/README.md](../crates/color/README.md)); the renderer side is `render::colorman`.
 - **Fast path.** A Rec. 709 sequence without wide gamut and media whose metadata says Rec. 709 /
-  sRGB decodes exactly as before, and its layers stay on the GPU. Log, HDR or wide-gamut media,
-  and every layer of an HDR/wide-gamut sequence, are converted on the CPU (the GPU path draws them
-  as pre-rendered images, like layers with effects).
+  sRGB decodes exactly as before, and its layers stay on the GPU. Display-referred HDR/wide-gamut
+  Y'CbCr on a plain Rec. 709 sequence also stays on the GPU: `GpuInputParams` carries the curve,
+  HLG OOTF, tone-map method, gamut matrix and desaturation. BT.2446 A runs in BT.2020 before the
+  matrix; BT.2390 retains its post-matrix stage. Log and HDR/wide-gamut sequences are converted
+  on the CPU and drawn as pre-rendered images.
+- **HLG → SDR.** BT.2446-1 Method A (§4.1, 1000 → 100 cd/m²) with its chroma correction is
+  shared by Source, automatic HLG input into an SDR sequence, HLG sequence monitoring and SDR
+  export. SDR output is relative linear BT.709 before sRGB display encoding. The HDR working
+  scale remains 203 cd/m² = 1, and HDR export bypasses SDR conversion. Existing automatic HLG
+  conversions adopt the corrected appearance; there is no project schema or command change.
 - **Outputs.** `RenderOptions::working_output` returns working-space pixels (HDR exports,
   scopes); otherwise the top-level render is converted for an SDR monitor. The other colour
   effects work on display-encoded values clamped to 0..1 (they clip HDR highlights above
@@ -591,8 +599,9 @@ frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage over
   exports the tone-mapped SDR picture instead; render previews always do.
 - **Monitors / display colour management.** The monitors are SDR (sRGB-encoded RGBA8 textures):
   HDR sequences are shown tone mapped. macOS EDR (extended-range `CAMetalLayer` output) is not
-  wired up: there is no `platform` crate yet and eframe/wgpu do not expose an EDR surface, so HDR
-  values above SDR white are never sent to the display. The scopes of an HDR sequence show the
+  wired up: HDR values above SDR white are never sent to the display. Dolby Vision per-frame
+  metadata is not processed, so HLG SDR conversion need not match QuickTime HDR playback.
+  The scopes of an HDR sequence show the
   working-space values (waveform in cd/m² on a PQ scale, BT.2020 vectorscope).
 - **LUTs.** Lumetri Input LUT and Creative Look reference `lib:<id>` (the project's LUT library,
   `Project::luts`, embedded `.cube`/`.3dl` text) or `builtin:<id>` (code-generated camera
