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