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}