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}