Skip to main content

drmkit_scene/
signaling.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! What to tell the connector about the frame it is being handed.
5//!
6//! The producer side describes each layer's container colorimetry and
7//! transfer function. The connector wants one `Colorspace` enum value and,
8//! for HDR, one `HDR_OUTPUT_METADATA` blob describing the mastering display.
9//! This is the bridge, and the whole of it is arithmetic over layer
10//! descriptions -- no device, so it can be reasoned about without one.
11
12use drmkit_display::color::{Chromaticity, ColorimetryInfo};
13use drmkit_display::{Colorspace, ConnectorCapabilities, HdrSourceMetadata, TransferFunction};
14
15use crate::display::DisplayParams;
16
17/// The container colorimetry a producer says its layer is in.
18///
19/// The friendly producer-side description, distinct from
20/// [`Colorspace`], which is what the driver advertises on a connector. The
21/// scene picks the connector enum that best matches the widest-gamut layer at
22/// commit time.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
24#[non_exhaustive]
25pub enum ColorPrimaries {
26    /// BT.709 / sRGB. The SDR default.
27    #[default]
28    Bt709,
29    /// Adobe RGB (1998).
30    AdobeRgb,
31    /// DCI-P3 D65, display-referred consumer P3.
32    DciP3,
33    /// BT.2020 / Rec.2100. HDR and wide-gamut SDR.
34    Bt2020,
35}
36
37impl ColorPrimaries {
38    /// Widest last, so the derived `Ord` ranks gamut directly.
39    ///
40    /// Deliberate: a separate rank function and a variant order that
41    /// disagreed would be a silent mis-ranking, and there is nothing to
42    /// notice it. Adding a variant in the wrong place breaks a test here
43    /// rather than a picture on a display.
44    #[must_use]
45    pub const fn is_wider_than(self, other: Self) -> bool {
46        (self as u8) > (other as u8)
47    }
48
49    /// Canonical CIE 1931 chromaticities, ready to seed mastering-display
50    /// primaries.
51    ///
52    /// All four are D65-white.
53    #[must_use]
54    pub const fn colorimetry(self) -> ColorimetryInfo {
55        let (red, green, blue) = match self {
56            // ITU-R BT.709-6 section 3.2.
57            Self::Bt709 => ((0.640, 0.330), (0.300, 0.600), (0.150, 0.060)),
58            // ITU-R BT.2020-2 section 2.3.
59            Self::Bt2020 => ((0.708, 0.292), (0.170, 0.797), (0.131, 0.046)),
60            // SMPTE EG 432-1.
61            Self::DciP3 => ((0.680, 0.320), (0.265, 0.690), (0.150, 0.060)),
62            // Adobe RGB (1998) section 4.3.4.1. Red and blue are BT.709's;
63            // only green is wider, which is the whole of the difference.
64            Self::AdobeRgb => ((0.640, 0.330), (0.210, 0.710), (0.150, 0.060)),
65        };
66        ColorimetryInfo {
67            red: Chromaticity { x: red.0, y: red.1 },
68            green: Chromaticity {
69                x: green.0,
70                y: green.1,
71            },
72            blue: Chromaticity {
73                x: blue.0,
74                y: blue.1,
75            },
76            white: Chromaticity {
77                x: 0.3127,
78                y: 0.3290,
79            },
80            has_primaries: true,
81            has_default_white: true,
82        }
83    }
84
85    /// The connector enum that best carries this.
86    #[must_use]
87    pub const fn colorspace(self) -> Colorspace {
88        match self {
89            // Not `Bt709Ycc`: that would force YCC encoding, which is wrong
90            // for an RGB source. `Default` lets the driver use its standard
91            // SDR signalling, which is what an sRGB desktop wants.
92            Self::Bt709 => Colorspace::Default,
93            // The RGB variant, for the desktop and signage path. YCC content
94            // says so through the plane's `COLOR_ENCODING` instead.
95            Self::Bt2020 => Colorspace::Bt2020Rgb,
96            Self::DciP3 => Colorspace::DciP3RgbD65,
97            Self::AdobeRgb => Colorspace::OpRgb,
98        }
99    }
100}
101
102/// What the scene wants declared on the connector for one frame.
103#[derive(Debug, Clone, PartialEq, Default)]
104#[non_exhaustive]
105pub struct OutputSignalling {
106    /// The `Colorspace` to write, or `None` to leave the property alone.
107    ///
108    /// `None` when no layer said what it was in. Writing a guess would
109    /// override whatever the last client left, which on a connector is
110    /// sticky.
111    pub colorspace: Option<Colorspace>,
112    /// The metadata to publish, or `None` for no HDR signalling.
113    pub hdr_metadata: Option<HdrSourceMetadata>,
114    /// Whether HDR was called for and could not be carried.
115    ///
116    /// The layers asked for it and the connector cannot: no
117    /// `HDR_OUTPUT_METADATA`, or a `max bpc` ceiling below 10. The metadata
118    /// is dropped rather than sent, and this says so -- a caller that wants
119    /// to tone-map itself, or tell the user, has no other way to find out.
120    pub hdr_downgraded: bool,
121}
122
123/// The widest-gamut primaries any layer names.
124///
125/// `None` when no layer names any, which the caller reads as "leave the
126/// connector alone" rather than as BT.709.
127#[must_use]
128pub fn widest_gamut<'a>(
129    layers: impl IntoIterator<Item = &'a DisplayParams>,
130) -> Option<ColorPrimaries> {
131    layers
132        .into_iter()
133        .filter_map(|layer| layer.color_primaries)
134        .max()
135}
136
137/// Whether a transfer function is one that needs HDR signalling.
138///
139/// `TraditionalGammaHdr` is not: it is HDR content over a BT.1886 path, and
140/// publishing PQ metadata for it would tell the sink to apply a curve the
141/// content is not in.
142const fn is_hdr(eotf: TransferFunction) -> bool {
143    matches!(
144        eotf,
145        TransferFunction::SmpteSt2084Pq | TransferFunction::Bt2100Hlg
146    )
147}
148
149/// Derive what the connector should signal for this frame's layers.
150///
151/// - The widest-gamut layer decides the `Colorspace`. No layer naming one
152///   leaves it unset.
153/// - Any layer in PQ or HLG produces metadata; PQ wins when both are present,
154///   being the more common HDR10 path.
155/// - The mastering primaries come from the widest-gamut layer, defaulting to
156///   BT.2020 when an HDR layer names none -- an HDR layer that did not say is
157///   far more likely to be BT.2020 than BT.709.
158/// - Luminance, `MaxCLL` and `MaxFALL` stay zero. A caller with real
159///   mastering data sets the whole struct itself; inventing values here would
160///   have the sink tone-map to numbers nobody measured.
161///
162/// `capabilities` gates the HDR half. Passing `None` skips the check, for a
163/// caller that already gated up front.
164#[must_use]
165pub fn derive_output_signalling(
166    layers: &[DisplayParams],
167    capabilities: Option<&ConnectorCapabilities>,
168) -> OutputSignalling {
169    let gamut = widest_gamut(layers);
170
171    let eotf = layers
172        .iter()
173        .filter_map(|layer| layer.source_eotf)
174        .filter(|eotf| is_hdr(*eotf))
175        // PQ over HLG, and `SmpteSt2084Pq` sorts before `Bt2100Hlg` in the
176        // enum, so this is `min` rather than `max`.
177        .min_by_key(|eotf| match eotf {
178            TransferFunction::SmpteSt2084Pq => 0_u8,
179            _ => 1,
180        });
181
182    let mut signalling = OutputSignalling {
183        colorspace: gamut.map(ColorPrimaries::colorspace),
184        hdr_metadata: eotf.map(|eotf| HdrSourceMetadata {
185            eotf,
186            display_primaries: gamut.unwrap_or(ColorPrimaries::Bt2020).colorimetry(),
187            max_display_mastering_luminance: 0,
188            min_display_mastering_luminance: 0,
189            max_content_light_level: 0,
190            max_frame_average_light_level: 0,
191        }),
192        hdr_downgraded: false,
193    };
194
195    if signalling.hdr_metadata.is_some() && capabilities.is_some_and(|caps| !caps.can_signal_hdr())
196    {
197        // Eight bits per component with a PQ flag is a sink that accepts the
198        // metadata and shows banded SDR. Dropping the signalling is the
199        // honest answer, and the flag is how the caller learns it happened.
200        signalling.hdr_metadata = None;
201        signalling.hdr_downgraded = true;
202    }
203
204    signalling
205}
206
207#[cfg(test)]
208mod tests {
209    use super::{ColorPrimaries, derive_output_signalling, widest_gamut};
210    use crate::display::DisplayParams;
211    use drmkit_display::{Colorspace, ConnectorCapabilities, MaxBpc, TransferFunction};
212
213    /// A layer that says nothing about colour.
214    fn plain() -> DisplayParams {
215        DisplayParams::default()
216    }
217
218    fn layer(primaries: Option<ColorPrimaries>, eotf: Option<TransferFunction>) -> DisplayParams {
219        DisplayParams {
220            color_primaries: primaries,
221            source_eotf: eotf,
222            ..DisplayParams::default()
223        }
224    }
225
226    /// A connector that can carry HDR: the blob and ten bits.
227    fn hdr_capable() -> ConnectorCapabilities {
228        ConnectorCapabilities::new(1)
229            .with_hdr_output_metadata(true)
230            .with_max_bpc(MaxBpc {
231                min: 8,
232                max: 10,
233                current: 10,
234            })
235    }
236
237    // ── Canonical primaries ────────────────────────────────────────────
238
239    #[test]
240    fn bt709_is_canonical_srgb() {
241        let c = ColorPrimaries::Bt709.colorimetry();
242        assert!((c.red.x - 0.640).abs() < 1e-6 && (c.red.y - 0.330).abs() < 1e-6);
243        assert!((c.green.x - 0.300).abs() < 1e-6 && (c.green.y - 0.600).abs() < 1e-6);
244        assert!((c.blue.x - 0.150).abs() < 1e-6 && (c.blue.y - 0.060).abs() < 1e-6);
245        assert!((c.white.x - 0.3127).abs() < 1e-6 && (c.white.y - 0.3290).abs() < 1e-6);
246        assert!(c.has_primaries && c.has_default_white);
247    }
248
249    #[test]
250    fn bt2020_matches_rec2100() {
251        let c = ColorPrimaries::Bt2020.colorimetry();
252        assert!((c.red.x - 0.708).abs() < 1e-6);
253        assert!((c.green.y - 0.797).abs() < 1e-6);
254        assert!((c.blue.x - 0.131).abs() < 1e-6);
255    }
256
257    #[test]
258    fn dci_p3_d65_matches_smpte_eg432() {
259        let c = ColorPrimaries::DciP3.colorimetry();
260        assert!((c.red.x - 0.680).abs() < 1e-6);
261        assert!((c.green.x - 0.265).abs() < 1e-6);
262        // D65, not the DCI theatre white -- this is the consumer variant.
263        assert!((c.white.x - 0.3127).abs() < 1e-6);
264    }
265
266    #[test]
267    fn adobe_rgb_differs_from_bt709_only_in_green() {
268        // The whole of Adobe RGB's extra gamut is the green primary. Red and
269        // blue matching BT.709 is the check that the table was not copied
270        // from the wrong row.
271        let adobe = ColorPrimaries::AdobeRgb.colorimetry();
272        let bt709 = ColorPrimaries::Bt709.colorimetry();
273        assert_eq!(adobe.red, bt709.red);
274        assert_eq!(adobe.blue, bt709.blue);
275        assert_ne!(adobe.green, bt709.green);
276        assert!((adobe.green.y - 0.710).abs() < 1e-6);
277    }
278
279    // ── Widest gamut ───────────────────────────────────────────────────
280
281    #[test]
282    fn no_layers_name_no_gamut() {
283        assert_eq!(widest_gamut(&[] as &[DisplayParams]), None);
284        assert_eq!(widest_gamut(&[plain(), plain()]), None);
285    }
286
287    #[test]
288    fn one_layer_passes_its_own_through() {
289        let layers = [layer(Some(ColorPrimaries::DciP3), None)];
290        assert_eq!(widest_gamut(&layers), Some(ColorPrimaries::DciP3));
291    }
292
293    #[test]
294    fn the_ranking_runs_bt2020_over_p3_over_adobe_over_bt709() {
295        for (wider, narrower) in [
296            (ColorPrimaries::Bt2020, ColorPrimaries::DciP3),
297            (ColorPrimaries::DciP3, ColorPrimaries::AdobeRgb),
298            (ColorPrimaries::AdobeRgb, ColorPrimaries::Bt709),
299        ] {
300            assert!(wider.is_wider_than(narrower), "{wider:?} over {narrower:?}");
301            // Order-independent: the answer is the gamut, not the position.
302            assert_eq!(
303                widest_gamut(&[layer(Some(narrower), None), layer(Some(wider), None)]),
304                Some(wider)
305            );
306            assert_eq!(
307                widest_gamut(&[layer(Some(wider), None), layer(Some(narrower), None)]),
308                Some(wider)
309            );
310        }
311    }
312
313    #[test]
314    fn a_mixed_set_selects_the_widest() {
315        let layers = [
316            layer(Some(ColorPrimaries::Bt709), None),
317            plain(),
318            layer(Some(ColorPrimaries::Bt2020), None),
319            layer(Some(ColorPrimaries::AdobeRgb), None),
320        ];
321        assert_eq!(widest_gamut(&layers), Some(ColorPrimaries::Bt2020));
322    }
323
324    #[test]
325    fn layers_that_say_nothing_are_passed_over_not_counted_as_bt709() {
326        // Counting an unset layer as BT.709 would be harmless here but wrong
327        // in `derive`: a frame of nothing-but-unset layers must leave the
328        // connector alone, not write Default over whatever is there.
329        let layers = [plain(), layer(Some(ColorPrimaries::DciP3), None), plain()];
330        assert_eq!(widest_gamut(&layers), Some(ColorPrimaries::DciP3));
331    }
332
333    // ── Primaries to connector enum ────────────────────────────────────
334
335    #[test]
336    fn bt709_maps_to_default_rather_than_bt709_ycc() {
337        // BT709_YCC would force YCC encoding on an RGB source. `Default` lets
338        // the driver use its standard SDR signalling, which is what an sRGB
339        // desktop wants.
340        assert_eq!(ColorPrimaries::Bt709.colorspace(), Colorspace::Default);
341    }
342
343    #[test]
344    fn the_other_three_map_to_their_own_entries() {
345        assert_eq!(ColorPrimaries::Bt2020.colorspace(), Colorspace::Bt2020Rgb);
346        assert_eq!(ColorPrimaries::DciP3.colorspace(), Colorspace::DciP3RgbD65);
347        assert_eq!(ColorPrimaries::AdobeRgb.colorspace(), Colorspace::OpRgb);
348    }
349
350    // ── Deriving the whole thing ───────────────────────────────────────
351
352    #[test]
353    fn no_layers_signal_nothing() {
354        let signalling = derive_output_signalling(&[], None);
355        assert_eq!(signalling.colorspace, None);
356        assert_eq!(signalling.hdr_metadata, None);
357        assert!(!signalling.hdr_downgraded);
358    }
359
360    #[test]
361    fn an_all_sdr_frame_that_names_nothing_leaves_the_connector_alone() {
362        let signalling = derive_output_signalling(&[plain(), plain()], None);
363        assert_eq!(signalling.colorspace, None);
364        assert_eq!(signalling.hdr_metadata, None);
365    }
366
367    #[test]
368    fn an_sdr_bt709_layer_asks_for_default_and_no_hdr() {
369        let layers = [layer(Some(ColorPrimaries::Bt709), None)];
370        let signalling = derive_output_signalling(&layers, None);
371        assert_eq!(signalling.colorspace, Some(Colorspace::Default));
372        assert_eq!(signalling.hdr_metadata, None);
373    }
374
375    #[test]
376    fn a_bt2020_pq_layer_produces_hdr_signalling() {
377        let layers = [layer(
378            Some(ColorPrimaries::Bt2020),
379            Some(TransferFunction::SmpteSt2084Pq),
380        )];
381        let signalling = derive_output_signalling(&layers, None);
382        assert_eq!(signalling.colorspace, Some(Colorspace::Bt2020Rgb));
383        let hdr = signalling.hdr_metadata.expect("hdr");
384        assert_eq!(hdr.eotf, TransferFunction::SmpteSt2084Pq);
385        assert_eq!(
386            hdr.display_primaries,
387            ColorPrimaries::Bt2020.colorimetry(),
388            "the mastering primaries come from the widest-gamut layer"
389        );
390        // Left at zero deliberately: inventing luminance would have the sink
391        // tone-map to numbers nobody measured.
392        assert_eq!(hdr.max_display_mastering_luminance, 0);
393        assert_eq!(hdr.max_content_light_level, 0);
394    }
395
396    #[test]
397    fn one_hdr_layer_among_sdr_ones_carries_the_frame() {
398        let layers = [
399            layer(Some(ColorPrimaries::Bt709), None),
400            layer(
401                Some(ColorPrimaries::Bt2020),
402                Some(TransferFunction::SmpteSt2084Pq),
403            ),
404        ];
405        let signalling = derive_output_signalling(&layers, None);
406        assert_eq!(signalling.colorspace, Some(Colorspace::Bt2020Rgb));
407        assert!(signalling.hdr_metadata.is_some());
408    }
409
410    #[test]
411    fn pq_wins_when_both_hdr_curves_are_present() {
412        // HDR10 is the common path, and the two cannot both be signalled.
413        for order in [[0, 1], [1, 0]] {
414            let both = [
415                layer(
416                    Some(ColorPrimaries::Bt2020),
417                    Some(TransferFunction::SmpteSt2084Pq),
418                ),
419                layer(
420                    Some(ColorPrimaries::Bt2020),
421                    Some(TransferFunction::Bt2100Hlg),
422                ),
423            ];
424            let layers = [both[order[0]], both[order[1]]];
425            let hdr = derive_output_signalling(&layers, None)
426                .hdr_metadata
427                .expect("hdr");
428            assert_eq!(hdr.eotf, TransferFunction::SmpteSt2084Pq);
429        }
430    }
431
432    #[test]
433    fn an_hlg_only_frame_signals_hlg() {
434        let layers = [layer(
435            Some(ColorPrimaries::Bt2020),
436            Some(TransferFunction::Bt2100Hlg),
437        )];
438        let hdr = derive_output_signalling(&layers, None)
439            .hdr_metadata
440            .expect("hdr");
441        assert_eq!(hdr.eotf, TransferFunction::Bt2100Hlg);
442    }
443
444    #[test]
445    fn an_hdr_layer_that_names_no_gamut_masters_in_bt2020() {
446        // BT.709 would be the wrong default here: content that is HDR and did
447        // not say is far more likely to be BT.2020, and telling the sink
448        // BT.709 shrinks the gamut it renders into.
449        let layers = [layer(None, Some(TransferFunction::SmpteSt2084Pq))];
450        let signalling = derive_output_signalling(&layers, None);
451        assert_eq!(signalling.colorspace, None, "no layer named a gamut");
452        let hdr = signalling.hdr_metadata.expect("hdr");
453        assert_eq!(hdr.display_primaries, ColorPrimaries::Bt2020.colorimetry());
454    }
455
456    #[test]
457    fn traditional_gamma_hdr_is_not_hdr_signalling() {
458        // HDR content over a BT.1886 path. Publishing PQ metadata for it would
459        // tell the sink to apply a curve the content is not in.
460        let layers = [layer(
461            Some(ColorPrimaries::Bt2020),
462            Some(TransferFunction::TraditionalGammaHdr),
463        )];
464        let signalling = derive_output_signalling(&layers, None);
465        assert_eq!(signalling.hdr_metadata, None);
466        assert!(!signalling.hdr_downgraded, "nothing was dropped");
467        assert_eq!(signalling.colorspace, Some(Colorspace::Bt2020Rgb));
468    }
469
470    // ── Connector constraints ──────────────────────────────────────────
471
472    #[test]
473    fn a_capable_connector_keeps_the_metadata() {
474        let layers = [layer(
475            Some(ColorPrimaries::Bt2020),
476            Some(TransferFunction::SmpteSt2084Pq),
477        )];
478        let signalling = derive_output_signalling(&layers, Some(&hdr_capable()));
479        assert!(signalling.hdr_metadata.is_some());
480        assert!(!signalling.hdr_downgraded);
481    }
482
483    #[test]
484    fn a_connector_without_the_blob_downgrades() {
485        let layers = [layer(
486            Some(ColorPrimaries::Bt2020),
487            Some(TransferFunction::SmpteSt2084Pq),
488        )];
489        let no_blob = ConnectorCapabilities::new(1).with_max_bpc(MaxBpc {
490            min: 8,
491            max: 12,
492            current: 12,
493        });
494        let signalling = derive_output_signalling(&layers, Some(&no_blob));
495        assert_eq!(signalling.hdr_metadata, None);
496        assert!(signalling.hdr_downgraded);
497        // The gamut still goes out: that half is not gated on HDR.
498        assert_eq!(signalling.colorspace, Some(Colorspace::Bt2020Rgb));
499    }
500
501    #[test]
502    fn a_connector_capped_at_eight_bits_downgrades() {
503        // The metadata would be accepted and the sink would show banded SDR.
504        let layers = [layer(
505            Some(ColorPrimaries::Bt2020),
506            Some(TransferFunction::SmpteSt2084Pq),
507        )];
508        let eight_bit = ConnectorCapabilities::new(1)
509            .with_hdr_output_metadata(true)
510            .with_max_bpc(MaxBpc {
511                min: 6,
512                max: 8,
513                current: 8,
514            });
515        let signalling = derive_output_signalling(&layers, Some(&eight_bit));
516        assert_eq!(signalling.hdr_metadata, None);
517        assert!(signalling.hdr_downgraded);
518    }
519
520    #[test]
521    fn ten_bits_is_enough() {
522        // The boundary, and it is inclusive: ten is where PQ stops banding.
523        let layers = [layer(
524            Some(ColorPrimaries::Bt2020),
525            Some(TransferFunction::SmpteSt2084Pq),
526        )];
527        assert!(
528            derive_output_signalling(&layers, Some(&hdr_capable()))
529                .hdr_metadata
530                .is_some()
531        );
532    }
533
534    #[test]
535    fn an_sdr_frame_is_never_downgraded() {
536        // There was nothing to drop, so the flag must stay false even on a
537        // connector that could not have carried HDR.
538        let layers = [layer(Some(ColorPrimaries::Bt709), None)];
539        let eight_bit = ConnectorCapabilities::new(1).with_max_bpc(MaxBpc {
540            min: 6,
541            max: 8,
542            current: 8,
543        });
544        let signalling = derive_output_signalling(&layers, Some(&eight_bit));
545        assert!(!signalling.hdr_downgraded);
546        assert_eq!(signalling.colorspace, Some(Colorspace::Default));
547    }
548
549    #[test]
550    fn without_capabilities_the_constraint_is_not_checked() {
551        // For a caller that gated up front, and for a test that wants the
552        // derive in isolation.
553        let layers = [layer(
554            Some(ColorPrimaries::Bt2020),
555            Some(TransferFunction::SmpteSt2084Pq),
556        )];
557        let signalling = derive_output_signalling(&layers, None);
558        assert!(signalling.hdr_metadata.is_some());
559        assert!(!signalling.hdr_downgraded);
560    }
561
562    #[test]
563    fn the_defaults_name_nothing() {
564        let params = DisplayParams::default();
565        assert_eq!(params.color_primaries, None);
566        assert_eq!(params.source_eotf, None);
567    }
568}