Skip to main content

drmkit_display/
hdr.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Source-side HDR static metadata, and the property blob that carries it.
5//!
6//! A caller describes the content it is emitting -- transfer function,
7//! mastering display primaries and luminance, content light levels -- and this
8//! serializes it into the kernel's `struct hdr_output_metadata`, which the
9//! connector's `HDR_OUTPUT_METADATA` property points at. The kernel turns that
10//! into the HDR `InfoFrame` the sink receives, per CTA-861.3 and SMPTE ST 2086.
11//!
12//! Pointing the property at `0` instead clears HDR signaling and the sink
13//! returns to SDR.
14
15use drmkit_core::{Device, PropertyBlob, Result as CoreResult};
16
17use crate::color::ColorimetryInfo;
18
19/// Serialized size of `struct hdr_output_metadata`.
20///
21/// The struct is a `__u32` followed by a 26-byte infoframe at offset 4, which
22/// pads to 32. Verified equal on x86-64 and aarch64 against the kernel's own
23/// `<drm/drm_mode.h>` -- it is a UAPI struct, so this is fixed rather than
24/// something to compute at build time, but it is worth stating where the
25/// number came from.
26pub const HDR_METADATA_SIZE: usize = 32;
27
28/// Offset of the infoframe within `struct hdr_output_metadata`.
29const INFOFRAME: usize = 4;
30
31/// Static metadata descriptor type 1, CTA-861.3 §6.9.1.
32///
33/// The kernel calls this `HDMI_STATIC_METADATA_TYPE1` in `<linux/hdmi.h>`,
34/// which is not part of the UAPI surface, so the constant is restated here
35/// rather than pulled in.
36const STATIC_METADATA_TYPE1: u8 = 0;
37
38/// Source-side electro-optical transfer function.
39///
40/// The integer values are the kernel's `enum hdmi_eotf`. That enum is
41/// kernel-internal, so the mapping is restated here to keep it stable for
42/// userspace; [`TransferFunction::as_u8`] is the only place it is applied.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44#[non_exhaustive]
45pub enum TransferFunction {
46    /// BT.1886 / sRGB.
47    #[default]
48    TraditionalGammaSdr,
49    /// Traditional gamma, HDR range.
50    TraditionalGammaHdr,
51    /// SMPTE ST 2084 (PQ) -- HDR10 and HDR10+.
52    SmpteSt2084Pq,
53    /// BT.2100 hybrid log-gamma.
54    Bt2100Hlg,
55}
56
57impl TransferFunction {
58    /// The value the kernel's `enum hdmi_eotf` gives this function.
59    #[must_use]
60    pub const fn as_u8(self) -> u8 {
61        match self {
62            Self::TraditionalGammaSdr => 0,
63            Self::TraditionalGammaHdr => 1,
64            Self::SmpteSt2084Pq => 2,
65            Self::Bt2100Hlg => 3,
66        }
67    }
68}
69
70/// What the source is emitting.
71///
72/// Units follow CTA-861.3:
73///
74/// | field | unit |
75/// |---|---|
76/// | `max_display_mastering_luminance` | cd/m², 1 cd/m² steps |
77/// | `min_display_mastering_luminance` | 0.0001 cd/m² steps |
78/// | `max_content_light_level` | cd/m², 1 cd/m² steps |
79/// | `max_frame_average_light_level` | cd/m², 1 cd/m² steps |
80///
81/// This describes the *content*, not the display it is going to. A sink that
82/// cannot reach these levels tone-maps; that is the sink's business, and
83/// misreporting here to flatter it produces worse results, not better.
84#[derive(Debug, Clone, Copy, PartialEq, Default)]
85#[allow(
86    clippy::exhaustive_structs,
87    reason = "built by callers with `..Default::default()`, which a new field does not break"
88)]
89pub struct HdrSourceMetadata {
90    /// Transfer function the content is encoded with.
91    pub eotf: TransferFunction,
92    /// Mastering display primaries and white point.
93    ///
94    /// Named by color here; the serializer reorders to the green/blue/red
95    /// the wire format asks for.
96    pub display_primaries: ColorimetryInfo,
97    /// Peak luminance of the mastering display, cd/m².
98    pub max_display_mastering_luminance: u16,
99    /// Black level of the mastering display, in 0.0001 cd/m² steps.
100    pub min_display_mastering_luminance: u16,
101    /// Brightest pixel in the content, cd/m².
102    pub max_content_light_level: u16,
103    /// Brightest frame average in the content, cd/m².
104    pub max_frame_average_light_level: u16,
105}
106
107/// CIE xy to the wire's 16-bit fixed point: 0.00002 steps, `0xC350` is 1.0.
108///
109/// Out-of-range and NaN both land on a defined value rather than a cast whose
110/// result is whatever the hardware felt like. NaN is tested for explicitly:
111/// `v < 0.0` alone is false for NaN, so it would fall through to the cast.
112fn cie_to_u16(v: f32) -> u16 {
113    if v.is_nan() || v < 0.0 {
114        return 0;
115    }
116    let scaled = (v * 50000.0).round();
117    if scaled >= 65535.0 {
118        return u16::MAX;
119    }
120    // In range and non-negative by the two tests above, so this cannot wrap or
121    // saturate to something surprising.
122    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
123    {
124        scaled as u16
125    }
126}
127
128/// Serialize to the kernel's `struct hdr_output_metadata` byte layout.
129///
130/// The bytes go straight to `create_property_blob`. Written field by field at
131/// measured offsets rather than by casting a `repr(C)` struct: the padding is
132/// part of what gets hashed, so it has to be demonstrably zero rather than
133/// whatever a struct literal left behind.
134///
135/// Primaries are reordered to green, blue, red -- CTA-861.3 §6.9.1.5 -- which
136/// is the one transformation here that is easy to get backwards and impossible
137/// to notice by looking at a working display, since it takes a saturated
138/// wide-gamut source to show the difference.
139#[must_use]
140pub fn serialize_hdr_metadata(src: &HdrSourceMetadata) -> [u8; HDR_METADATA_SIZE] {
141    let mut out = [0u8; HDR_METADATA_SIZE];
142
143    // hdr_output_metadata.metadata_type, native endian like the rest.
144    out[0..4].copy_from_slice(&u32::from(STATIC_METADATA_TYPE1).to_ne_bytes());
145
146    out[INFOFRAME] = src.eotf.as_u8();
147    out[INFOFRAME + 1] = STATIC_METADATA_TYPE1;
148
149    let p = &src.display_primaries;
150    let mut put = |offset: usize, value: u16| {
151        out[offset..offset + 2].copy_from_slice(&value.to_ne_bytes());
152    };
153    // display_primaries[0..3] at infoframe offset 2, then white_point at 12.
154    put(INFOFRAME + 2, cie_to_u16(p.green.x));
155    put(INFOFRAME + 4, cie_to_u16(p.green.y));
156    put(INFOFRAME + 6, cie_to_u16(p.blue.x));
157    put(INFOFRAME + 8, cie_to_u16(p.blue.y));
158    put(INFOFRAME + 10, cie_to_u16(p.red.x));
159    put(INFOFRAME + 12, cie_to_u16(p.red.y));
160    put(INFOFRAME + 14, cie_to_u16(p.white.x));
161    put(INFOFRAME + 16, cie_to_u16(p.white.y));
162
163    put(INFOFRAME + 18, src.max_display_mastering_luminance);
164    put(INFOFRAME + 20, src.min_display_mastering_luminance);
165    put(INFOFRAME + 22, src.max_content_light_level);
166    put(INFOFRAME + 24, src.max_frame_average_light_level);
167
168    // Bytes 30 and 31 are the struct's tail padding and stay zero.
169    out
170}
171
172/// FNV-1a, 64-bit.
173const fn fnv1a_64(data: &[u8]) -> u64 {
174    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
175    let mut i = 0;
176    while i < data.len() {
177        hash ^= data[i] as u64;
178        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
179        i += 1;
180    }
181    hash
182}
183
184/// A stable hash of the serialized metadata.
185///
186/// Identical content hashes identically, and changing any single field changes
187/// it. That is the whole job: it lets a cache tell "the same metadata again"
188/// from "new metadata", so unchanged HDR does not rebuild a kernel blob every
189/// frame.
190#[must_use]
191pub fn hdr_metadata_hash(src: &HdrSourceMetadata) -> u64 {
192    fnv1a_64(&serialize_hdr_metadata(src))
193}
194
195/// What the cache needs of a blob, so it can be tested without a device.
196///
197/// The C++ grows a `synthesize_for_test` on the blob type itself -- a public
198/// constructor that exists only so tests can make one without a DRM fd, and
199/// which production code can call by accident. Naming the requirement instead
200/// keeps the seam in the type system: the cache is generic, tests substitute
201/// their own blob, and there is no back door on the real one.
202pub trait HdrBlob {
203    /// The blob id to write into `HDR_OUTPUT_METADATA`.
204    fn blob_id(&self) -> u32;
205
206    /// Give up the blob without asking the kernel to destroy it.
207    ///
208    /// For session loss, where the descriptor it was created on is gone. See
209    /// [`PropertyBlob::forget`].
210    fn forget(self);
211}
212
213/// A property blob holding serialized HDR metadata, freed when dropped.
214///
215/// The borrow of the device is what makes freeing it safe -- see
216/// [`PropertyBlob`], whose reasoning this inherits along with its lifetime.
217#[derive(Debug)]
218pub struct HdrMetadataBlob<'a> {
219    blob: PropertyBlob<'a>,
220    content_hash: u64,
221}
222
223impl<'a> HdrMetadataBlob<'a> {
224    /// Serialize `src` and hand it to the kernel as a property blob.
225    ///
226    /// # Errors
227    ///
228    /// [`drmkit_core::CoreError`] if the kernel refuses to create the blob.
229    pub fn create(device: &'a Device, src: &HdrSourceMetadata) -> CoreResult<Self> {
230        let bytes = serialize_hdr_metadata(src);
231        Ok(Self {
232            blob: device.create_property_blob(&bytes[..])?,
233            content_hash: fnv1a_64(&bytes),
234        })
235    }
236
237    /// The hash of the metadata this blob was built from.
238    #[must_use]
239    pub const fn content_hash(&self) -> u64 {
240        self.content_hash
241    }
242}
243
244impl HdrBlob for HdrMetadataBlob<'_> {
245    fn blob_id(&self) -> u32 {
246        // Blob ids are u32 in the UAPI; the crate widens them to u64 for
247        // property values, which is where this comes back from.
248        u32::try_from(self.blob.id()).unwrap_or(0)
249    }
250
251    fn forget(self) {
252        self.blob.forget();
253    }
254}