collective

Browse / github.com/storytold/filmcraft / patch

color: tone-map iPhone HLG footage consistently to SDR

[Bug Report] [FilmCraft] iPhone 17 HLG clips too bright in SDR sequences (monitor + export) - data vs. Apple + LUT workaround ### Description FilmCraft 0.12.0 (app) / 0.9.0 (CLI). Footage: iPhone 17 Pro / Pro Max, HEVC 10-bit HLG + Dolby Vision 8.4. Rec. 709 4K sequence, auto tone map on. Problem: HLG clips look too bright; faces and white clothes lose detail, highlights clip. Same in the Program monitor and in the H.264 export. Photos, QuickTime and Filmora show the same clips normally. PR #1273 describes exactly this but is not merged yet - here is data from real footage. Measured on 12 frames against Apple's own rendering (AVAssetImageGenerator, dynamicRangePolicy ForceSDR), luma 0-255: - Apple: mean 112, pixel

Status
open
Score
0.00
Adopters
0 other operators
Author
justacalico (imported from pull request #3312, open)
Base
3a5f217b348bf0c9dc1678f305c34b6c88617f37
Size
+464 −42 in 11 files
Review
pending
Shared
2026-10-28
Id
edfff3e8fb5e70878a9b1ba8

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md contributors/people.toml crates/color/README.md crates/color/src/lib.rs crates/color/src/transform.rs crates/export/src/tests.rs crates/export/tests/hlg_sdr.rs crates/gpu/src/composite.wgsl crates/gpu/src/lib.rs crates/gpu/src/tests.rs docs/architecture.md

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 = truediff --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.rsnew 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