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
76// Lock in full per-item documentation for this module (issue #73).
77#![warn(missing_docs)]
78
79use std::collections::hash_map::DefaultHasher;
80use std::hash::{Hash, Hasher};
81use std::sync::Arc;
82
83use crate::color::{ColorSpace, Primaries, TransferFunction, decode_transfer, primaries_matrix};
84use crate::tree::Rect;
85
86/// IEEE 754 half-float, re-exported from the `half` crate so callers
87/// of [`Image::from_rgba_f16_in`] don't need their own version-matched
88/// `half` dependency.
89pub use half::f16;
90
91fn mat3_mul_vec3(m: [[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
92    [
93        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
94        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
95        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
96    ]
97}
98
99/// Channel layout + width of an [`Image`]'s pixel buffer. Always RGBA
100/// interleaved, top-left origin, row-major; variants differ in the
101/// per-channel encoding.
102#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
103pub enum PixelFormat {
104    /// 8-bit unsigned normalized per channel — 4 bytes per pixel.
105    Rgba8,
106    /// 16-bit unsigned normalized per channel — 8 bytes per pixel
107    /// (e.g. 16-bit PNG).
108    Rgba16,
109    /// IEEE 754 half float per channel — 8 bytes per pixel.
110    RgbaF16,
111    /// f32 per channel — 16 bytes per pixel (e.g. EXR, Radiance HDR).
112    RgbaF32,
113}
114
115impl PixelFormat {
116    /// Bytes per RGBA pixel in this format (4, 8, or 16).
117    pub const fn bytes_per_pixel(self) -> usize {
118        match self {
119            PixelFormat::Rgba8 => 4,
120            PixelFormat::Rgba16 | PixelFormat::RgbaF16 => 8,
121            PixelFormat::RgbaF32 => 16,
122        }
123    }
124}
125
126/// A raster image. RGBA pixels (see [`PixelFormat`]) tagged with the
127/// [`ColorSpace`] they were authored in; top-left origin, row-major.
128/// Cheap `Arc`-backed clone; backends key their texture cache off
129/// [`Self::content_hash`] so two equal `Image`s share a GPU slot.
130#[derive(Clone)]
131pub struct Image {
132    inner: Arc<ImageInner>,
133}
134
135struct ImageInner {
136    /// Raw pixel bytes, native-endian, `width * height *
137    /// format.bytes_per_pixel()` long.
138    pixels: Vec<u8>,
139    width: u32,
140    height: u32,
141    format: PixelFormat,
142    color_space: ColorSpace,
143    content_hash: u64,
144}
145
146impl Image {
147    /// Build from sRGB-encoded RGBA8 pixels — the common case for
148    /// decoded PNG/JPEG art. Panics if `pixels.len() != width * height *
149    /// 4`. Untagged 8-bit sources should use this (the web's convention
150    /// for untagged images is sRGB).
151    pub fn from_rgba8(width: u32, height: u32, pixels: Vec<u8>) -> Self {
152        Self::from_rgba8_in(ColorSpace::SRGB, width, height, pixels)
153    }
154
155    /// Build from RGBA8 pixels authored in `space` — e.g.
156    /// [`ColorSpace::DISPLAY_P3`] for a P3-tagged JPEG. Panics if
157    /// `pixels.len() != width * height * 4`.
158    pub fn from_rgba8_in(space: ColorSpace, width: u32, height: u32, pixels: Vec<u8>) -> Self {
159        Self::new_raw(PixelFormat::Rgba8, space, width, height, pixels)
160    }
161
162    /// Build from sRGB-encoded RGB8 pixels (no alpha channel — opaque
163    /// sources like JPEG or RGB PNG). Expands to RGBA internally with
164    /// alpha = 255; callers don't need to repack rows by hand. Panics
165    /// if `pixels.len() != width * height * 3`.
166    pub fn from_rgb8(width: u32, height: u32, pixels: Vec<u8>) -> Self {
167        Self::from_rgb8_in(ColorSpace::SRGB, width, height, pixels)
168    }
169
170    /// Build from RGB8 pixels (no alpha channel) authored in `space`.
171    /// Expands to RGBA internally with alpha = 255. Panics if
172    /// `pixels.len() != width * height * 3`.
173    pub fn from_rgb8_in(space: ColorSpace, width: u32, height: u32, pixels: Vec<u8>) -> Self {
174        let expected = (width as usize) * (height as usize) * 3;
175        assert!(
176            pixels.len() == expected,
177            "Image::from_rgb8_in: pixel buffer holds {} u8 channel values, expected {} ({}x{} RGB)",
178            pixels.len(),
179            expected,
180            width,
181            height,
182        );
183        let mut rgba = Vec::with_capacity((width as usize) * (height as usize) * 4);
184        for rgb in pixels.chunks_exact(3) {
185            rgba.extend_from_slice(rgb);
186            rgba.push(u8::MAX);
187        }
188        Self::new_raw(PixelFormat::Rgba8, space, width, height, rgba)
189    }
190
191    /// Build from 16-bit unsigned-normalized RGBA pixels authored in
192    /// `space` — e.g. a 16-bit PNG. Panics if `pixels.len() != width *
193    /// height * 4` (u16 channel values, not bytes).
194    pub fn from_rgba16_in(space: ColorSpace, width: u32, height: u32, pixels: Vec<u16>) -> Self {
195        Self::check_channel_count("from_rgba16_in", "u16", width, height, pixels.len());
196        let bytes = pixels.iter().flat_map(|v| v.to_ne_bytes()).collect();
197        Self::new_raw(PixelFormat::Rgba16, space, width, height, bytes)
198    }
199
200    /// Build from typed half-float RGBA pixels authored in `space`.
201    /// The `f16` type is re-exported as [`crate::image::f16`] so
202    /// callers don't need their own version-matched `half` dependency.
203    /// Panics if `pixels.len() != width * height * 4`.
204    ///
205    /// Sources that hand f16 data as raw bit patterns (most decoders)
206    /// can skip the typed layer via [`Self::from_rgba_f16_bits_in`].
207    pub fn from_rgba_f16_in(
208        space: ColorSpace,
209        width: u32,
210        height: u32,
211        pixels: Vec<half::f16>,
212    ) -> Self {
213        Self::check_channel_count("from_rgba_f16_in", "f16", width, height, pixels.len());
214        let bytes = pixels
215            .iter()
216            .flat_map(|v| v.to_bits().to_ne_bytes())
217            .collect();
218        Self::new_raw(PixelFormat::RgbaF16, space, width, height, bytes)
219    }
220
221    /// Build from half-float RGBA pixels given as raw IEEE 754 bit
222    /// patterns (the shape most decoders hand f16 data in) authored in
223    /// `space`. Panics if `bits.len() != width * height * 4`. For
224    /// typed `f16` buffers use [`Self::from_rgba_f16_in`].
225    pub fn from_rgba_f16_bits_in(
226        space: ColorSpace,
227        width: u32,
228        height: u32,
229        bits: Vec<u16>,
230    ) -> Self {
231        Self::check_channel_count(
232            "from_rgba_f16_bits_in",
233            "f16-bit",
234            width,
235            height,
236            bits.len(),
237        );
238        let bytes = bits.iter().flat_map(|v| v.to_ne_bytes()).collect();
239        Self::new_raw(PixelFormat::RgbaF16, space, width, height, bytes)
240    }
241
242    /// Build from f32 RGBA pixels authored in `space` — e.g. a decoded
243    /// EXR in [`ColorSpace::SCRGB_LINEAR`]. Panics if `pixels.len() !=
244    /// width * height * 4`.
245    pub fn from_rgba_f32_in(space: ColorSpace, width: u32, height: u32, pixels: Vec<f32>) -> Self {
246        Self::check_channel_count("from_rgba_f32_in", "f32", width, height, pixels.len());
247        let bytes = pixels.iter().flat_map(|v| v.to_ne_bytes()).collect();
248        Self::new_raw(PixelFormat::RgbaF32, space, width, height, bytes)
249    }
250
251    /// Validate a typed constructor's channel-value count so the panic
252    /// message speaks in the caller's units (channel values, not bytes —
253    /// `new_raw`'s byte assert backstops the internal paths).
254    fn check_channel_count(ctor: &str, unit: &str, width: u32, height: u32, got: usize) {
255        let expected = (width as usize) * (height as usize) * 4;
256        assert_eq!(
257            got, expected,
258            "Image::{ctor}: expected {expected} {unit} channel values ({width}x{height} RGBA), got {got}",
259        );
260    }
261
262    fn new_raw(
263        format: PixelFormat,
264        space: ColorSpace,
265        width: u32,
266        height: u32,
267        pixels: Vec<u8>,
268    ) -> Self {
269        let expected = (width as usize) * (height as usize) * format.bytes_per_pixel();
270        assert_eq!(
271            pixels.len(),
272            expected,
273            "Image: expected {expected} bytes ({width}x{height} {format:?}), got {}",
274            pixels.len(),
275        );
276        let mut h = DefaultHasher::new();
277        width.hash(&mut h);
278        height.hash(&mut h);
279        format.hash(&mut h);
280        space.hash(&mut h);
281        pixels.hash(&mut h);
282        let content_hash = h.finish();
283        Self {
284            inner: Arc::new(ImageInner {
285                pixels,
286                width,
287                height,
288                format,
289                color_space: space,
290                content_hash,
291            }),
292        }
293    }
294
295    /// Image width in pixels.
296    pub fn width(&self) -> u32 {
297        self.inner.width
298    }
299
300    /// Image height in pixels.
301    pub fn height(&self) -> u32 {
302        self.inner.height
303    }
304
305    /// Channel encoding of the pixel buffer.
306    pub fn format(&self) -> PixelFormat {
307        self.inner.format
308    }
309
310    /// The color space the pixel values were authored in.
311    pub fn color_space(&self) -> ColorSpace {
312        self.inner.color_space
313    }
314
315    /// Raw pixel bytes, length `width * height *
316    /// format().bytes_per_pixel()`, native-endian. Top-left origin.
317    pub fn pixels(&self) -> &[u8] {
318        &self.inner.pixels
319    }
320
321    /// True when the pixel buffer can upload directly to an 8-bit sRGB
322    /// texture and let the sampler decode — RGBA8 in the default
323    /// [`ColorSpace::SRGB`]. Everything else goes through
324    /// [`Self::to_scrgb_f16`].
325    pub fn is_srgb8(&self) -> bool {
326        self.inner.format == PixelFormat::Rgba8 && self.inner.color_space == ColorSpace::SRGB
327    }
328
329    /// Convert to linear sRGB-primaries extended-range ("scRGB")
330    /// half-float pixels for GPU upload: RGBA interleaved, `width *
331    /// height * 4` raw f16 bit patterns, alpha unchanged (straight, not
332    /// premultiplied — the image shader premultiplies at blend).
333    ///
334    /// This is the working-space representation every renderer
335    /// composites in, so sampling needs no further conversion.
336    /// Wide-gamut primaries land outside `[0, 1]` and HDR brights above
337    /// `1.0`; both survive on float textures.
338    ///
339    /// ## Luminance contract
340    ///
341    /// Working-space `1.0` displays at the output's reference white
342    /// (the renderer scales to the swapchain's encoding, e.g.
343    /// `white_scale` on scRGB). Relative transfers (sRGB, gamma,
344    /// linear) already encode `1.0` = reference white and convert
345    /// as-is. PQ is absolute (signal 1.0 = 10000 nits), so this
346    /// conversion anchors it: a pixel at the source's
347    /// [`reference_luminance_nits`](ColorSpace::reference_luminance_nits)
348    /// (203 for [`ColorSpace::BT2020_PQ`], per BT.2408) converts to
349    /// working-space `1.0`, and a 1000-nit highlight lands at ~4.9× —
350    /// HDR headroom the per-image remaster grades into the panel's
351    /// volume (see [`DynamicRangeLimit`]). HLG is scene-referred and
352    /// currently decodes without an OOTF or anchoring — its contract
353    /// is still open. Note [`crate::color::Color`] conversion does
354    /// *not* anchor PQ (UI colors stay encoding-literal); the anchor
355    /// is an image-pipeline behavior.
356    pub fn to_scrgb_f16(&self) -> Vec<u16> {
357        self.to_scrgb_f16_with_peak().0
358    }
359
360    /// [`Self::to_scrgb_f16`] plus the image's measured content peak:
361    /// the maximum linear RGB channel value over all pixels, in
362    /// working-space units (`1.0` = reference white). For a still image
363    /// this is its effective MaxCLL — backends cache it per texture and
364    /// feed it to the luminance remaster (see
365    /// [`DynamicRangeLimit`] and `docs/COLOR_MANAGEMENT.md`). Alpha is
366    /// ignored (the remaster runs on straight rgb before the blend
367    /// premultiply). Non-finite channel values are skipped.
368    pub fn to_scrgb_f16_with_peak(&self) -> (Vec<u16>, f32) {
369        let inner = &*self.inner;
370        let tf = inner.color_space.transfer;
371        let matrix = (inner.color_space.primaries != Primaries::Srgb)
372            .then(|| primaries_matrix(inner.color_space.primaries, Primaries::Srgb));
373        // PQ decodes to absolute luminance (1.0 = 10000 nits); anchor it
374        // so the source's reference white lands at working-space 1.0.
375        // Relative transfers already put reference white at 1.0. HLG is
376        // scene-referred — its anchoring (OOTF) is still open, see
377        // docs/COLOR_MANAGEMENT.md.
378        let lum_scale = match tf {
379            TransferFunction::Pq => {
380                let r = inner.color_space.reference_luminance_nits;
381                debug_assert!(
382                    r > 0.0,
383                    "Image::to_scrgb_f16: PQ source tagged with \
384                     non-positive reference_luminance_nits ({r}); the \
385                     reference white anchors absolute PQ luminance into \
386                     the working space"
387                );
388                10_000.0 / r
389            }
390            _ => 1.0,
391        };
392        let px = (inner.width as usize) * (inner.height as usize);
393        let mut out = Vec::with_capacity(px * 4);
394        let mut peak = 0.0f32;
395
396        // Stream pixels as f32 RGBA in source encoding, decode the TF
397        // (LUT for the integer formats), change primaries, encode f16.
398        let mut push = |rgba: [f32; 4]| {
399            let lin = match matrix {
400                Some(m) => mat3_mul_vec3(m, [rgba[0], rgba[1], rgba[2]]),
401                None => [rgba[0], rgba[1], rgba[2]],
402            };
403            let lin = [lin[0] * lum_scale, lin[1] * lum_scale, lin[2] * lum_scale];
404            for c in lin {
405                // `max` drops NaN; the finite check drops +inf (a half
406                // float bit pattern decoders do produce).
407                if c.is_finite() {
408                    peak = peak.max(c);
409                }
410            }
411            out.push(half::f16::from_f32(lin[0]).to_bits());
412            out.push(half::f16::from_f32(lin[1]).to_bits());
413            out.push(half::f16::from_f32(lin[2]).to_bits());
414            out.push(half::f16::from_f32(rgba[3]).to_bits());
415        };
416
417        match inner.format {
418            PixelFormat::Rgba8 => {
419                let lut: Vec<f32> = (0..=255u32)
420                    .map(|v| decode_transfer(v as f32 / 255.0, tf))
421                    .collect();
422                for p in inner.pixels.chunks_exact(4) {
423                    push([
424                        lut[p[0] as usize],
425                        lut[p[1] as usize],
426                        lut[p[2] as usize],
427                        p[3] as f32 / 255.0,
428                    ]);
429                }
430            }
431            PixelFormat::Rgba16 => {
432                let lut: Vec<f32> = (0..=65535u32)
433                    .map(|v| decode_transfer(v as f32 / 65535.0, tf))
434                    .collect();
435                for p in inner.pixels.chunks_exact(8) {
436                    let ch = |i: usize| u16::from_ne_bytes([p[i * 2], p[i * 2 + 1]]) as usize;
437                    push([lut[ch(0)], lut[ch(1)], lut[ch(2)], ch(3) as f32 / 65535.0]);
438                }
439            }
440            PixelFormat::RgbaF16 => {
441                for p in inner.pixels.chunks_exact(8) {
442                    let ch = |i: usize| {
443                        half::f16::from_bits(u16::from_ne_bytes([p[i * 2], p[i * 2 + 1]])).to_f32()
444                    };
445                    push([
446                        decode_transfer(ch(0), tf),
447                        decode_transfer(ch(1), tf),
448                        decode_transfer(ch(2), tf),
449                        ch(3),
450                    ]);
451                }
452            }
453            PixelFormat::RgbaF32 => {
454                for p in inner.pixels.chunks_exact(16) {
455                    let ch = |i: usize| {
456                        f32::from_ne_bytes([p[i * 4], p[i * 4 + 1], p[i * 4 + 2], p[i * 4 + 3]])
457                    };
458                    push([
459                        decode_transfer(ch(0), tf),
460                        decode_transfer(ch(1), tf),
461                        decode_transfer(ch(2), tf),
462                        ch(3),
463                    ]);
464                }
465            }
466        }
467        (out, peak)
468    }
469
470    /// Return this image downscaled so its larger side fits `max_dim`,
471    /// or a cheap (`Arc`-bump) clone when it already fits. An image
472    /// larger than the device's `max_*_dimension_2d` (8192 by default;
473    /// 4096 on some adapters) would fail GPU texture creation and panic
474    /// the process — content the user merely clicked on. Every backend
475    /// calls this with its device limit before upload (issue #78).
476    ///
477    /// When it downscales, the resample runs in linear scRGB f16
478    /// working space (box-average, each source pixel read once) so
479    /// colour averages correctly and HDR brights `>1.0` survive; the
480    /// result is an `RgbaF16` / [`ColorSpace::SCRGB_LINEAR`] image
481    /// regardless of source format, so an oversized 8-bit sRGB source
482    /// uploads through the f16 path — the memory-for-simplicity trade
483    /// of a single resampler. Aspect ratio is preserved, so a source
484    /// oversized on one axis shrinks on both. Logs a `warn` on the
485    /// downscale; the fit case is silent.
486    pub fn downscaled_to_fit(&self, max_dim: u32) -> Image {
487        let (w, h) = (self.inner.width, self.inner.height);
488        if max_dim == 0 || w.max(h) <= max_dim {
489            return self.clone();
490        }
491        let (bits, nw, nh) = downscale_scrgb_f16(&self.to_scrgb_f16(), w, h, max_dim);
492        log::warn!(
493            "image {w}x{h} exceeds the GPU texture dimension limit ({max_dim}); \
494             downscaled to {nw}x{nh} to avoid a create_texture validation failure"
495        );
496        Image::from_rgba_f16_bits_in(ColorSpace::SCRGB_LINEAR, nw, nh, bits)
497    }
498
499    /// Stable hash of `(width, height, format, color_space, pixels)`.
500    /// Backends use this as the key into their per-image texture cache.
501    pub fn content_hash(&self) -> u64 {
502        self.inner.content_hash
503    }
504
505    /// Short hex label for inspection / dump output, e.g.
506    /// `"image:1a2b3c4d"`.
507    pub fn label(&self) -> String {
508        format!("image:{:08x}", self.inner.content_hash as u32)
509    }
510}
511
512/// Box-average downscale of interleaved scRGB f16 RGBA pixels (raw f16
513/// bit patterns, `w * h * 4` long) so the larger side fits `max_dim`,
514/// preserving aspect ratio. Returns the resampled bits and the new
515/// dimensions. Averaging is in linear working space (the f16 values
516/// already are linear), so colour and `>1.0` brights survive; alpha
517/// averages straight. Each source pixel is read exactly once — the cost
518/// is one pass over the original. Only called when downscaling
519/// (`max_dim < max(w, h)`), so every target maps to ≥1 source pixel.
520fn downscale_scrgb_f16(bits: &[u16], w: u32, h: u32, max_dim: u32) -> (Vec<u16>, u32, u32) {
521    let scale = f64::from(max_dim) / f64::from(w.max(h));
522    let nw = ((f64::from(w) * scale).floor() as u32).clamp(1, max_dim);
523    let nh = ((f64::from(h) * scale).floor() as u32).clamp(1, max_dim);
524
525    let decode = |x: u32, y: u32, c: usize| -> f32 {
526        let idx = (y as usize * w as usize + x as usize) * 4 + c;
527        half::f16::from_bits(bits[idx]).to_f32()
528    };
529    // Source-pixel span [s0, s1) covering target index `t` of `n` over a
530    // source extent of `src`. With `src >= n` (downscale) it's non-empty.
531    let span = |t: u32, n: u32, src: u32| {
532        let s0 = (u64::from(t) * u64::from(src) / u64::from(n)) as u32;
533        let s1 = ((u64::from(t + 1) * u64::from(src) / u64::from(n)) as u32).max(s0 + 1);
534        (s0, s1.min(src))
535    };
536
537    let mut out = Vec::with_capacity(nw as usize * nh as usize * 4);
538    for ty in 0..nh {
539        let (sy0, sy1) = span(ty, nh, h);
540        for tx in 0..nw {
541            let (sx0, sx1) = span(tx, nw, w);
542            let mut acc = [0.0f64; 4];
543            let mut n = 0.0f64;
544            for sy in sy0..sy1 {
545                for sx in sx0..sx1 {
546                    for (c, a) in acc.iter_mut().enumerate() {
547                        *a += f64::from(decode(sx, sy, c));
548                    }
549                    n += 1.0;
550                }
551            }
552            for a in acc {
553                out.push(half::f16::from_f32((a / n) as f32).to_bits());
554            }
555        }
556    }
557    (out, nw, nh)
558}
559
560impl PartialEq for Image {
561    fn eq(&self, other: &Self) -> bool {
562        // Arc identity → fast path. Fallback to content hash so two
563        // independently constructed `Image`s with equal pixels still
564        // compare equal (matches `SvgIcon`'s hash-driven identity).
565        Arc::ptr_eq(&self.inner, &other.inner)
566            || self.inner.content_hash == other.inner.content_hash
567    }
568}
569
570impl Eq for Image {}
571
572impl std::fmt::Debug for Image {
573    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
574        f.debug_struct("Image")
575            .field("width", &self.inner.width)
576            .field("height", &self.inner.height)
577            .field("format", &self.inner.format)
578            .field("color_space", &self.inner.color_space)
579            .field(
580                "content_hash",
581                &format_args!("{:016x}", self.inner.content_hash),
582            )
583            .finish()
584    }
585}
586
587/// How a raster image projects into the rect resolved for its El.
588/// Mirrors CSS `object-fit`. The El rect (after `padding`) is the
589/// "viewport"; the image is the "content".
590#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
591pub enum ImageFit {
592    /// Scale uniformly so the image fits inside the rect, preserving
593    /// aspect ratio. Letterbox bands appear on the side that runs
594    /// short. Default — matches the CSS default for `<img>` in most
595    /// frameworks.
596    #[default]
597    Contain,
598    /// Scale uniformly so the image covers the rect, preserving aspect
599    /// ratio. Excess on the longer axis is clipped via the El's
600    /// scissor (the destination rect can extend past the El's content
601    /// area; `draw_ops` clips it back).
602    Cover,
603    /// Stretch the image to the rect, ignoring aspect ratio.
604    Fill,
605    /// No scaling — paint at the image's natural pixel size, anchored
606    /// top-left within the rect. Excess clips via the scissor.
607    None,
608}
609
610/// How much of the output's HDR headroom an image draw may use.
611/// Mirrors CSS `dynamic-range-limit`.
612///
613/// Backends remaster image content whose measured peak exceeds the
614/// resolved limit: a hue-preserving BT.2390 roll-off maps the image's
615/// luminance range into the limit at sample time, re-derived live when
616/// the output's headroom changes (window moves, HDR toggles). Content
617/// that already fits renders untouched — ordinary SDR art never pays
618/// for this. See `docs/COLOR_MANAGEMENT.md`.
619#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
620pub enum DynamicRangeLimit {
621    /// Tonemap to SDR: the image may not exceed reference white.
622    Standard,
623    /// Bright but bounded: at most [`Self::CONSTRAINED_HIGH_HEADROOM`]×
624    /// reference white (less when the output offers less). For grids /
625    /// feeds of HDR content where full-blast highlights would be
626    /// hostile. (CSS `constrained-high`; the exact ceiling is
627    /// UA-defined there too.)
628    ConstrainedHigh,
629    /// Use the output's full headroom — remaster only what the panel
630    /// cannot show. Default, matching the CSS initial value.
631    #[default]
632    NoLimit,
633}
634
635impl DynamicRangeLimit {
636    /// The `ConstrainedHigh` headroom ceiling, in multiples of
637    /// reference white.
638    pub const CONSTRAINED_HIGH_HEADROOM: f32 = 2.0;
639
640    /// Resolve to a luminance limit in working-space units (multiples
641    /// of reference white), given the output's available `headroom`
642    /// (`target_max / reference`, `1.0` on SDR, `f32::INFINITY` when
643    /// the output declared no maximum).
644    pub fn resolve(self, headroom: f32) -> f32 {
645        let headroom = headroom.max(1.0);
646        match self {
647            DynamicRangeLimit::Standard => 1.0,
648            DynamicRangeLimit::ConstrainedHigh => headroom.min(Self::CONSTRAINED_HIGH_HEADROOM),
649            DynamicRangeLimit::NoLimit => headroom,
650        }
651    }
652}
653
654impl ImageFit {
655    /// Project an image of natural size `(nw, nh)` into `rect` according
656    /// to this fit. The returned rect is where the image should paint;
657    /// for `Cover` / `None` it may extend past `rect` and the caller
658    /// is expected to scissor-clip to `rect`.
659    pub fn project(self, nw: u32, nh: u32, rect: Rect) -> Rect {
660        let nw = (nw as f32).max(1.0);
661        let nh = (nh as f32).max(1.0);
662        match self {
663            ImageFit::Fill => rect,
664            ImageFit::None => Rect::new(rect.x, rect.y, nw, nh),
665            ImageFit::Contain => {
666                let scale = (rect.w / nw).min(rect.h / nh).max(0.0);
667                let w = nw * scale;
668                let h = nh * scale;
669                Rect::new(
670                    rect.x + (rect.w - w) * 0.5,
671                    rect.y + (rect.h - h) * 0.5,
672                    w,
673                    h,
674                )
675            }
676            ImageFit::Cover => {
677                let scale = (rect.w / nw).max(rect.h / nh).max(0.0);
678                let w = nw * scale;
679                let h = nh * scale;
680                Rect::new(
681                    rect.x + (rect.w - w) * 0.5,
682                    rect.y + (rect.h - h) * 0.5,
683                    w,
684                    h,
685                )
686            }
687        }
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    fn rgba(w: u32, h: u32, byte: u8) -> Vec<u8> {
696        vec![byte; (w as usize) * (h as usize) * 4]
697    }
698
699    #[test]
700    fn from_rgba8_validates_buffer_length() {
701        let _ = Image::from_rgba8(2, 2, rgba(2, 2, 0));
702    }
703
704    #[test]
705    #[should_panic(expected = "expected 16 bytes")]
706    fn from_rgba8_panics_on_size_mismatch() {
707        let _ = Image::from_rgba8(2, 2, vec![0; 12]);
708    }
709
710    #[test]
711    fn from_rgb8_expands_to_opaque_rgba() {
712        let img = Image::from_rgb8(2, 1, vec![10, 20, 30, 40, 50, 60]);
713        assert_eq!(img.format(), PixelFormat::Rgba8);
714        assert_eq!(img.pixels(), &[10, 20, 30, 255, 40, 50, 60, 255]);
715        // Identity matches the equivalent RGBA8 construction, so both
716        // share a backend texture-cache slot.
717        let rgba = Image::from_rgba8(2, 1, vec![10, 20, 30, 255, 40, 50, 60, 255]);
718        assert_eq!(img.content_hash(), rgba.content_hash());
719    }
720
721    #[test]
722    #[should_panic(expected = "expected 6")]
723    fn from_rgb8_panics_on_size_mismatch() {
724        let _ = Image::from_rgb8(2, 1, vec![0; 8]);
725    }
726
727    #[test]
728    fn from_rgba_f16_typed_matches_bits_constructor() {
729        let values: Vec<f16> = [0.0_f32, 0.25, 0.5, 1.0]
730            .iter()
731            .map(|&v| f16::from_f32(v))
732            .collect();
733        let bits: Vec<u16> = values.iter().map(|v| v.to_bits()).collect();
734        let typed = Image::from_rgba_f16_in(ColorSpace::SCRGB_LINEAR, 1, 1, values);
735        let raw = Image::from_rgba_f16_bits_in(ColorSpace::SCRGB_LINEAR, 1, 1, bits);
736        assert_eq!(typed.format(), PixelFormat::RgbaF16);
737        assert_eq!(typed.content_hash(), raw.content_hash());
738        assert_eq!(typed.pixels(), raw.pixels());
739    }
740
741    #[test]
742    fn equal_pixels_share_content_hash() {
743        let a = Image::from_rgba8(4, 4, rgba(4, 4, 0xab));
744        let b = Image::from_rgba8(4, 4, rgba(4, 4, 0xab));
745        assert_eq!(a.content_hash(), b.content_hash());
746        assert_eq!(a, b);
747    }
748
749    #[test]
750    fn different_pixels_get_distinct_hash() {
751        let a = Image::from_rgba8(2, 2, rgba(2, 2, 0x00));
752        let b = Image::from_rgba8(2, 2, rgba(2, 2, 0xff));
753        assert_ne!(a.content_hash(), b.content_hash());
754    }
755
756    #[test]
757    fn same_pixels_different_space_get_distinct_hash() {
758        let a = Image::from_rgba8(2, 2, rgba(2, 2, 0xab));
759        let b = Image::from_rgba8_in(ColorSpace::DISPLAY_P3, 2, 2, rgba(2, 2, 0xab));
760        assert_ne!(a.content_hash(), b.content_hash());
761        assert_ne!(a, b);
762    }
763
764    #[test]
765    fn srgb8_fast_path_predicate() {
766        assert!(Image::from_rgba8(1, 1, rgba(1, 1, 0)).is_srgb8());
767        assert!(!Image::from_rgba8_in(ColorSpace::DISPLAY_P3, 1, 1, rgba(1, 1, 0)).is_srgb8());
768        assert!(!Image::from_rgba_f32_in(ColorSpace::SCRGB_LINEAR, 1, 1, vec![0.0; 4]).is_srgb8());
769    }
770
771    fn f16_val(bits: u16) -> f32 {
772        half::f16::from_bits(bits).to_f32()
773    }
774
775    #[test]
776    fn scrgb_conversion_decodes_srgb_tf() {
777        // sRGB 0xbc (188) ≈ 0.5 linear.
778        let img = Image::from_rgba8(1, 1, vec![188, 188, 188, 255]);
779        let out = img.to_scrgb_f16();
780        assert_eq!(out.len(), 4);
781        for c in &out[..3] {
782            assert!((f16_val(*c) - 0.5).abs() < 0.01, "got {}", f16_val(*c));
783        }
784        assert!((f16_val(out[3]) - 1.0).abs() < 1e-3);
785    }
786
787    #[test]
788    fn scrgb_conversion_preserves_white_across_primaries() {
789        // Pure white is white in every D65 space — the primaries matrix
790        // must preserve it.
791        let img = Image::from_rgba8_in(ColorSpace::DISPLAY_P3, 1, 1, vec![255, 255, 255, 255]);
792        let out = img.to_scrgb_f16();
793        for c in &out[..3] {
794            assert!((f16_val(*c) - 1.0).abs() < 0.01, "got {}", f16_val(*c));
795        }
796    }
797
798    #[test]
799    fn scrgb_conversion_maps_p3_red_out_of_gamut() {
800        // P3 pure red lies outside sRGB: r > 1, g < 0 in scRGB.
801        let img = Image::from_rgba8_in(ColorSpace::DISPLAY_P3, 1, 1, vec![255, 0, 0, 255]);
802        let out = img.to_scrgb_f16();
803        let (r, g) = (f16_val(out[0]), f16_val(out[1]));
804        assert!(r > 1.0, "P3 red r = {r}, expected > 1");
805        assert!(g < 0.0, "P3 red g = {g}, expected < 0");
806    }
807
808    #[test]
809    fn scrgb_conversion_passes_linear_floats_through() {
810        // HDR-bright scRGB float input is already in the target space —
811        // values above 1.0 must survive untouched.
812        let img =
813            Image::from_rgba_f32_in(ColorSpace::SCRGB_LINEAR, 1, 1, vec![4.0, 0.25, 1.0, 0.5]);
814        let out = img.to_scrgb_f16();
815        assert!((f16_val(out[0]) - 4.0).abs() < 0.01);
816        assert!((f16_val(out[1]) - 0.25).abs() < 0.001);
817        assert!((f16_val(out[2]) - 1.0).abs() < 0.001);
818        assert!((f16_val(out[3]) - 0.5).abs() < 0.001);
819    }
820
821    #[test]
822    fn scrgb_conversion_handles_rgba16_and_f16_bits() {
823        // 16-bit mid-gray in linear space: 0.5 exactly.
824        let half_u16 = (0.5f32 * 65535.0) as u16;
825        let img = Image::from_rgba16_in(
826            ColorSpace::SRGB_LINEAR,
827            1,
828            1,
829            vec![half_u16, half_u16, half_u16, 65535],
830        );
831        let out = img.to_scrgb_f16();
832        assert!(
833            (f16_val(out[0]) - 0.5).abs() < 0.001,
834            "got {}",
835            f16_val(out[0])
836        );
837
838        // f16 bit-pattern round trip through a linear space is identity.
839        let bits = half::f16::from_f32(2.5).to_bits();
840        let img = Image::from_rgba_f16_bits_in(
841            ColorSpace::SCRGB_LINEAR,
842            1,
843            1,
844            vec![bits, bits, bits, half::f16::from_f32(1.0).to_bits()],
845        );
846        let out = img.to_scrgb_f16();
847        assert!((f16_val(out[0]) - 2.5).abs() < 0.01);
848    }
849
850    #[test]
851    fn pq_anchors_reference_white_to_working_one() {
852        // PQ encode of 203 nits (the BT.2408 reference white that
853        // BT2020_PQ carries) ≈ signal 0.5807. After anchoring it must
854        // land at working-space 1.0 — i.e. display at the output's
855        // reference white, not 203/10000 = dark.
856        let img = Image::from_rgba_f32_in(
857            ColorSpace::BT2020_PQ,
858            1,
859            1,
860            vec![0.5807, 0.5807, 0.5807, 1.0],
861        );
862        let (out, peak) = img.to_scrgb_f16_with_peak();
863        for c in &out[..3] {
864            assert!((f16_val(*c) - 1.0).abs() < 0.02, "got {}", f16_val(*c));
865        }
866        assert!((peak - 1.0).abs() < 0.02, "got {peak}");
867    }
868
869    #[test]
870    fn pq_peak_signal_lands_at_headroom_above_reference() {
871        // Signal 1.0 = 10000 nits → 10000/203 ≈ 49.3× reference white.
872        // The peak must measure post-anchor so the remaster grades it.
873        let img = Image::from_rgba_f32_in(ColorSpace::BT2020_PQ, 1, 1, vec![1.0, 1.0, 1.0, 1.0]);
874        let (out, peak) = img.to_scrgb_f16_with_peak();
875        let expected = 10_000.0 / 203.0;
876        assert!(
877            (f16_val(out[0]) - expected).abs() / expected < 0.01,
878            "got {}",
879            f16_val(out[0])
880        );
881        assert!((peak - expected).abs() / expected < 0.01, "got {peak}");
882    }
883
884    #[test]
885    fn pq_anchor_honors_overridden_reference_white() {
886        // A master graded to 100-nit diffuse white anchors there.
887        let space = ColorSpace {
888            reference_luminance_nits: 100.0,
889            ..ColorSpace::BT2020_PQ
890        };
891        // PQ encode of 100 nits ≈ signal 0.5081.
892        let img = Image::from_rgba_f32_in(space, 1, 1, vec![0.5081, 0.5081, 0.5081, 1.0]);
893        let (out, _) = img.to_scrgb_f16_with_peak();
894        assert!(
895            (f16_val(out[0]) - 1.0).abs() < 0.02,
896            "got {}",
897            f16_val(out[0])
898        );
899    }
900
901    #[test]
902    fn measured_peak_is_max_linear_channel() {
903        // SDR sources peak at most 1.0; HDR floats report their real max.
904        let (_, peak) = Image::from_rgba8(1, 1, vec![255, 128, 0, 255]).to_scrgb_f16_with_peak();
905        assert!((peak - 1.0).abs() < 1e-3, "got {peak}");
906
907        let img = Image::from_rgba_f32_in(
908            ColorSpace::SCRGB_LINEAR,
909            2,
910            1,
911            vec![0.5, 0.5, 0.5, 1.0, 3.75, 0.25, 1.0, 0.5],
912        );
913        let (_, peak) = img.to_scrgb_f16_with_peak();
914        assert!((peak - 3.75).abs() < 0.01, "got {peak}");
915    }
916
917    #[test]
918    fn measured_peak_skips_non_finite() {
919        let img = Image::from_rgba_f32_in(
920            ColorSpace::SCRGB_LINEAR,
921            1,
922            1,
923            vec![f32::NAN, f32::INFINITY, 2.0, 1.0],
924        );
925        let (_, peak) = img.to_scrgb_f16_with_peak();
926        assert!((peak - 2.0).abs() < 0.01, "got {peak}");
927    }
928
929    #[test]
930    fn dynamic_range_limit_resolves_against_headroom() {
931        use DynamicRangeLimit::*;
932        // 1000-nit panel at 203-nit reference ≈ 4.93× headroom.
933        let h = 1000.0 / 203.0;
934        assert_eq!(Standard.resolve(h), 1.0);
935        assert_eq!(ConstrainedHigh.resolve(h), 2.0);
936        assert_eq!(NoLimit.resolve(h), h);
937        // SDR: everything collapses to 1.0.
938        assert_eq!(NoLimit.resolve(1.0), 1.0);
939        assert_eq!(ConstrainedHigh.resolve(1.0), 1.0);
940        // No declared maximum: NoLimit never remasters.
941        assert_eq!(NoLimit.resolve(f32::INFINITY), f32::INFINITY);
942        // Sub-1.0 (bogus) headroom clamps up.
943        assert_eq!(NoLimit.resolve(0.5), 1.0);
944    }
945
946    #[test]
947    fn fit_contain_letterboxes_horizontally() {
948        // 200x100 image into 400x400 rect: contain → 400x200 centred.
949        let r = ImageFit::Contain.project(200, 100, Rect::new(0.0, 0.0, 400.0, 400.0));
950        assert!((r.w - 400.0).abs() < 0.01);
951        assert!((r.h - 200.0).abs() < 0.01);
952        assert!((r.x - 0.0).abs() < 0.01);
953        assert!((r.y - 100.0).abs() < 0.01);
954    }
955
956    #[test]
957    fn fit_cover_overflows_horizontally() {
958        // 100x200 image into 400x400 rect: cover → 400x800 centred —
959        // overflow above and below the rect, scissor crops.
960        let r = ImageFit::Cover.project(100, 200, Rect::new(0.0, 0.0, 400.0, 400.0));
961        assert!((r.w - 400.0).abs() < 0.01);
962        assert!((r.h - 800.0).abs() < 0.01);
963        assert!((r.y + 200.0).abs() < 0.01);
964    }
965
966    #[test]
967    fn fit_fill_stretches() {
968        let r = ImageFit::Fill.project(100, 200, Rect::new(10.0, 20.0, 300.0, 50.0));
969        assert_eq!(r, Rect::new(10.0, 20.0, 300.0, 50.0));
970    }
971
972    #[test]
973    fn fit_none_uses_natural_size() {
974        let r = ImageFit::None.project(64, 32, Rect::new(10.0, 20.0, 400.0, 400.0));
975        assert_eq!(r, Rect::new(10.0, 20.0, 64.0, 32.0));
976    }
977
978    #[test]
979    fn downscaled_to_fit_is_a_cheap_clone_when_within_limit() {
980        let img = Image::from_rgba8(4, 4, rgba(4, 4, 0xab));
981        let fitted = img.downscaled_to_fit(8);
982        // Shares the Arc — same content, untouched format/space.
983        assert_eq!(fitted, img);
984        assert_eq!(fitted.format(), PixelFormat::Rgba8);
985        assert_eq!(fitted.color_space(), ColorSpace::SRGB);
986        assert_eq!((fitted.width(), fitted.height()), (4, 4));
987        // max_dim == 0 is a no-op too (never happens with real limits).
988        assert_eq!(img.downscaled_to_fit(0), img);
989    }
990
991    #[test]
992    fn downscaled_to_fit_shrinks_oversized_preserving_aspect() {
993        // 12x4 → max_dim 6: scale 0.5 → 6x2, routed to scRGB f16.
994        let img = Image::from_rgba8(12, 4, rgba(12, 4, 0xff));
995        let fitted = img.downscaled_to_fit(6);
996        assert_eq!((fitted.width(), fitted.height()), (6, 2));
997        assert_eq!(fitted.format(), PixelFormat::RgbaF16);
998        assert_eq!(fitted.color_space(), ColorSpace::SCRGB_LINEAR);
999    }
1000
1001    #[test]
1002    fn downscaled_to_fit_preserves_solid_colour() {
1003        // Solid sRGB mid-gray (0xbc ≈ 0.5 linear): every averaged output
1004        // pixel reproduces it once decoded back to working space.
1005        let img = Image::from_rgba8(16, 16, rgba(16, 16, 0xbc));
1006        let fitted = img.downscaled_to_fit(4);
1007        assert_eq!((fitted.width(), fitted.height()), (4, 4));
1008        let out = fitted.to_scrgb_f16();
1009        assert_eq!(out.len(), 4 * 4 * 4);
1010        for px in out.chunks_exact(4) {
1011            for c in &px[..3] {
1012                assert!((f16_val(*c) - 0.5).abs() < 0.02, "got {}", f16_val(*c));
1013            }
1014            // The helper fills all four channels with 0xbc, so alpha is
1015            // 188/255 straight (not transfer-decoded like rgb).
1016            assert!((f16_val(px[3]) - 188.0 / 255.0).abs() < 0.02);
1017        }
1018    }
1019}