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
@@ -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).@@ -88,3 +88,6 @@ sync_github_name = false [people.webdevtodayjason] display_name = "Jason Brashear"++[people.justacalico]+sync_github_name = true@@ -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).@@ -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)]@@ -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());@@ -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)new file mode 100644@@ -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));+ }+ }+ }+}@@ -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;@@ -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()),@@ -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);@@ -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