Skip to main content

damascene_core/
image.rs

1//! App-supplied raster images.
2//!
3//! Apps construct an [`Image`] once (typically as a `LazyLock` over a
4//! decoded byte slice) and embed it in the tree via the [`crate::image`]
5//! builder. Identity is content-hashed: two `Image`s built from the same
6//! pixels share a backend texture-cache slot. Cloning is a cheap `Arc`
7//! bump.
8//!
9//! ```
10//! use std::sync::LazyLock;
11//! use damascene_core::prelude::*;
12//!
13//! static AVATAR: LazyLock<Image> = LazyLock::new(|| {
14//!     // 2x2 RGBA8 placeholder. Real apps decode PNG/JPEG once in
15//!     // their LazyLock body — `damascene-core` deliberately does not pull
16//!     // in image-decoding crates.
17//!     Image::from_rgba8(
18//!         2, 2,
19//!         vec![
20//!             0xff, 0x00, 0x00, 0xff,  0x00, 0xff, 0x00, 0xff,
21//!             0x00, 0x00, 0xff, 0xff,  0xff, 0xff, 0xff, 0xff,
22//!         ],
23//!     )
24//! });
25//!
26//! fn cell() -> El {
27//!     image(AVATAR.clone()).image_fit(ImageFit::Cover).radius(8.0)
28//! }
29//! ```
30//!
31//! Decoding (`png`, `jpeg`, etc.) is intentionally the app's
32//! responsibility — keeps `damascene-core` free of heavy media deps and
33//! lets each app pick its own decoder + colour-space pipeline.
34//!
35//! ## Color management
36//!
37//! Every `Image` carries a [`ColorSpace`] tag describing how its channel
38//! values map to light — like an ICC-tagged image in a browser. The
39//! plain [`from_rgba8`](Image::from_rgba8) constructor tags
40//! [`ColorSpace::SRGB`] (matching the web's untagged-image convention);
41//! the `*_in` constructors accept wide-gamut and HDR sources:
42//!
43//! ```
44//! use damascene_core::color::ColorSpace;
45//! use damascene_core::image::Image;
46//!
47//! // A Display-P3 JPEG decoded to 8-bit RGBA:
48//! let p3 = Image::from_rgba8_in(
49//!     ColorSpace::DISPLAY_P3, 1, 1, vec![0xff, 0x00, 0x00, 0xff],
50//! );
51//! // A linear float HDR source (EXR, Radiance, …):
52//! let hdr = Image::from_rgba_f32_in(
53//!     ColorSpace::SCRGB_LINEAR, 1, 1, vec![4.0, 4.0, 4.0, 1.0],
54//! );
55//! # let _ = (p3, hdr);
56//! ```
57//!
58//! Backends upload 8-bit sRGB images directly (hardware decodes on
59//! sample) and normalize everything else through
60//! [`to_scrgb_f16`](Image::to_scrgb_f16) onto an extended-range float
61//! texture, so wide-gamut and HDR pixels survive to the swapchain
62//! losslessly when the surface is extended-range (see
63//! `docs/COLOR_MANAGEMENT.md`); on SDR surfaces out-of-gamut chroma
64//! clips at the target while over-bright luminance rolls off
65//! gracefully (see [`DynamicRangeLimit`]).
66//!
67//! The luminance contract in one line: **a pixel at the source's
68//! reference white displays at the output's reference white.** PQ
69//! sources are anchored by their tagged
70//! [`reference_luminance_nits`](crate::color::ColorSpace::reference_luminance_nits)
71//! (203 for [`ColorSpace::BT2020_PQ`] per BT.2408 — override the field
72//! if your master is graded differently); everything brighter is HDR
73//! headroom, remastered per [`DynamicRangeLimit`]. See
74//! [`to_scrgb_f16`](Image::to_scrgb_f16) for the full statement.
75
76use std::collections::hash_map::DefaultHasher;
77use std::hash::{Hash, Hasher};
78use std::sync::Arc;
79
80use crate::color::{ColorSpace, Primaries, TransferFunction, decode_transfer, primaries_matrix};
81use crate::tree::Rect;
82
83fn mat3_mul_vec3(m: [[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
84    [
85        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
86        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
87        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
88    ]
89}
90
91/// Channel layout + width of an [`Image`]'s pixel buffer. Always RGBA
92/// interleaved, top-left origin, row-major; variants differ in the
93/// per-channel encoding.
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
95pub enum PixelFormat {
96    /// 8-bit unsigned normalized per channel — 4 bytes per pixel.
97    Rgba8,
98    /// 16-bit unsigned normalized per channel — 8 bytes per pixel
99    /// (e.g. 16-bit PNG).
100    Rgba16,
101    /// IEEE 754 half float per channel — 8 bytes per pixel.
102    RgbaF16,
103    /// f32 per channel — 16 bytes per pixel (e.g. EXR, Radiance HDR).
104    RgbaF32,
105}
106
107impl PixelFormat {
108    pub const fn bytes_per_pixel(self) -> usize {
109        match self {
110            PixelFormat::Rgba8 => 4,
111            PixelFormat::Rgba16 | PixelFormat::RgbaF16 => 8,
112            PixelFormat::RgbaF32 => 16,
113        }
114    }
115}
116
117/// A raster image. RGBA pixels (see [`PixelFormat`]) tagged with the
118/// [`ColorSpace`] they were authored in; top-left origin, row-major.
119/// Cheap `Arc`-backed clone; backends key their texture cache off
120/// [`Self::content_hash`] so two equal `Image`s share a GPU slot.
121#[derive(Clone)]
122pub struct Image {
123    inner: Arc<ImageInner>,
124}
125
126struct ImageInner {
127    /// Raw pixel bytes, native-endian, `width * height *
128    /// format.bytes_per_pixel()` long.
129    pixels: Vec<u8>,
130    width: u32,
131    height: u32,
132    format: PixelFormat,
133    color_space: ColorSpace,
134    content_hash: u64,
135}
136
137impl Image {
138    /// Build from sRGB-encoded RGBA8 pixels — the common case for
139    /// decoded PNG/JPEG art. Panics if `pixels.len() != width * height *
140    /// 4`. Untagged 8-bit sources should use this (the web's convention
141    /// for untagged images is sRGB).
142    pub fn from_rgba8(width: u32, height: u32, pixels: Vec<u8>) -> Self {
143        Self::from_rgba8_in(ColorSpace::SRGB, width, height, pixels)
144    }
145
146    /// Build from RGBA8 pixels authored in `space` — e.g.
147    /// [`ColorSpace::DISPLAY_P3`] for a P3-tagged JPEG. Panics if
148    /// `pixels.len() != width * height * 4`.
149    pub fn from_rgba8_in(space: ColorSpace, width: u32, height: u32, pixels: Vec<u8>) -> Self {
150        Self::new_raw(PixelFormat::Rgba8, space, width, height, pixels)
151    }
152
153    /// Build from 16-bit unsigned-normalized RGBA pixels authored in
154    /// `space` — e.g. a 16-bit PNG. Panics if `pixels.len() != width *
155    /// height * 4` (u16 channel values, not bytes).
156    pub fn from_rgba16_in(space: ColorSpace, width: u32, height: u32, pixels: Vec<u16>) -> Self {
157        Self::check_channel_count("from_rgba16_in", "u16", width, height, pixels.len());
158        let bytes = pixels.iter().flat_map(|v| v.to_ne_bytes()).collect();
159        Self::new_raw(PixelFormat::Rgba16, space, width, height, bytes)
160    }
161
162    /// Build from half-float RGBA pixels given as raw IEEE 754 bit
163    /// patterns (the shape most decoders hand f16 data in) authored in
164    /// `space`. Panics if `bits.len() != width * height * 4`.
165    pub fn from_rgba_f16_bits_in(
166        space: ColorSpace,
167        width: u32,
168        height: u32,
169        bits: Vec<u16>,
170    ) -> Self {
171        Self::check_channel_count(
172            "from_rgba_f16_bits_in",
173            "f16-bit",
174            width,
175            height,
176            bits.len(),
177        );
178        let bytes = bits.iter().flat_map(|v| v.to_ne_bytes()).collect();
179        Self::new_raw(PixelFormat::RgbaF16, space, width, height, bytes)
180    }
181
182    /// Build from f32 RGBA pixels authored in `space` — e.g. a decoded
183    /// EXR in [`ColorSpace::SCRGB_LINEAR`]. Panics if `pixels.len() !=
184    /// width * height * 4`.
185    pub fn from_rgba_f32_in(space: ColorSpace, width: u32, height: u32, pixels: Vec<f32>) -> Self {
186        Self::check_channel_count("from_rgba_f32_in", "f32", width, height, pixels.len());
187        let bytes = pixels.iter().flat_map(|v| v.to_ne_bytes()).collect();
188        Self::new_raw(PixelFormat::RgbaF32, space, width, height, bytes)
189    }
190
191    /// Validate a typed constructor's channel-value count so the panic
192    /// message speaks in the caller's units (channel values, not bytes —
193    /// `new_raw`'s byte assert backstops the internal paths).
194    fn check_channel_count(ctor: &str, unit: &str, width: u32, height: u32, got: usize) {
195        let expected = (width as usize) * (height as usize) * 4;
196        assert_eq!(
197            got, expected,
198            "Image::{ctor}: expected {expected} {unit} channel values ({width}x{height} RGBA), got {got}",
199        );
200    }
201
202    fn new_raw(
203        format: PixelFormat,
204        space: ColorSpace,
205        width: u32,
206        height: u32,
207        pixels: Vec<u8>,
208    ) -> Self {
209        let expected = (width as usize) * (height as usize) * format.bytes_per_pixel();
210        assert_eq!(
211            pixels.len(),
212            expected,
213            "Image: expected {expected} bytes ({width}x{height} {format:?}), got {}",
214            pixels.len(),
215        );
216        let mut h = DefaultHasher::new();
217        width.hash(&mut h);
218        height.hash(&mut h);
219        format.hash(&mut h);
220        space.hash(&mut h);
221        pixels.hash(&mut h);
222        let content_hash = h.finish();
223        Self {
224            inner: Arc::new(ImageInner {
225                pixels,
226                width,
227                height,
228                format,
229                color_space: space,
230                content_hash,
231            }),
232        }
233    }
234
235    pub fn width(&self) -> u32 {
236        self.inner.width
237    }
238
239    pub fn height(&self) -> u32 {
240        self.inner.height
241    }
242
243    pub fn format(&self) -> PixelFormat {
244        self.inner.format
245    }
246
247    /// The color space the pixel values were authored in.
248    pub fn color_space(&self) -> ColorSpace {
249        self.inner.color_space
250    }
251
252    /// Raw pixel bytes, length `width * height *
253    /// format().bytes_per_pixel()`, native-endian. Top-left origin.
254    pub fn pixels(&self) -> &[u8] {
255        &self.inner.pixels
256    }
257
258    /// True when the pixel buffer can upload directly to an 8-bit sRGB
259    /// texture and let the sampler decode — RGBA8 in the default
260    /// [`ColorSpace::SRGB`]. Everything else goes through
261    /// [`Self::to_scrgb_f16`].
262    pub fn is_srgb8(&self) -> bool {
263        self.inner.format == PixelFormat::Rgba8 && self.inner.color_space == ColorSpace::SRGB
264    }
265
266    /// Convert to linear sRGB-primaries extended-range ("scRGB")
267    /// half-float pixels for GPU upload: RGBA interleaved, `width *
268    /// height * 4` raw f16 bit patterns, alpha unchanged (straight, not
269    /// premultiplied — the image shader premultiplies at blend).
270    ///
271    /// This is the working-space representation every renderer
272    /// composites in, so sampling needs no further conversion.
273    /// Wide-gamut primaries land outside `[0, 1]` and HDR brights above
274    /// `1.0`; both survive on float textures.
275    ///
276    /// ## Luminance contract
277    ///
278    /// Working-space `1.0` displays at the output's reference white
279    /// (the renderer scales to the swapchain's encoding, e.g.
280    /// `white_scale` on scRGB). Relative transfers (sRGB, gamma,
281    /// linear) already encode `1.0` = reference white and convert
282    /// as-is. PQ is absolute (signal 1.0 = 10000 nits), so this
283    /// conversion anchors it: a pixel at the source's
284    /// [`reference_luminance_nits`](ColorSpace::reference_luminance_nits)
285    /// (203 for [`ColorSpace::BT2020_PQ`], per BT.2408) converts to
286    /// working-space `1.0`, and a 1000-nit highlight lands at ~4.9× —
287    /// HDR headroom the per-image remaster grades into the panel's
288    /// volume (see [`DynamicRangeLimit`]). HLG is scene-referred and
289    /// currently decodes without an OOTF or anchoring — its contract
290    /// is still open. Note [`crate::color::Color`] conversion does
291    /// *not* anchor PQ (UI colors stay encoding-literal); the anchor
292    /// is an image-pipeline behavior.
293    pub fn to_scrgb_f16(&self) -> Vec<u16> {
294        self.to_scrgb_f16_with_peak().0
295    }
296
297    /// [`Self::to_scrgb_f16`] plus the image's measured content peak:
298    /// the maximum linear RGB channel value over all pixels, in
299    /// working-space units (`1.0` = reference white). For a still image
300    /// this is its effective MaxCLL — backends cache it per texture and
301    /// feed it to the luminance remaster (see
302    /// [`DynamicRangeLimit`] and `docs/COLOR_MANAGEMENT.md`). Alpha is
303    /// ignored (the remaster runs on straight rgb before the blend
304    /// premultiply). Non-finite channel values are skipped.
305    pub fn to_scrgb_f16_with_peak(&self) -> (Vec<u16>, f32) {
306        let inner = &*self.inner;
307        let tf = inner.color_space.transfer;
308        let matrix = (inner.color_space.primaries != Primaries::Srgb)
309            .then(|| primaries_matrix(inner.color_space.primaries, Primaries::Srgb));
310        // PQ decodes to absolute luminance (1.0 = 10000 nits); anchor it
311        // so the source's reference white lands at working-space 1.0.
312        // Relative transfers already put reference white at 1.0. HLG is
313        // scene-referred — its anchoring (OOTF) is still open, see
314        // docs/COLOR_MANAGEMENT.md.
315        let lum_scale = match tf {
316            TransferFunction::Pq => {
317                let r = inner.color_space.reference_luminance_nits;
318                debug_assert!(
319                    r > 0.0,
320                    "Image::to_scrgb_f16: PQ source tagged with \
321                     non-positive reference_luminance_nits ({r}); the \
322                     reference white anchors absolute PQ luminance into \
323                     the working space"
324                );
325                10_000.0 / r
326            }
327            _ => 1.0,
328        };
329        let px = (inner.width as usize) * (inner.height as usize);
330        let mut out = Vec::with_capacity(px * 4);
331        let mut peak = 0.0f32;
332
333        // Stream pixels as f32 RGBA in source encoding, decode the TF
334        // (LUT for the integer formats), change primaries, encode f16.
335        let mut push = |rgba: [f32; 4]| {
336            let lin = match matrix {
337                Some(m) => mat3_mul_vec3(m, [rgba[0], rgba[1], rgba[2]]),
338                None => [rgba[0], rgba[1], rgba[2]],
339            };
340            let lin = [lin[0] * lum_scale, lin[1] * lum_scale, lin[2] * lum_scale];
341            for c in lin {
342                // `max` drops NaN; the finite check drops +inf (a half
343                // float bit pattern decoders do produce).
344                if c.is_finite() {
345                    peak = peak.max(c);
346                }
347            }
348            out.push(half::f16::from_f32(lin[0]).to_bits());
349            out.push(half::f16::from_f32(lin[1]).to_bits());
350            out.push(half::f16::from_f32(lin[2]).to_bits());
351            out.push(half::f16::from_f32(rgba[3]).to_bits());
352        };
353
354        match inner.format {
355            PixelFormat::Rgba8 => {
356                let lut: Vec<f32> = (0..=255u32)
357                    .map(|v| decode_transfer(v as f32 / 255.0, tf))
358                    .collect();
359                for p in inner.pixels.chunks_exact(4) {
360                    push([
361                        lut[p[0] as usize],
362                        lut[p[1] as usize],
363                        lut[p[2] as usize],
364                        p[3] as f32 / 255.0,
365                    ]);
366                }
367            }
368            PixelFormat::Rgba16 => {
369                let lut: Vec<f32> = (0..=65535u32)
370                    .map(|v| decode_transfer(v as f32 / 65535.0, tf))
371                    .collect();
372                for p in inner.pixels.chunks_exact(8) {
373                    let ch = |i: usize| u16::from_ne_bytes([p[i * 2], p[i * 2 + 1]]) as usize;
374                    push([lut[ch(0)], lut[ch(1)], lut[ch(2)], ch(3) as f32 / 65535.0]);
375                }
376            }
377            PixelFormat::RgbaF16 => {
378                for p in inner.pixels.chunks_exact(8) {
379                    let ch = |i: usize| {
380                        half::f16::from_bits(u16::from_ne_bytes([p[i * 2], p[i * 2 + 1]])).to_f32()
381                    };
382                    push([
383                        decode_transfer(ch(0), tf),
384                        decode_transfer(ch(1), tf),
385                        decode_transfer(ch(2), tf),
386                        ch(3),
387                    ]);
388                }
389            }
390            PixelFormat::RgbaF32 => {
391                for p in inner.pixels.chunks_exact(16) {
392                    let ch = |i: usize| {
393                        f32::from_ne_bytes([p[i * 4], p[i * 4 + 1], p[i * 4 + 2], p[i * 4 + 3]])
394                    };
395                    push([
396                        decode_transfer(ch(0), tf),
397                        decode_transfer(ch(1), tf),
398                        decode_transfer(ch(2), tf),
399                        ch(3),
400                    ]);
401                }
402            }
403        }
404        (out, peak)
405    }
406
407    /// Stable hash of `(width, height, format, color_space, pixels)`.
408    /// Backends use this as the key into their per-image texture cache.
409    pub fn content_hash(&self) -> u64 {
410        self.inner.content_hash
411    }
412
413    /// Short hex label for inspection / dump output, e.g.
414    /// `"image:1a2b3c4d"`.
415    pub fn label(&self) -> String {
416        format!("image:{:08x}", self.inner.content_hash as u32)
417    }
418}
419
420impl PartialEq for Image {
421    fn eq(&self, other: &Self) -> bool {
422        // Arc identity → fast path. Fallback to content hash so two
423        // independently constructed `Image`s with equal pixels still
424        // compare equal (matches `SvgIcon`'s hash-driven identity).
425        Arc::ptr_eq(&self.inner, &other.inner)
426            || self.inner.content_hash == other.inner.content_hash
427    }
428}
429
430impl Eq for Image {}
431
432impl std::fmt::Debug for Image {
433    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434        f.debug_struct("Image")
435            .field("width", &self.inner.width)
436            .field("height", &self.inner.height)
437            .field("format", &self.inner.format)
438            .field("color_space", &self.inner.color_space)
439            .field(
440                "content_hash",
441                &format_args!("{:016x}", self.inner.content_hash),
442            )
443            .finish()
444    }
445}
446
447/// How a raster image projects into the rect resolved for its El.
448/// Mirrors CSS `object-fit`. The El rect (after `padding`) is the
449/// "viewport"; the image is the "content".
450#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
451pub enum ImageFit {
452    /// Scale uniformly so the image fits inside the rect, preserving
453    /// aspect ratio. Letterbox bands appear on the side that runs
454    /// short. Default — matches the CSS default for `<img>` in most
455    /// frameworks.
456    #[default]
457    Contain,
458    /// Scale uniformly so the image covers the rect, preserving aspect
459    /// ratio. Excess on the longer axis is clipped via the El's
460    /// scissor (the destination rect can extend past the El's content
461    /// area; `draw_ops` clips it back).
462    Cover,
463    /// Stretch the image to the rect, ignoring aspect ratio.
464    Fill,
465    /// No scaling — paint at the image's natural pixel size, anchored
466    /// top-left within the rect. Excess clips via the scissor.
467    None,
468}
469
470/// How much of the output's HDR headroom an image draw may use.
471/// Mirrors CSS `dynamic-range-limit`.
472///
473/// Backends remaster image content whose measured peak exceeds the
474/// resolved limit: a hue-preserving BT.2390 roll-off maps the image's
475/// luminance range into the limit at sample time, re-derived live when
476/// the output's headroom changes (window moves, HDR toggles). Content
477/// that already fits renders untouched — ordinary SDR art never pays
478/// for this. See `docs/COLOR_MANAGEMENT.md`.
479#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
480pub enum DynamicRangeLimit {
481    /// Tonemap to SDR: the image may not exceed reference white.
482    Standard,
483    /// Bright but bounded: at most [`Self::CONSTRAINED_HIGH_HEADROOM`]×
484    /// reference white (less when the output offers less). For grids /
485    /// feeds of HDR content where full-blast highlights would be
486    /// hostile. (CSS `constrained-high`; the exact ceiling is
487    /// UA-defined there too.)
488    ConstrainedHigh,
489    /// Use the output's full headroom — remaster only what the panel
490    /// cannot show. Default, matching the CSS initial value.
491    #[default]
492    NoLimit,
493}
494
495impl DynamicRangeLimit {
496    /// The `ConstrainedHigh` headroom ceiling, in multiples of
497    /// reference white.
498    pub const CONSTRAINED_HIGH_HEADROOM: f32 = 2.0;
499
500    /// Resolve to a luminance limit in working-space units (multiples
501    /// of reference white), given the output's available `headroom`
502    /// (`target_max / reference`, `1.0` on SDR, `f32::INFINITY` when
503    /// the output declared no maximum).
504    pub fn resolve(self, headroom: f32) -> f32 {
505        let headroom = headroom.max(1.0);
506        match self {
507            DynamicRangeLimit::Standard => 1.0,
508            DynamicRangeLimit::ConstrainedHigh => headroom.min(Self::CONSTRAINED_HIGH_HEADROOM),
509            DynamicRangeLimit::NoLimit => headroom,
510        }
511    }
512}
513
514impl ImageFit {
515    /// Project an image of natural size `(nw, nh)` into `rect` according
516    /// to this fit. The returned rect is where the image should paint;
517    /// for `Cover` / `None` it may extend past `rect` and the caller
518    /// is expected to scissor-clip to `rect`.
519    pub fn project(self, nw: u32, nh: u32, rect: Rect) -> Rect {
520        let nw = (nw as f32).max(1.0);
521        let nh = (nh as f32).max(1.0);
522        match self {
523            ImageFit::Fill => rect,
524            ImageFit::None => Rect::new(rect.x, rect.y, nw, nh),
525            ImageFit::Contain => {
526                let scale = (rect.w / nw).min(rect.h / nh).max(0.0);
527                let w = nw * scale;
528                let h = nh * scale;
529                Rect::new(
530                    rect.x + (rect.w - w) * 0.5,
531                    rect.y + (rect.h - h) * 0.5,
532                    w,
533                    h,
534                )
535            }
536            ImageFit::Cover => {
537                let scale = (rect.w / nw).max(rect.h / nh).max(0.0);
538                let w = nw * scale;
539                let h = nh * scale;
540                Rect::new(
541                    rect.x + (rect.w - w) * 0.5,
542                    rect.y + (rect.h - h) * 0.5,
543                    w,
544                    h,
545                )
546            }
547        }
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    fn rgba(w: u32, h: u32, byte: u8) -> Vec<u8> {
556        vec![byte; (w as usize) * (h as usize) * 4]
557    }
558
559    #[test]
560    fn from_rgba8_validates_buffer_length() {
561        let _ = Image::from_rgba8(2, 2, rgba(2, 2, 0));
562    }
563
564    #[test]
565    #[should_panic(expected = "expected 16 bytes")]
566    fn from_rgba8_panics_on_size_mismatch() {
567        let _ = Image::from_rgba8(2, 2, vec![0; 12]);
568    }
569
570    #[test]
571    fn equal_pixels_share_content_hash() {
572        let a = Image::from_rgba8(4, 4, rgba(4, 4, 0xab));
573        let b = Image::from_rgba8(4, 4, rgba(4, 4, 0xab));
574        assert_eq!(a.content_hash(), b.content_hash());
575        assert_eq!(a, b);
576    }
577
578    #[test]
579    fn different_pixels_get_distinct_hash() {
580        let a = Image::from_rgba8(2, 2, rgba(2, 2, 0x00));
581        let b = Image::from_rgba8(2, 2, rgba(2, 2, 0xff));
582        assert_ne!(a.content_hash(), b.content_hash());
583    }
584
585    #[test]
586    fn same_pixels_different_space_get_distinct_hash() {
587        let a = Image::from_rgba8(2, 2, rgba(2, 2, 0xab));
588        let b = Image::from_rgba8_in(ColorSpace::DISPLAY_P3, 2, 2, rgba(2, 2, 0xab));
589        assert_ne!(a.content_hash(), b.content_hash());
590        assert_ne!(a, b);
591    }
592
593    #[test]
594    fn srgb8_fast_path_predicate() {
595        assert!(Image::from_rgba8(1, 1, rgba(1, 1, 0)).is_srgb8());
596        assert!(!Image::from_rgba8_in(ColorSpace::DISPLAY_P3, 1, 1, rgba(1, 1, 0)).is_srgb8());
597        assert!(!Image::from_rgba_f32_in(ColorSpace::SCRGB_LINEAR, 1, 1, vec![0.0; 4]).is_srgb8());
598    }
599
600    fn f16_val(bits: u16) -> f32 {
601        half::f16::from_bits(bits).to_f32()
602    }
603
604    #[test]
605    fn scrgb_conversion_decodes_srgb_tf() {
606        // sRGB 0xbc (188) ≈ 0.5 linear.
607        let img = Image::from_rgba8(1, 1, vec![188, 188, 188, 255]);
608        let out = img.to_scrgb_f16();
609        assert_eq!(out.len(), 4);
610        for c in &out[..3] {
611            assert!((f16_val(*c) - 0.5).abs() < 0.01, "got {}", f16_val(*c));
612        }
613        assert!((f16_val(out[3]) - 1.0).abs() < 1e-3);
614    }
615
616    #[test]
617    fn scrgb_conversion_preserves_white_across_primaries() {
618        // Pure white is white in every D65 space — the primaries matrix
619        // must preserve it.
620        let img = Image::from_rgba8_in(ColorSpace::DISPLAY_P3, 1, 1, vec![255, 255, 255, 255]);
621        let out = img.to_scrgb_f16();
622        for c in &out[..3] {
623            assert!((f16_val(*c) - 1.0).abs() < 0.01, "got {}", f16_val(*c));
624        }
625    }
626
627    #[test]
628    fn scrgb_conversion_maps_p3_red_out_of_gamut() {
629        // P3 pure red lies outside sRGB: r > 1, g < 0 in scRGB.
630        let img = Image::from_rgba8_in(ColorSpace::DISPLAY_P3, 1, 1, vec![255, 0, 0, 255]);
631        let out = img.to_scrgb_f16();
632        let (r, g) = (f16_val(out[0]), f16_val(out[1]));
633        assert!(r > 1.0, "P3 red r = {r}, expected > 1");
634        assert!(g < 0.0, "P3 red g = {g}, expected < 0");
635    }
636
637    #[test]
638    fn scrgb_conversion_passes_linear_floats_through() {
639        // HDR-bright scRGB float input is already in the target space —
640        // values above 1.0 must survive untouched.
641        let img =
642            Image::from_rgba_f32_in(ColorSpace::SCRGB_LINEAR, 1, 1, vec![4.0, 0.25, 1.0, 0.5]);
643        let out = img.to_scrgb_f16();
644        assert!((f16_val(out[0]) - 4.0).abs() < 0.01);
645        assert!((f16_val(out[1]) - 0.25).abs() < 0.001);
646        assert!((f16_val(out[2]) - 1.0).abs() < 0.001);
647        assert!((f16_val(out[3]) - 0.5).abs() < 0.001);
648    }
649
650    #[test]
651    fn scrgb_conversion_handles_rgba16_and_f16_bits() {
652        // 16-bit mid-gray in linear space: 0.5 exactly.
653        let half_u16 = (0.5f32 * 65535.0) as u16;
654        let img = Image::from_rgba16_in(
655            ColorSpace::SRGB_LINEAR,
656            1,
657            1,
658            vec![half_u16, half_u16, half_u16, 65535],
659        );
660        let out = img.to_scrgb_f16();
661        assert!(
662            (f16_val(out[0]) - 0.5).abs() < 0.001,
663            "got {}",
664            f16_val(out[0])
665        );
666
667        // f16 bit-pattern round trip through a linear space is identity.
668        let bits = half::f16::from_f32(2.5).to_bits();
669        let img = Image::from_rgba_f16_bits_in(
670            ColorSpace::SCRGB_LINEAR,
671            1,
672            1,
673            vec![bits, bits, bits, half::f16::from_f32(1.0).to_bits()],
674        );
675        let out = img.to_scrgb_f16();
676        assert!((f16_val(out[0]) - 2.5).abs() < 0.01);
677    }
678
679    #[test]
680    fn pq_anchors_reference_white_to_working_one() {
681        // PQ encode of 203 nits (the BT.2408 reference white that
682        // BT2020_PQ carries) ≈ signal 0.5807. After anchoring it must
683        // land at working-space 1.0 — i.e. display at the output's
684        // reference white, not 203/10000 = dark.
685        let img = Image::from_rgba_f32_in(
686            ColorSpace::BT2020_PQ,
687            1,
688            1,
689            vec![0.5807, 0.5807, 0.5807, 1.0],
690        );
691        let (out, peak) = img.to_scrgb_f16_with_peak();
692        for c in &out[..3] {
693            assert!((f16_val(*c) - 1.0).abs() < 0.02, "got {}", f16_val(*c));
694        }
695        assert!((peak - 1.0).abs() < 0.02, "got {peak}");
696    }
697
698    #[test]
699    fn pq_peak_signal_lands_at_headroom_above_reference() {
700        // Signal 1.0 = 10000 nits → 10000/203 ≈ 49.3× reference white.
701        // The peak must measure post-anchor so the remaster grades it.
702        let img = Image::from_rgba_f32_in(ColorSpace::BT2020_PQ, 1, 1, vec![1.0, 1.0, 1.0, 1.0]);
703        let (out, peak) = img.to_scrgb_f16_with_peak();
704        let expected = 10_000.0 / 203.0;
705        assert!(
706            (f16_val(out[0]) - expected).abs() / expected < 0.01,
707            "got {}",
708            f16_val(out[0])
709        );
710        assert!((peak - expected).abs() / expected < 0.01, "got {peak}");
711    }
712
713    #[test]
714    fn pq_anchor_honors_overridden_reference_white() {
715        // A master graded to 100-nit diffuse white anchors there.
716        let space = ColorSpace {
717            reference_luminance_nits: 100.0,
718            ..ColorSpace::BT2020_PQ
719        };
720        // PQ encode of 100 nits ≈ signal 0.5081.
721        let img = Image::from_rgba_f32_in(space, 1, 1, vec![0.5081, 0.5081, 0.5081, 1.0]);
722        let (out, _) = img.to_scrgb_f16_with_peak();
723        assert!(
724            (f16_val(out[0]) - 1.0).abs() < 0.02,
725            "got {}",
726            f16_val(out[0])
727        );
728    }
729
730    #[test]
731    fn measured_peak_is_max_linear_channel() {
732        // SDR sources peak at most 1.0; HDR floats report their real max.
733        let (_, peak) = Image::from_rgba8(1, 1, vec![255, 128, 0, 255]).to_scrgb_f16_with_peak();
734        assert!((peak - 1.0).abs() < 1e-3, "got {peak}");
735
736        let img = Image::from_rgba_f32_in(
737            ColorSpace::SCRGB_LINEAR,
738            2,
739            1,
740            vec![0.5, 0.5, 0.5, 1.0, 3.75, 0.25, 1.0, 0.5],
741        );
742        let (_, peak) = img.to_scrgb_f16_with_peak();
743        assert!((peak - 3.75).abs() < 0.01, "got {peak}");
744    }
745
746    #[test]
747    fn measured_peak_skips_non_finite() {
748        let img = Image::from_rgba_f32_in(
749            ColorSpace::SCRGB_LINEAR,
750            1,
751            1,
752            vec![f32::NAN, f32::INFINITY, 2.0, 1.0],
753        );
754        let (_, peak) = img.to_scrgb_f16_with_peak();
755        assert!((peak - 2.0).abs() < 0.01, "got {peak}");
756    }
757
758    #[test]
759    fn dynamic_range_limit_resolves_against_headroom() {
760        use DynamicRangeLimit::*;
761        // 1000-nit panel at 203-nit reference ≈ 4.93× headroom.
762        let h = 1000.0 / 203.0;
763        assert_eq!(Standard.resolve(h), 1.0);
764        assert_eq!(ConstrainedHigh.resolve(h), 2.0);
765        assert_eq!(NoLimit.resolve(h), h);
766        // SDR: everything collapses to 1.0.
767        assert_eq!(NoLimit.resolve(1.0), 1.0);
768        assert_eq!(ConstrainedHigh.resolve(1.0), 1.0);
769        // No declared maximum: NoLimit never remasters.
770        assert_eq!(NoLimit.resolve(f32::INFINITY), f32::INFINITY);
771        // Sub-1.0 (bogus) headroom clamps up.
772        assert_eq!(NoLimit.resolve(0.5), 1.0);
773    }
774
775    #[test]
776    fn fit_contain_letterboxes_horizontally() {
777        // 200x100 image into 400x400 rect: contain → 400x200 centred.
778        let r = ImageFit::Contain.project(200, 100, Rect::new(0.0, 0.0, 400.0, 400.0));
779        assert!((r.w - 400.0).abs() < 0.01);
780        assert!((r.h - 200.0).abs() < 0.01);
781        assert!((r.x - 0.0).abs() < 0.01);
782        assert!((r.y - 100.0).abs() < 0.01);
783    }
784
785    #[test]
786    fn fit_cover_overflows_horizontally() {
787        // 100x200 image into 400x400 rect: cover → 400x800 centred —
788        // overflow above and below the rect, scissor crops.
789        let r = ImageFit::Cover.project(100, 200, Rect::new(0.0, 0.0, 400.0, 400.0));
790        assert!((r.w - 400.0).abs() < 0.01);
791        assert!((r.h - 800.0).abs() < 0.01);
792        assert!((r.y + 200.0).abs() < 0.01);
793    }
794
795    #[test]
796    fn fit_fill_stretches() {
797        let r = ImageFit::Fill.project(100, 200, Rect::new(10.0, 20.0, 300.0, 50.0));
798        assert_eq!(r, Rect::new(10.0, 20.0, 300.0, 50.0));
799    }
800
801    #[test]
802    fn fit_none_uses_natural_size() {
803        let r = ImageFit::None.project(64, 32, Rect::new(10.0, 20.0, 400.0, 400.0));
804        assert_eq!(r, Rect::new(10.0, 20.0, 64.0, 32.0));
805    }
806}