From 99b51417005b40f0992d695f56ff3ec645844cd3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:23:00 -0500 Subject: [PATCH] craft-exr: deep data and Cryptomatte samples from PhotoCraft (feature decode, stacked on #992) (#1001) * craft-exr: bounded part listing, flat decoding and writing (feature decode) PhotoCraft's exr-crate wrapper (preview check, per-part and total size limits, file-size plausibility, part ranking, one-part decode) and EffectCraft's streaming channel filter, as one reader that returns a neutral model: part headers with text attributes, and the kept channels in their stored type. * craft-exr: deep data and Cryptomatte samples (feature decode) PhotoCraft's deep OpenEXR reader (NONE, RLE and ZIPS deep scan lines and tiles, every allocation admitted against the byte budget) and its flattening, and its Cryptomatte layer listing and rank decoding, on the decode module. --- Cargo.lock | 1 + crates/exr/Cargo.toml | 4 +- crates/exr/src/cryptomatte_samples.rs | 192 +++++ crates/exr/src/decode.rs | 30 +- crates/exr/src/deep.rs | 853 +++++++++++++++++++++ crates/exr/src/deep_tests.rs | 1015 +++++++++++++++++++++++++ crates/exr/src/lib.rs | 8 + 7 files changed, 2100 insertions(+), 3 deletions(-) create mode 100644 crates/exr/src/cryptomatte_samples.rs create mode 100644 crates/exr/src/deep.rs create mode 100644 crates/exr/src/deep_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9fcbb791..dbc89abd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,6 +1114,7 @@ name = "craft-exr" version = "0.1.0" dependencies = [ "exr", + "flate2", "half", "serde_json", "thiserror 2.0.21", diff --git a/crates/exr/Cargo.toml b/crates/exr/Cargo.toml index 0c0989ba..c2776352 100644 --- a/crates/exr/Cargo.toml +++ b/crates/exr/Cargo.toml @@ -13,10 +13,12 @@ workspace = true [features] # Reading and writing pixels through the `exr` crate (BSD-3-Clause). The naming and Cryptomatte # modules need none of it. -decode = ["dep:exr", "dep:half", "dep:thiserror"] +decode = ["dep:exr", "dep:half", "dep:thiserror", "dep:flate2"] [dependencies] serde_json = { workspace = true } exr = { version = "1.73", default-features = false, optional = true } half = { version = "2", optional = true } thiserror = { workspace = true, optional = true } +# zlib for deep ZIPS chunks, which the `exr` crate does not read. +flate2 = { version = "1", default-features = false, features = ["rust_backend"], optional = true } diff --git a/crates/exr/src/cryptomatte_samples.rs b/crates/exr/src/cryptomatte_samples.rs new file mode 100644 index 00000000..799e5e9a --- /dev/null +++ b/crates/exr/src/cryptomatte_samples.rs @@ -0,0 +1,192 @@ +//! Cryptomatte layers and their samples in a file (feature `decode`), on top of +//! [`crate::cryptomatte`] for the naming and [`crate::decode`] for the pixels. + +use crate::cryptomatte::{attribute_name, keys, layer_streams, parse_manifest, split_stream}; +use crate::decode::{Error, Limits, PartInfo, Result, decode_part, parts}; + +/// One Cryptomatte layer of a file: header facts and the parsed manifest. +#[derive(Debug, Clone, PartialEq)] +pub struct Layer { + /// The part that holds it. + pub part: usize, + /// The `cryptomatte//name` value (`CryptoObject`, …). + pub name: String, + /// The 7-hex-digit metadata key. + pub key: String, + /// The `conversion` attribute (`uint32_to_float32`), when given. + pub conversion: Option, + /// The embedded manifest, (name, hash) sorted by name; empty without one. + pub manifest: Vec<(String, u32)>, + /// The `manif_file` attribute: a sidecar manifest's file name, when given. + pub sidecar_manifest: Option, + /// The rank channel sets, in index order (`crypto_asset00`, `crypto_asset01`, …). + pub streams: Vec, +} + +/// The Cryptomatte layers of the parts, sorted by part, then name. A layer whose channels are +/// not in its part is left out. +pub fn layers_of(parts: &[PartInfo]) -> Vec { + let mut out = Vec::new(); + for p in parts { + let names: Vec<&str> = p.channels.iter().map(|c| c.name.as_str()).collect(); + // Many file-controlled keys can name one layer: find its streams once. + let mut streams_for: std::collections::HashMap> = std::collections::HashMap::new(); + for key in keys(p.text_attributes.iter().map(|(k, _)| k.as_str())) { + let Some(name) = p.text(&attribute_name(&key, "name")) else { continue }; + let streams = streams_for.entry(name.to_string()).or_insert_with(|| layer_streams(names.iter().copied(), name)).clone(); + if streams.is_empty() { + continue; + } + out.push(Layer { + part: p.index, + name: name.to_string(), + conversion: p.text(&attribute_name(&key, "conversion")).map(str::to_string), + manifest: p.text(&attribute_name(&key, "manifest")).map(parse_manifest).unwrap_or_default(), + sidecar_manifest: p.text(&attribute_name(&key, "manif_file")).map(str::to_string), + key, + streams, + }); + } + } + out.sort_by(|a, b| (a.part, &a.name).cmp(&(b.part, &b.name))); + out +} + +/// Every Cryptomatte layer of a file, from its headers. +pub fn layers(bytes: &[u8], limits: &Limits) -> Result> { + Ok(layers_of(&parts(bytes, limits)?)) +} + +/// The samples of one layer: per pixel of the part's data window, its (ID, coverage) pairs with +/// coverage above zero and a finite ID, sorted by descending coverage. +#[derive(Debug, Clone, PartialEq)] +pub struct Ranks { + pub width: u32, + pub height: u32, + pub pixels: Vec>, +} + +/// Decodes the samples of `layer` (from [`layers`]). Only its rank channels are decoded. +pub fn decode(bytes: &[u8], layer: &Layer, limits: &Limits) -> Result { + let all = parts(bytes, limits)?; + let info = all.get(layer.part).ok_or_else(|| Error::Malformed("the Cryptomatte part is missing".into()))?; + let (w, h) = (info.data_window.width, info.data_window.height); + // Each pixel also gets a pair list: its header and up to one (ID, coverage) pair per rank, + // on top of four f32 channels per set. + let ranks = layer.streams.len().saturating_mul(2); + let per_pixel = (layer.streams.len().max(1) as u64) + .saturating_mul(16) + .saturating_add(std::mem::size_of::>() as u64) + .saturating_add((ranks as u64).saturating_mul(std::mem::size_of::<(f32, f32)>() as u64)); + limits.check_bytes(w, h, per_pixel)?; + let wanted = |name: &str| split_stream(name).is_some_and(|s| layer.streams.contains(&s.stream)); + let part = decode_part(bytes, layer.part, &wanted, limits)?; + let npx = w as usize * h as usize; + // Two (ID, coverage) ranks per set: rank `slot × 2 + stream.rank`. + let mut ids: Vec> = vec![Vec::new(); ranks]; + let mut coverages: Vec> = vec![Vec::new(); ranks]; + for c in &part.channels { + let Some(stream) = split_stream(&c.name) else { continue }; + let Some(slot) = layer.streams.iter().position(|s| *s == stream.stream) else { continue }; + let values = c.samples.to_f32(); + if values.len() != npx { + return Err(Error::Malformed("a Cryptomatte channel is smaller than its layer".into())); + } + let bucket = if stream.coverage { &mut coverages } else { &mut ids }; + if let Some(dst) = slot.checked_mul(2).and_then(|r| r.checked_add(stream.rank)).and_then(|r| bucket.get_mut(r)) { + *dst = values; + } + } + let mut pixels = vec![Vec::new(); npx]; + for (p, out) in pixels.iter_mut().enumerate() { + for (id, cov) in ids.iter().zip(&coverages) { + let (id, cov) = (id.get(p).copied().unwrap_or(0.0), cov.get(p).copied().unwrap_or(0.0)); + if cov > 0.0 && id.is_finite() { + out.push((id, cov)); + } + } + out.sort_by(|a, b| b.1.total_cmp(&a.1)); + } + Ok(Ranks { width: w, height: h, pixels }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use exr::meta::attribute::{AttributeValue, Text}; + use exr::prelude::{ + AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, ImageAttributes, IntegerBounds, Layer as ExrLayer, LayerAttributes, + LineOrder, WritableImage, + }; + + use super::*; + use crate::cryptomatte::{id, key}; + + /// A 2 × 1 file with a `CryptoAsset` layer of two rank sets (`crypto_asset00`, `01`) and a + /// beauty RGB, its manifest naming `cube` and `sphere`. + fn file() -> Vec { + let (cube, sphere) = (id("cube"), id("sphere")); + let k = key("CryptoAsset"); + let mut attrs = LayerAttributes::default(); + let mut text = |field: &str, v: String| { + attrs.other.insert(Text::from(attribute_name(&k, field).as_str()), AttributeValue::Text(Text::from(v.as_str()))); + }; + text("name", "CryptoAsset".into()); + text("conversion", "uint32_to_float32".into()); + text("manifest", format!(r#"{{"cube":"{:08x}","sphere":"{:08x}"}}"#, cube.to_bits(), sphere.to_bits())); + let ch = |n: &str, v: [f32; 2]| AnyChannel::new(n, FlatSamples::F32(v.to_vec())); + let list = vec![ + ch("R", [1.0, 1.0]), + ch("G", [1.0, 1.0]), + ch("B", [1.0, 1.0]), + // Pixel 0: cube 0.75, sphere 0.25 (in the second rank); pixel 1: sphere 1. + ch("crypto_asset00.red", [cube, sphere]), + ch("crypto_asset00.green", [0.75, 1.0]), + ch("crypto_asset00.blue", [sphere, 0.0]), + ch("crypto_asset00.alpha", [0.25, 0.0]), + ch("crypto_asset01.R", [0.0, cube]), + ch("crypto_asset01.G", [0.0, 0.0]), + ch("crypto_asset01.B", [f32::NAN, 0.0]), + ch("crypto_asset01.A", [0.5, 0.0]), + ]; + let layer = ExrLayer::new( + (2, 1), + attrs, + Encoding { compression: Compression::ZIP1, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into()), + ); + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (2, 1))), vec![layer]); + let mut out = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).unwrap(); + out.into_inner() + } + + #[test] + fn layers_come_from_the_headers() { + let bytes = file(); + let found = layers(&bytes, &Limits::default()).unwrap(); + let [l] = found.as_slice() else { panic!("{found:?}") }; + assert_eq!( + (l.part, l.name.as_str(), l.key.as_str(), l.conversion.as_deref()), + (0, "CryptoAsset", key("CryptoAsset").as_str(), Some("uint32_to_float32")) + ); + assert_eq!(l.streams, ["crypto_asset00", "crypto_asset01"]); + assert_eq!(l.manifest.iter().map(|(n, _)| n.as_str()).collect::>(), ["cube", "sphere"]); + assert_eq!(l.sidecar_manifest, None); + } + + #[test] + fn ranks_are_sorted_by_coverage_and_skip_empty_and_nan_ids() { + let bytes = file(); + let layer = layers(&bytes, &Limits::default()).unwrap().remove(0); + let r = decode(&bytes, &layer, &Limits::default()).unwrap(); + assert_eq!((r.width, r.height), (2, 1)); + assert_eq!(r.pixels[0], [(id("cube"), 0.75), (id("sphere"), 0.25)]); + assert_eq!(r.pixels[1], [(id("sphere"), 1.0)]); + // The per-pixel lists count against the budget. + let tight = Limits { max_bytes: 2 * 64, ..Limits::default() }; + assert!(matches!(decode(&bytes, &layer, &tight), Err(Error::LimitExceeded(_)))); + let missing = Layer { part: 3, ..layer }; + assert!(matches!(decode(&bytes, &missing, &Limits::default()), Err(Error::Malformed(_)))); + } +} diff --git a/crates/exr/src/decode.rs b/crates/exr/src/decode.rs index c5e90682..2b47f42e 100644 --- a/crates/exr/src/decode.rs +++ b/crates/exr/src/decode.rs @@ -89,6 +89,32 @@ impl Default for Limits { } } +impl Limits { + /// Effectively unlimited (still bounded by overflow checks). + pub fn none() -> Self { + Limits { max_width: u32::MAX, max_height: u32::MAX, max_pixels: u64::MAX, max_bytes: u64::MAX, max_ratio: u64::MAX } + } + + /// Checks a `width × height` buffer of `bytes_per_pixel` against the size limits. + pub(crate) fn check_bytes(&self, width: u32, height: u32, bytes_per_pixel: u64) -> Result<()> { + if width == 0 || height == 0 { + return Err(Error::Malformed(format!("zero-sized picture {width}x{height}"))); + } + if width > self.max_width || height > self.max_height { + return Err(Error::LimitExceeded(format!("{width}x{height} exceeds {}x{}", self.max_width, self.max_height))); + } + let pixels = u64::from(width) * u64::from(height); + if pixels > self.max_pixels { + return Err(Error::LimitExceeded(format!("{pixels} pixels exceed {}", self.max_pixels))); + } + let bytes = pixels.saturating_mul(bytes_per_pixel); + if bytes > self.max_bytes || usize::try_from(bytes).is_err() { + return Err(Error::LimitExceeded(format!("{bytes} bytes exceed {}", self.max_bytes))); + } + Ok(()) + } +} + /// How a channel's samples are stored. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SampleType { @@ -98,7 +124,7 @@ pub enum SampleType { } impl SampleType { - fn of(t: ExrSample) -> Self { + pub(crate) fn of(t: ExrSample) -> Self { match t { ExrSample::F16 => SampleType::F16, ExrSample::F32 => SampleType::F32, @@ -167,7 +193,7 @@ impl PartInfo { /// and goes on: thousands of tiny malformed previews would each cost that allocation. This walks /// the raw headers and checks each preview against its own size. Anything else odd is left to /// the crate to report. -fn check_previews(b: &[u8]) -> Result<()> { +pub(crate) fn check_previews(b: &[u8]) -> Result<()> { if b.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01][..]) { return Ok(()); } diff --git a/crates/exr/src/deep.rs b/crates/exr/src/deep.rs new file mode 100644 index 00000000..b86d497c --- /dev/null +++ b/crates/exr/src/deep.rs @@ -0,0 +1,853 @@ +//! Deep OpenEXR (`deepscanline` / `deeptile`): a variable-length list of samples per pixel, each +//! with its own colour, alpha and depth. The `exr` crate refuses deep headers, so the chunk stream +//! is walked here, per the OpenEXR file layout specification: a deep chunk carries its +//! coordinates, the packed size of the sample-count table, the packed and unpacked sizes of the +//! sample data, then the compressed count table (cumulative within each row of the block) and the +//! compressed sample data (row by row, each row channel by channel). The layout was checked +//! against files written by the OpenEXR library. Deep data permits NONE, RLE, ZIPS and ZIP +//! compression; the first three are read (one scan line per block keeps the count table +//! unambiguous) and ZIP is [`Error::Unsupported`]. +//! +//! [`decode_deep`] returns the samples ([`DeepImage`]); [`flatten`] composites them into a flat +//! picture: samples sorted by Z, front-to-back `over` with premultiplied colour, un-premultiplied +//! into straight alpha (HDR values are kept, not clamped). +//! +//! Every allocation is admitted against [`Limits::max_bytes`] first: the per-pixel sample-count +//! index, each count table with its decompression scratch, and the samples. + +use std::io::{Cursor, Read, Seek, SeekFrom}; + +use exr::meta::attribute::{LevelMode, SampleType as ExrSample}; +use exr::meta::{BlockDescription, MetaData}; +use exr::prelude::Compression; +use half::f16; + +use crate::channels::ColorLayout; +use crate::decode::{Error, Limits, Result, SampleType, check_previews}; + +fn err(e: impl std::fmt::Display) -> Error { + Error::Malformed(e.to_string()) +} + +fn unsup(reason: impl Into) -> Error { + Error::Unsupported(reason.into()) +} + +/// A zeroed buffer of `len` elements, or an error when the allocator refuses it. +fn alloc_zeroed(len: usize) -> Result> { + let mut v = Vec::new(); + v.try_reserve_exact(len).map_err(|_| Error::LimitExceeded(format!("cannot allocate {len} elements")))?; + v.resize(len, T::default()); + Ok(v) +} + +/// One channel of a [`DeepImage`]. +#[derive(Debug, Clone, PartialEq)] +pub struct DeepChannel { + /// The channel name as in the file (`R`, `G`, `B`, `A`, `Z`, …). + pub name: String, + /// The sample type as stored; `samples` always holds the value as f32 (half and float + /// exactly, unsigned integers exactly up to 2^24). + pub sample: SampleType, + /// Every sample of the channel, in pixel order; pixel `i`'s are `counts[i]..counts[i + 1]`. + pub samples: Vec, +} + +/// The samples of a deep part. All channels share the sample layout, so a sample is one index +/// into every channel. +#[derive(Debug, Clone, PartialEq)] +pub struct DeepImage { + pub width: u32, + pub height: u32, + /// The stage the picture opens on: the display window size, when the file declares one the + /// data region fits in and the limits allow (each side at most 32768). `None` otherwise: the + /// picture is the data window alone. + pub stage: Option<(u32, u32)>, + /// Where the data window's (0, 0) sits on the stage; `(0, 0)` without a stage. + pub origin: (i32, i32), + /// The sample channels in file order. + pub channels: Vec, + /// Cumulative sample counts, `width × height + 1` entries: pixel `i` holds the samples + /// `counts[i]..counts[i + 1]` of every channel. + pub counts: Vec, +} + +impl DeepImage { + /// A channel by its exact name. + pub fn channel(&self, name: &str) -> Option<&DeepChannel> { + self.channels.iter().find(|c| c.name == name) + } + + /// The samples of one pixel, as indices into every channel's `samples`. + pub fn sample_range(&self, pixel: usize) -> std::ops::Range { + let end = self.counts.get(pixel + 1).copied().unwrap_or(0); + self.counts.get(pixel).copied().unwrap_or(end)..end + } + + /// The total number of samples over all pixels. + pub fn total_samples(&self) -> u64 { + self.counts.last().copied().unwrap_or(0) + } +} + +/// A flattened deep picture. +#[derive(Debug, Clone, PartialEq)] +pub struct Flat { + pub width: u32, + pub height: u32, + pub layout: ColorLayout, + /// `width × height × layout.channels()` straight-alpha samples, top row first. + pub samples: Vec, + /// `Some(origin)` when the picture is the display-window stage with the data placed at `origin`. + pub stage_origin: Option<(i32, i32)>, + /// The most samples one pixel had. + pub max_samples_per_pixel: u32, +} + +/// Reads the samples of the first deep part. `Err(Unsupported)` when the file has none. +pub fn decode_deep(bytes: &[u8], limits: &Limits) -> Result { + check_previews(bytes)?; + let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?; + decode_deep_meta(&meta, bytes, limits) +} +// --- checked little-endian reads ------------------------------------------------------------- +// Every length and offset comes from the file, so every read is bounds-checked and a short +// read is a malformed file, never a panic. + +fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8]> { + // Borrow packed payloads from the input. Copying them here allocates before count-table + // admission and needlessly duplicates the table even when the second pass just skips it. + let truncated = || err(format!("truncated file while reading {what}")); + let start = usize::try_from(cur.position()).map_err(|_| truncated())?; + let end = start.checked_add(n).ok_or_else(truncated)?; + let bytes = *cur.get_ref(); + let v = bytes.get(start..end).ok_or_else(truncated)?; + cur.set_position(end as u64); + Ok(v) +} + +fn i32(cur: &mut Cursor<&[u8]>) -> Result { + let b = read_exact(cur, 4, "an i32")?; + Ok(i32::from_le_bytes([ + b.first().copied().unwrap_or(0), + b.get(1).copied().unwrap_or(0), + b.get(2).copied().unwrap_or(0), + b.get(3).copied().unwrap_or(0), + ])) +} + +fn u64(cur: &mut Cursor<&[u8]>) -> Result { + let b = read_exact(cur, 8, "a u64")?; + let a: [u8; 8] = b.try_into().unwrap_or([0; 8]); + Ok(u64::from_le_bytes(a)) +} + +fn seek_to(cur: &mut Cursor<&[u8]>, pos: u64) -> Result<()> { + if pos > cur.get_ref().len() as u64 { + return Err(err(format!("chunk offset {pos} points past the end of the file"))); + } + cur.seek(SeekFrom::Start(pos)).map_err(err)?; + Ok(()) +} + +// --- decompression ---------------------------------------------------------------------------- + +/// Undo the ZIP predictor: each byte was stored as its difference to the previous byte plus +/// 128 (so a zero difference is 0x80), modulo 256. Applies to the sample-count table and the +/// sample data alike. +fn differences_to_samples(buf: &mut [u8]) { + if let Some(&first) = buf.first() { + let mut prev = i16::from(first); + for b in buf.iter_mut().skip(1) { + let s = (prev + i16::from(*b) - 128) as u8; + prev = i16::from(s); + *b = s; + } + } +} + +/// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half +/// holding every second byte of the original; weave them back together. The scratch copy is +/// allocated fallibly, so a buffer that passed `max_bytes` but not the allocator is an error. +fn interleave_byte_blocks(buf: &mut [u8]) -> Result<()> { + let half = buf.len().div_ceil(2); + let mut out = alloc_zeroed::(buf.len())?; + let mut k = 0usize; + for i in 0..buf.len() / 2 { + let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied()); + if let (Some(a), Some(b), Some(pair)) = (a, b, out.get_mut(k..k + 2)) { + if let Some(byte) = pair.first_mut() { + *byte = a; + } + if let Some(byte) = pair.get_mut(1) { + *byte = b; + } + } + k += 2; + } + if buf.len() % 2 == 1 && half > 0 { + let last = buf.get(half - 1).copied(); + if let (Some(last), Some(slot)) = (last, out.get_mut(k)) { + *slot = last; + } + } + buf.copy_from_slice(&out); + Ok(()) +} + +/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`, +/// but never reserves past `limit`: callers admit exactly that size against `max_bytes`, and +/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error. +fn reserve_up_to(out: &mut Vec, extra: usize, limit: usize, what: &str) -> Result<()> { + let need = out.len().saturating_add(extra); + if need > limit { + return Err(err(format!("{what}: data expands past {limit} bytes"))); + } + if need > out.capacity() { + let target = out.capacity().saturating_mul(2).max(need).min(limit); + out.try_reserve_exact(target.saturating_sub(out.len())) + .map_err(|_| Error::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?; + } + Ok(()) +} + +/// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative +/// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate). +fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result> { + let mut out = Vec::new(); + reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?; + let mut i = 0; + while i < src.len() { + let count = src[i] as i8; + i += 1; + let n = if count < 0 { (-(count as i32)) as usize } else { count as usize + 1 }; + if out.len().saturating_add(n) > expected { + return Err(err(format!("{what}: RLE data expands past {expected} bytes"))); + } + reserve_up_to(&mut out, n, expected, what)?; + if count < 0 { + let end = i.saturating_add(n); + let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?; + if run.len() != n { + return Err(err(format!("{what}: RLE run points past the compressed data"))); + } + out.extend_from_slice(run); + i = end; + } else { + let b = src.get(i).copied().ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?; + i += 1; + out.resize(out.len() + n, b); + } + } + if out.len() != expected { + return Err(err(format!("{what}: RLE data expands to {} bytes, expected {expected}", out.len()))); + } + Ok(out) +} + +/// Decompress `what` to exactly `expected` bytes. Like flat blocks, a payload that already +/// has the uncompressed length is stored raw whatever the declared compression. +fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result> { + if packed.len() == expected { + let mut out = Vec::new(); + reserve_up_to(&mut out, expected, expected, what)?; + out.extend_from_slice(packed); + return Ok(out); + } + match compression { + Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))), + Compression::RLE => { + // Like ZIPS, RLE runs over the predicted, byte-split stream. + let mut out = rle_decode(packed, expected, what)?; + differences_to_samples(&mut out); + interleave_byte_blocks(&mut out)?; + Ok(out) + } + Compression::ZIP1 => { + // Inflate straight into `out`, grown with `reserve_up_to` rather than by + // `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are + // in, a one-byte read checks that the stream ends there. + let malformed = || err(format!("{what}: zlib data is malformed")); + let mut zlib = flate2::bufread::ZlibDecoder::new(packed); + let mut out = Vec::new(); + let mut filled = 0; + while filled < expected { + if filled == out.len() { + let grow = out.len().max(1 << 13).min(expected - filled); + reserve_up_to(&mut out, grow, expected, what)?; + out.resize(filled + grow, 0); + } + let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?; + if n == 0 { + break; + } + filled += n; + } + out.truncate(filled); + if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 { + return Err(err(format!("{what}: zlib data expands past {expected} bytes"))); + } + if out.len() != expected { + return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len()))); + } + differences_to_samples(&mut out); + interleave_byte_blocks(&mut out)?; + Ok(out) + } + other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))), + } +} + +// --- the deep chunk stream --------------------------------------------------------------------- + +/// One parsed chunk: the block it covers, plus where to find the compressed sample data in +/// the second pass. Its pixels' sample counts live in the decode's global index, so a chunk +/// holds no per-pixel data. +struct DeepChunk { + /// The block's rows within the data window. + rows: std::ops::Range, + /// The block's columns within the data window (clipped to it for an edge tile). + cols: std::ops::Range, + /// File offset of the chunk start. + pos: u64, + /// Size of the compressed sample data in the file. + packed_data: u64, + /// The decompressed sample-data size the file declares (validated against the table). + unpacked_data: u64, +} + +fn sample_bytes(t: ExrSample) -> usize { + match t { + ExrSample::U32 | ExrSample::F32 => 4, + ExrSample::F16 => 2, + } +} + +/// One stored sample as f32. `b` holds [`sample_bytes`] bytes; a short slice reads as zero. +fn sample_value(t: ExrSample, b: &[u8]) -> f32 { + match t { + ExrSample::F16 => b.try_into().map(|a: [u8; 2]| f16::from_le_bytes(a).to_f32()).unwrap_or(0.0), + ExrSample::F32 => b.try_into().map(f32::from_le_bytes).unwrap_or(0.0), + ExrSample::U32 => b.try_into().map(|a: [u8; 4]| u32::from_le_bytes(a) as f32).unwrap_or(0.0), + } +} + +/// [`decode_deep`] on headers read without validation (deep headers fail it). +fn decode_deep_meta(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Result { + let header_index = meta.headers.iter().position(|h| h.deep).ok_or_else(|| unsup("the file has no deep part"))?; + let header = meta.headers.get(header_index).ok_or_else(|| unsup("the file has no deep part"))?; + let compression = header.compression; + if !matches!(compression, Compression::Uncompressed | Compression::RLE | Compression::ZIP1) { + return Err(unsup(format!("deep data with {compression:?} compression (NONE, RLE and ZIPS are supported)"))); + } + + let dw = header.data_window(); + let (w, h) = (dw.size.0, dw.size.1); + if w == 0 || h == 0 { + return Err(err("the data window is empty")); + } + if w > u32::MAX as usize || h > u32::MAX as usize { + return Err(err("the data window is too large")); + } + let y0 = dw.position.1; + let npixels = w.checked_mul(h).ok_or_else(|| err("the data window is too large"))?; + limits.check_bytes(w as u32, h as u32, (header.channels.list.len().max(1) as u64) * 4)?; + // The per-pixel bookkeeping is the cumulative sample-count index the result returns + // (`DeepImage::counts`: one u64 per pixel, plus one), live from the first chunk to the end. + // Charge it before anything is allocated; the count tables and the samples are admitted + // against what it leaves. The data-window check above budgets only four bytes a pixel. + let index_len = npixels.checked_add(1).ok_or_else(|| err("the data window is too large"))?; + let index_bytes = (index_len as u64).checked_mul(8).ok_or_else(|| err("the data window is too large"))?; + if index_bytes > limits.max_bytes { + return Err(Error::LimitExceeded(format!( + "the sample-count index of {w}x{h} deep pixels needs {index_bytes} bytes, exceeding max_bytes {}", + limits.max_bytes + ))); + } + let budget = limits.max_bytes - index_bytes; + + // The display window is the full stage the renderer composed onto (a render pass's data + // window is often its crop). Open on the stage when it plausibly holds the data region: + // sane size for the limits, and the data region inside it. Otherwise fall back to the + // data window alone, as before. + let display = header.shared_attributes.display_window; + let display_size = (display.size.0.max(1) as u32, display.size.1.max(1) as u32); + let (stage, origin) = match display_size { + (sw, sh) + if sw <= 1 << 15 + && sh <= 1 << 15 + && limits.check_bytes(sw, sh, 4).is_ok() + && dw.position.0 >= display.position.0 + && dw.position.1 >= display.position.1 + && dw.position.0.saturating_add(w as i32) <= display.position.0.saturating_add(sw as i32) + && dw.position.1.saturating_add(h as i32) <= display.position.1.saturating_add(sh as i32) => + { + (Some((sw, sh)), (dw.position.0 - display.position.0, dw.position.1 - display.position.1)) + } + _ => (None, (0, 0)), + }; + + let exr_types: Vec = header.channels.list.iter().map(|c| c.sample_type).collect(); + let bytes_per_sample: usize = exr_types.iter().map(|t| sample_bytes(*t)).sum(); + let multipart = meta.requirements.has_multiple_layers; + + // The offset tables follow the headers: `chunk_count` u64 per header, in header order. + // Our part's table starts after the tables of all earlier parts. + let mut table_pos = header_end(bytes)?; + for h_ in meta.headers.iter().take(header_index) { + table_pos = table_pos.saturating_add((h_.chunk_count.min(1 << 24) as u64).saturating_mul(8)); + } + let mut cur = Cursor::new(bytes); + seek_to(&mut cur, table_pos)?; + let mut offsets = Vec::with_capacity(header.chunk_count.min(1 << 20)); + for _ in 0..header.chunk_count { + offsets.push(u64(&mut cur)?); + } + let tables_end = cur.position(); + + // Block geometry. + let (tiles_x, tile_w, tile_h, slots) = match &header.blocks { + BlockDescription::ScanLines => (0usize, w, 1usize, h), // one line per block for NONE/RLE/ZIPS + BlockDescription::Tiles(t) => { + if t.level_mode != LevelMode::Singular { + return Err(unsup("deep mip-map or rip-map tiles (single-level tiles are supported)")); + } + let (tw, th) = (t.tile_size.0.max(1), t.tile_size.1.max(1)); + let tx = w.saturating_add(tw - 1) / tw; + let ty = h.saturating_add(th - 1) / th; + (tx, tw, th, tx.checked_mul(ty).ok_or_else(|| err("tile count overflows"))?) + } + }; + + // Each block has exactly one chunk (a duplicate or a missing one is malformed), so a table + // of any other length is refused here, before the per-block bookkeeping is sized by the + // header's geometry: `seen` and `chunks` stay proportional to the offset table in the file. + if offsets.len() != slots { + return Err(err(format!("the offset table lists {} chunks, the data window needs {slots}", offsets.len()))); + } + // Per-pixel sample counts in `counts[p + 1]`, made cumulative after the first pass. + let mut counts = alloc_zeroed::(index_len)?; + let mut seen = vec![false; slots]; + let mut chunks: Vec = Vec::with_capacity(offsets.len()); + + for &off in &offsets { + if off < tables_end || off >= bytes.len() as u64 { + return Err(err(format!("chunk offset {off} is outside the chunk area of the file"))); + } + seek_to(&mut cur, off)?; + if multipart { + let part = i32(&mut cur)?; + if part < 0 || part as usize != header_index { + return Err(err(format!("chunk declares part {part}, expected {header_index}"))); + } + } + // Block coordinates → the block's pixel bounds and its slot. + let (block_rows, block_cols, slot): (std::ops::Range, std::ops::Range, usize) = match &header.blocks { + BlockDescription::ScanLines => { + let y = i32(&mut cur)?; + let row = match y.checked_sub(y0) { + Some(d) if d >= 0 && (d as usize) < h => d as usize, + _ => return Err(err(format!("scan line block y={y} is outside the data window"))), + }; + (row..row + 1, 0..w, row) + } + BlockDescription::Tiles(_) => { + let (tx, ty, lx, ly) = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?); + if (lx, ly) != (0, 0) { + return Err(err(format!("deep tile at level ({lx},{ly}); only level 0 exists in a single-level file"))); + } + if tx < 0 || ty < 0 || (tx as usize) >= tiles_x || (ty as usize) * tile_h >= h { + return Err(err(format!("tile ({tx},{ty}) is outside the data window"))); + } + let (bx, by) = ((tx as usize) * tile_w, (ty as usize) * tile_h); + let rows = (by + tile_h).min(h); + let cols = (bx + tile_w).min(w); + let slot = (ty as usize).saturating_mul(tiles_x) + tx as usize; + (by..rows, bx..cols, slot) + } + }; + let block_len = block_rows.len().saturating_mul(block_cols.len()); + if seen.get(slot).copied().unwrap_or(true) { + return Err(err(format!("two chunks cover block {slot}"))); + } + if let Some(s) = seen.get_mut(slot) { + *s = true; + } + + let packed_table = u64(&mut cur)?; + let packed_data = u64(&mut cur)?; + let unpacked_data = u64(&mut cur)?; + let remaining = (bytes.len() as u64).saturating_sub(cur.position()); + if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) { + return Err(err("deep chunk sizes point past the end of the file")); + } + let what = "the sample-count table"; + let table_bytes = read_exact(&mut cur, packed_table as usize, what)?; + let expected_table = block_len.checked_mul(4).ok_or_else(|| err("count table overflows"))?; + // A compressed clipped table also needs its scratch copy. The data-window check only + // budgets four bytes per channel per pixel, insufficient for a one-channel image. + // An equal-sized payload takes decompress's raw path, which allocates no scratch. + // Both are admitted against what the sample-count index leaves of `max_bytes`. + let buffers = if table_bytes.len() == expected_table { 1 } else { 2 }; + let clipped_need = (expected_table as u64).saturating_mul(buffers); + if clipped_need > budget { + return Err(Error::LimitExceeded(format!( + "{what} needs {clipped_need} bytes including decompression scratch, on top of the {index_bytes} byte sample-count index, exceeding max_bytes {}", + limits.max_bytes + ))); + } + // An edge tile's table may still be stored at the full tile size (its pixels first, in + // rows of the clipped width, then unused entries), as the OpenEXR library writes it. + // That size comes from the header's tile size alone, which nothing ties to the data + // window, so it is tried only when `max_bytes` has room for the table and for the copy + // that undoes its byte split. Otherwise only the clipped size is read. + let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4)); + let raw = match full_table { + // Scan lines and interior tiles: the full size is the clipped size. + Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?, + Some(full) if (full as u64).saturating_mul(2) <= budget => { + decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))? + } + _ => decompress(compression, table_bytes, expected_table, what).map_err(|_| { + // Four bytes per pixel of the tile, twice: the table and its scratch copy. + let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8); + Error::LimitExceeded(format!( + "{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, on top of the {index_bytes} byte sample-count index, exceeding max_bytes {}", + limits.max_bytes + )) + })?, + }; + + // The table is cumulative within each row of the block (a tile restarts at every row); + // each pixel's own count is the difference to its left neighbour's entry. + let mut entries = raw.as_chunks::<4>().0.iter(); + let mut total = 0u64; + for yy in block_rows.clone() { + let mut prev = 0u32; + for xx in block_cols.clone() { + let v = u32::from_le_bytes(*entries.next().ok_or_else(|| err("the count table is short"))?); + if v < prev { + return Err(err("the sample-count table decreases (malformed cumulative counts)")); + } + let p = yy.saturating_mul(w).saturating_add(xx).saturating_add(1); + if let Some(c) = counts.get_mut(p) { + *c = u64::from(v - prev); + } + prev = v; + } + total = total.checked_add(u64::from(prev)).ok_or_else(|| err("the block's sample count overflows"))?; + } + let expected_data = total.checked_mul(bytes_per_sample as u64).ok_or_else(|| err("the sample data size overflows"))?; + if expected_data != unpacked_data { + return Err(err(format!("the chunk declares {unpacked_data} bytes of sample data, the count table implies {expected_data}"))); + } + chunks.push(DeepChunk { rows: block_rows, cols: block_cols, pos: off, packed_data, unpacked_data }); + } + if seen.iter().any(|s| !s) { + let missing = seen.iter().filter(|s| !**s).count(); + return Err(err(format!("{missing} of {slots} chunks are missing from the offset table"))); + } + + // Global cumulative counts in raster order, in place: pixel `p` holds the samples + // `counts[p]..counts[p + 1]`. + let mut acc = 0u64; + for c in counts.iter_mut() { + acc = acc.checked_add(*c).ok_or_else(|| err("the total sample count overflows"))?; + *c = acc; + } + let total_samples = acc; + let sample_bytes_total = total_samples.checked_mul(exr_types.len() as u64 * 4).ok_or_else(|| err("the sample buffer size overflows"))?; + if sample_bytes_total > budget { + return Err(Error::LimitExceeded(format!( + "deep samples need {sample_bytes_total} bytes on top of the {index_bytes} byte sample-count index, exceeding max_bytes {}", + limits.max_bytes + ))); + } + let total_samples = + usize::try_from(total_samples).map_err(|_| Error::LimitExceeded(format!("{total_samples} deep samples do not fit in memory")))?; + + let mut channels: Vec = Vec::with_capacity(header.channels.list.len()); + for c in &header.channels.list { + channels.push(DeepChannel { name: c.name.to_string(), sample: SampleType::of(c.sample_type), samples: alloc_zeroed::(total_samples)? }); + } + + // Second pass: the compressed sample data, placed row by row (a block's channel data is + // contiguous in the file, but a tile's rows interleave with other tiles' in raster order). + for chunk in &chunks { + seek_to(&mut cur, chunk.pos)?; + if multipart { + let _part = i32(&mut cur)?; + } + match &header.blocks { + BlockDescription::ScanLines => { + let _y = i32(&mut cur)?; + } + BlockDescription::Tiles(_) => { + let _coords = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?); + } + } + let packed_table = u64(&mut cur)?; + let _packed_data = u64(&mut cur)?; + let _unpacked_data = u64(&mut cur)?; + // The count table sits between the sizes and the sample data; skip its bytes. + read_exact(&mut cur, packed_table as usize, "the sample-count table")?; + let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?; + let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?; + // Row by row (a scan line block is one row), and within a row channel by channel, as + // flat tiles are laid out: each pixel's samples are contiguous within its channel. A + // block row is a run of consecutive pixels in raster order, so its samples are the + // contiguous range `counts[first]..counts[last + 1]` of every output channel. + let mut in_off = 0usize; + for yy in chunk.rows.clone() { + let first = yy.saturating_mul(w).saturating_add(chunk.cols.start); + let end = yy.saturating_mul(w).saturating_add(chunk.cols.end); + let index = |p: usize| counts.get(p).copied().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| err("pixel index out of range")); + let (row_from, row_to) = (index(first)?, index(end)?); + let row_total = row_to.checked_sub(row_from).ok_or_else(|| err("the sample-count index decreases"))?; + for (ci, t) in exr_types.iter().enumerate() { + let sb = sample_bytes(*t); + let ch_bytes = row_total.checked_mul(sb).ok_or_else(|| err("channel size overflows"))?; + let stop = in_off.checked_add(ch_bytes).ok_or_else(|| err("channel size overflows"))?; + let src = raw.get(in_off..stop).ok_or_else(|| err("the sample data is short"))?; + in_off = stop; + let out = channels.get_mut(ci).ok_or_else(|| err("channel index out of range"))?; + let dst = out.samples.get_mut(row_from..row_to).ok_or_else(|| err("the sample data is short"))?; + for (slot, b) in dst.iter_mut().zip(src.chunks_exact(sb)) { + *slot = sample_value(*t, b); + } + } + } + } + + Ok(DeepImage { width: w as u32, height: h as u32, stage, origin, channels, counts }) +} + +/// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`] +/// consumes exactly the version field and the headers, so re-reading with a fresh cursor +/// and taking its end position yields it. +fn header_end(bytes: &[u8]) -> Result { + let mut cur = Cursor::new(bytes); + MetaData::read_from_buffered(&mut cur, false).map_err(err)?; + Ok(cur.position()) +} + +/// Composite the deep samples into a flat image: per pixel, samples sorted by Z (stable; the +/// file is assumed messy), front-to-back `over` with premultiplied colour, un-premultiplied +/// into straight alpha at the end. Without any alpha channel the front-most sample wins, +/// opaque. Z, ZBack and other deep channels influence the flat result only through the +/// ordering; volume samples (with ZBack) are approximated as point samples. +pub fn flatten(deep: &DeepImage, limits: &Limits) -> Result { + let npx = deep.width as usize * deep.height as usize; + let has = |n: &str| deep.channel(n).is_some(); + let rgb = has("R") && has("G") && has("B"); + let gray = !rgb && (has("Y") || deep.channels.len() == 1); + let layout = if rgb || gray { + if has("A") || has("AR") || has("AG") || has("AB") { + if rgb { ColorLayout::Rgba } else { ColorLayout::YA } + } else if rgb { + ColorLayout::Rgb + } else { + ColorLayout::Y + } + } else { + return Err(unsup("no R/G/B or Y channels in the deep part")); + }; + let nc = layout.channels(); + // Admit the flattened output at its true size: nc f32 samples per pixel. The deep decode + // admitted the data window at four bytes per pixel, before the flattened channel layout + // was known, so re-check here with the actual layout and f32 sample size. + limits.check_bytes(deep.width, deep.height, nc as u64 * 4)?; + let samples = |n: &str| -> &[f32] { deep.channel(n).map(|c| c.samples.as_slice()).unwrap_or(&[]) }; + let (r, g, b) = (samples("R"), samples("G"), samples("B")); + let (ar, ag, ab) = (samples("AR"), samples("AG"), samples("AB")); + let (a, z) = (samples("A"), samples("Z")); + let y = samples("Y"); + let lum = if y.is_empty() { deep.channels.first().map(|c| c.samples.as_slice()).unwrap_or(&[]) } else { y }; + + let mut out = alloc_zeroed::(npx * nc)?; + let mut order: Vec = Vec::new(); + let mut max_samples = 0u32; + for p in 0..npx { + let range = deep.sample_range(p); + let (s0, n) = (range.start as usize, (range.end - range.start) as usize); + max_samples = max_samples.max(u32::try_from(n).unwrap_or(u32::MAX)); + if n == 0 { + continue; // no samples: transparent black + } + order.clear(); + order.extend(0u32..n as u32); + if !z.is_empty() { + // Stable sort by Z; a NaN depth (invalid) sorts last, deterministically. + order.sort_by(|&i, &j| { + let zi = z.get(s0 + i as usize).copied().unwrap_or(f32::NAN); + let zj = z.get(s0 + j as usize).copied().unwrap_or(f32::NAN); + zi.total_cmp(&zj) + }); + } + let at = |ch: &[f32], i: u32| ch.get(s0 + i as usize).copied().unwrap_or(0.0); + // Per-colour-channel alpha: AR/AG/AB when present, else A, else opaque. + let alpha = |i: u32| -> [f32; 3] { + let s = at(a, i); + [ + if !a.is_empty() && ar.is_empty() { + s + } else if !ar.is_empty() { + at(ar, i) + } else { + 1.0 + }, + if !a.is_empty() && ag.is_empty() { + s + } else if !ag.is_empty() { + at(ag, i) + } else { + 1.0 + }, + if !a.is_empty() && ab.is_empty() { + s + } else if !ab.is_empty() { + at(ab, i) + } else { + 1.0 + }, + ] + }; + let mut cacc = [0f32; 3]; // premultiplied colour + let mut aacc = [0f32; 3]; // accumulated alpha + for &i in &order { + let ai = alpha(i); + let src = if rgb { [at(r, i), at(g, i), at(b, i)] } else { [at(lum, i); 3] }; + let mut covered = true; + for k in 0..3 { + let t = (1.0 - aacc[k]).max(0.0); + cacc[k] += src[k] * t; + aacc[k] += t * ai[k]; + covered &= aacc[k] >= 1.0; + } + if covered { + break; // everything behind is hidden + } + } + let a_out = if a.is_empty() && ar.is_empty() && ag.is_empty() && ab.is_empty() { 1.0 } else { (aacc[0] + aacc[1] + aacc[2]) / 3.0 }; + let a_clamped = a_out.clamp(0.0, 1.0); + // Un-premultiply into straight alpha (the deep colours are premultiplied). + let straight = |c: f32| if a_clamped > 1e-6 { c / a_clamped } else { 0.0 }; + let base = p * nc; + // Written by layout: RGB(A), gray(+A) — a fixed RGBA quadruple would put a luminance + // copy into a GrayA image's alpha slot. + // A fixed array plus a length: this runs once per pixel, so no allocation. + let (vals, len) = match (rgb, nc) { + (true, 4) => ([straight(cacc[0]), straight(cacc[1]), straight(cacc[2]), a_clamped], 4), + (true, _) => ([straight(cacc[0]), straight(cacc[1]), straight(cacc[2]), 0.0], 3), + (false, 2) => ([straight(cacc[0]), a_clamped, 0.0, 0.0], 2), + (false, _) => ([straight(cacc[0]), 0.0, 0.0, 0.0], 1), + }; + for (k, v) in vals.into_iter().take(len).enumerate() { + if let Some(slot) = out.get_mut(base + k) { + *slot = v; + } + } + } + // Open on the full display-window stage when the file declares one: the renderer's data + // window is its crop, and the composition lives on the whole stage. The composed region + // pads to the stage at its offset, transparent elsewhere. + match deep.stage { + Some((sw, sh)) => { + // The stage can be larger than the data window: admit it at its real size first. + limits.check_bytes(sw, sh, nc as u64 * 4)?; + let mut padded = alloc_zeroed::(sw as usize * sh as usize * nc)?; + let row_len = deep.width as usize * nc; + let dx = deep.origin.0.max(0) as usize; + for row in 0..deep.height as usize { + let src = row * row_len; + let dst = (row + deep.origin.1.max(0) as usize) * sw as usize + dx; + if let (Some(d), Some(r)) = (padded.get_mut(dst * nc..dst * nc + row_len), out.get(src..src + row_len)) { + d.copy_from_slice(r); + } + } + Ok(Flat { width: sw, height: sh, layout, samples: padded, stage_origin: Some(deep.origin), max_samples_per_pixel: max_samples }) + } + None => Ok(Flat { width: deep.width, height: deep.height, layout, samples: out, stage_origin: None, max_samples_per_pixel: max_samples }), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use super::*; + use std::io::Write as _; + + #[test] + fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() { + let bytes = [1u8, 2, 3, 4]; + let mut cur = Cursor::new(bytes.as_slice()); + let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes"); + assert_eq!(prefix, &[1, 2, 3]); + assert_eq!(prefix.as_ptr(), bytes.as_ptr()); + assert_eq!(cur.position(), 3); + assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(Error::Malformed(_)))); + assert_eq!(cur.position(), 3); + assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(Error::Malformed(_)))); + cur.set_position(u64::MAX); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(Error::Malformed(_)))); + } + + /// What the writer stores before compressing: the byte split, then the +128 predictor. + fn predicted(raw: &[u8]) -> Vec { + let half = raw.len().div_ceil(2); + let mut out = vec![0u8; raw.len()]; + for (i, &v) in raw.iter().enumerate() { + out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v; + } + let mut prev = 0i16; + for (i, b) in out.iter_mut().enumerate() { + let v = i16::from(*b); + if i > 0 { + *b = (v - prev + 128) as u8; + } + prev = v; + } + out + } + + /// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count + /// table, with the copy that undoes its byte split), so decompression must not reserve + /// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table + /// 4 MiB, so the "admitted" table and its copy took up to three times the table size. + #[test] + fn decompressed_buffers_reserve_no_more_than_expected() { + for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] { + let raw = vec![0u8; n]; + let p = predicted(&raw); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(&p).expect("write to a Vec"); + let zips = z.finish().expect("finish a Vec"); + assert!(decompress(Compression::ZIP1, &zips[..zips.len() - 1], n, "the table").is_err()); + // RLE: the first byte as a literal, then runs of up to 128 copies of the next. + let mut rle = vec![0xFF, p[0]]; + for run in p[1..].chunks(128) { + rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]); + } + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + let out = decompress(compression, packed, n, "the table").expect("decompresses"); + assert_eq!(out, raw, "{compression:?}, {n} bytes"); + assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity()); + } + // One byte short, the same data expands past the expected size and is refused. + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes"); + } + } + } + + /// A size past the limit is malformed data, and a reservation the allocator refuses is an + /// error rather than an abort. + #[test] + fn reservations_past_the_limit_or_the_allocator_are_errors() { + let mut v = Vec::new(); + assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(Error::Malformed(_)))); + assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(Error::LimitExceeded(_)))); + assert_eq!(v.capacity(), 0); + } +} diff --git a/crates/exr/src/deep_tests.rs b/crates/exr/src/deep_tests.rs new file mode 100644 index 00000000..cb172180 --- /dev/null +++ b/crates/exr/src/deep_tests.rs @@ -0,0 +1,1015 @@ +//! Deep EXR, ported from PhotoCraft's tests: a synthetic generator builds deep chunks per the +//! OpenEXR file layout specification (NONE, RLE and ZIPS × scan lines and tiles), the oracle +//! tests check the compositing against hand-computed values, and malformed inputs must error, +//! never panic. PhotoCraft's check against the OpenEXR project's deep test images stays with its +//! corpus. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::Write as _; + +use crate::channels::ColorLayout; +use crate::decode::{Error as CodecError, Limits, SampleType, f16}; +use crate::deep::{Flat, decode_deep as decode_deep_exr, flatten}; + +/// What PhotoCraft's tests read off a flattened picture. +enum Warn { + DeepFlattened, + DisplayWindowStage { origin: (i32, i32) }, +} + +struct Img { + flat: Flat, + warnings: Vec, +} + +impl Img { + fn layout(&self) -> ColorLayout { + self.flat.layout + } + fn sample_type(&self) -> SampleType { + SampleType::F32 + } + fn to_f32_samples(&self) -> Option> { + Some(self.flat.samples.clone()) + } + fn width(&self) -> u32 { + self.flat.width + } + fn height(&self) -> u32 { + self.flat.height + } +} + +/// The flat decode of a deep file, as PhotoCraft's picture decode does it: the part sizes checked +/// first, then the deep samples read and flattened. +fn decode_with(bytes: &[u8], limits: Limits) -> Result { + crate::decode::parts(bytes, &limits)?; + let flat = flatten(&decode_deep_exr(bytes, &limits)?, &limits)?; + let mut warnings = vec![Warn::DeepFlattened]; + if let Some(origin) = flat.stage_origin { + warnings.push(Warn::DisplayWindowStage { origin }); + } + Ok(Img { flat, warnings }) +} + +fn decode(bytes: &[u8]) -> Result { + decode_with(bytes, Limits::default()) +} + +// --------------------------------------------------------------------------------------------- +// A minimal deep-EXR writer, mirroring what the reference files show on disk: +// version 2 | deep bit (and the tile bit for tiles), one header with the mandatory +// attributes in alphabetical order, a chunk offset table, then deep chunks of +// [y | tile coords][u64 packed table][u64 packed data][u64 unpacked data][table][data]. +// The table holds u32 sample counts, cumulative within each row of the block; the sample +// data is grouped per row, then per channel, in pixel order (the layout the OpenEXR library +// writes). NONE stores raw; RLE and ZIPS separate even/odd bytes, apply the +128 predictor, +// then RLE packs runs and ZIPS deflates. + +fn attr(out: &mut Vec, name: &str, ty: &str, payload: &[u8]) { + out.extend_from_slice(name.as_bytes()); + out.push(0); + out.extend_from_slice(ty.as_bytes()); + out.push(0); + out.extend_from_slice(&(payload.len() as i32).to_le_bytes()); + out.extend_from_slice(payload); +} + +fn samples_to_differences(buf: &mut [u8]) { + if let Some(&first) = buf.first() { + let mut prev = i16::from(first); + for b in buf.iter_mut().skip(1) { + let v = i16::from(*b); + *b = (v - prev + 128) as u8; + prev = v; + } + } +} + +fn separate_bytes_fragments(buf: &mut [u8]) { + let copy = buf.to_vec(); + let half = copy.len().div_ceil(2); + for i in 0..copy.len() { + buf[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = copy[i]; + } +} + +fn rle_bytes(src: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < src.len() { + let mut run = 1; + while i + run < src.len() && run < 128 && src[i + run] == src[i] { + run += 1; + } + // OpenEXR tokens: `n >= 0` repeats the next byte n + 1 times, `n < 0` copies -n bytes. + if run > 1 { + out.push((run - 1) as u8); + out.push(src[i]); + i += run; + } else { + let start = i; + i += 1; + while i < src.len() && (i - start) < 127 && src[i] != src[i - 1] { + i += 1; + } + let n = i - start; + out.push((-(n as i32)) as u8); + out.extend_from_slice(&src[start..start + n]); + } + } + out +} + +fn compress(kind: u8, raw: &[u8]) -> Vec { + match kind { + 1 => { + // Like ZIPS, RLE packs the byte-split, predicted stream. + let mut b = raw.to_vec(); + separate_bytes_fragments(&mut b); + samples_to_differences(&mut b); + let c = rle_bytes(&b); + if c.len() < raw.len() { c } else { raw.to_vec() } + } + 2 => { + let mut b = raw.to_vec(); + separate_bytes_fragments(&mut b); + samples_to_differences(&mut b); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + // Writing to a Vec never fails; the encoder errors surface in `finish`. + #[allow(clippy::unwrap_used)] + z.write_all(&b).unwrap(); + #[allow(clippy::unwrap_used)] + let c = z.finish().unwrap(); + if c.len() < raw.len() { c } else { raw.to_vec() } + } + _ => raw.to_vec(), + } +} + +/// One channel: a name (the caller must pass channels in alphabetical order, as chlist +/// requires) and the sample type as in the chlist attribute (0 = u32, 1 = half, 2 = float). +struct Chan { + name: &'static str, + ty: i32, +} + +/// `counts[p]` samples per pixel; `sample(p, ci)` returns channel `ci`'s values for pixel +/// `p` (its length must be `counts[p]`). `tile` switches between deepscanline and deeptile. +#[allow(clippy::too_many_arguments)] +fn gen_deep( + w: usize, + h: usize, + channels: &[Chan], + compression: u8, + tile: Option<(usize, usize)>, + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, +) -> Vec { + gen_deep_tables(w, h, channels, compression, tile, counts, sample, false) +} + +/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile +/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its +/// clipped width), then zeros. +#[allow(clippy::too_many_arguments)] +fn gen_deep_tables( + w: usize, + h: usize, + channels: &[Chan], + compression: u8, + tile: Option<(usize, usize)>, + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, + full_tile_tables: bool, +) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0x0131_2f76u32.to_le_bytes()); + // Single-part deep files (deepscanline and deeptile alike) carry only the deep-data + // flag; the tiling lives in the `tiles` attribute and the `type` string. + out.extend_from_slice(&(2u32 | 0x800).to_le_bytes()); + + let mut chlist = Vec::new(); + for c in channels { + chlist.extend_from_slice(c.name.as_bytes()); + chlist.push(0); + chlist.extend_from_slice(&c.ty.to_le_bytes()); + chlist.push(0); // pLinear + chlist.extend_from_slice(&[0u8; 3]); + chlist.extend_from_slice(&1i32.to_le_bytes()); + chlist.extend_from_slice(&1i32.to_le_bytes()); + } + chlist.push(0); + attr(&mut out, "channels", "chlist", &chlist); + attr(&mut out, "compression", "compression", &[compression]); + // box2i is inclusive: the data window of a w×h image at the origin ends at (w-1, h-1). + let dw: Vec = [0i32, 0, w as i32 - 1, h as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect(); + attr(&mut out, "dataWindow", "box2i", &dw); + attr(&mut out, "displayWindow", "box2i", &dw); + attr(&mut out, "lineOrder", "lineOrder", &[0]); + attr(&mut out, "pixelAspectRatio", "float", &1f32.to_le_bytes()); + attr(&mut out, "screenWindowCenter", "v2f", &[0f32.to_le_bytes(), 0f32.to_le_bytes()].concat()); + attr(&mut out, "screenWindowWidth", "float", &1f32.to_le_bytes()); + if let Some((tw, th)) = tile { + let mut td = Vec::new(); + td.extend_from_slice(&(tw as u32).to_le_bytes()); + td.extend_from_slice(&(th as u32).to_le_bytes()); + td.push(0); // one level, round down + attr(&mut out, "tiles", "tiledesc", &td); + } + attr(&mut out, "type", "string", if tile.is_some() { b"deeptile" } else { b"deepscanline" }); + attr(&mut out, "version", "int", &1i32.to_le_bytes()); + out.push(0); // end of the header + + // The blocks: scan lines (one line each) or the level-0 tiles. + struct Block { + pixels: Vec, + /// Pixels per row: the count table restarts and the sample data is grouped per row. + row_w: usize, + } + let blocks: Vec = match tile { + None => (0..h).map(|y| Block { pixels: (y * w..y * w + w).collect(), row_w: w }).collect(), + Some((tw, th)) => { + let tx = w.div_ceil(tw); + let ty = h.div_ceil(th); + let mut v = Vec::new(); + for by in 0..ty { + for bx in 0..tx { + let mut px = Vec::new(); + for yy in (by * th)..((by * th + th).min(h)) { + for xx in (bx * tw)..((bx * tw + tw).min(w)) { + px.push(yy * w + xx); + } + } + v.push(Block { pixels: px, row_w: (bx * tw + tw).min(w) - bx * tw }); + } + } + v + } + }; + + let table_len = |b: &Block| b.pixels.len() * 4; + let data_len = |b: &Block| -> usize { + let samples: u64 = b.pixels.iter().map(|&p| counts[p] as u64).sum(); + channels.iter().map(|c| samples as usize * if c.ty == 1 { 2 } else { 4 }).sum() + }; + + // Build every chunk, remember the offsets, then write the offset table first. + let mut chunks: Vec<(usize, Vec)> = Vec::new(); // (block index, framed chunk) + for (bi, b) in blocks.iter().enumerate() { + let mut c = Vec::new(); + match tile { + None => { + let y = (bi % h) as i32; + c.extend_from_slice(&y.to_le_bytes()); + } + Some((tw, _)) => { + let tx = w.div_ceil(tw); + let (bx, by) = (bi % tx, bi / tx); + c.extend_from_slice(&(bx as i32).to_le_bytes()); + c.extend_from_slice(&(by as i32).to_le_bytes()); + c.extend_from_slice(&0i32.to_le_bytes()); + c.extend_from_slice(&0i32.to_le_bytes()); + } + } + let mut table = Vec::with_capacity(b.pixels.len() * 4); + for row in b.pixels.chunks(b.row_w) { + let mut acc = 0u32; + for &p in row { + acc += counts[p]; + table.extend_from_slice(&acc.to_le_bytes()); + } + } + assert_eq!(table.len(), table_len(b)); + if let (true, Some((tw, th))) = (full_tile_tables, tile) { + table.resize(tw * th * 4, 0); + } + let mut data = Vec::new(); + for row in b.pixels.chunks(b.row_w) { + for (ci, ch) in channels.iter().enumerate() { + for &p in row { + for v in sample(p, ci) { + match ch.ty { + 1 => data.extend_from_slice(&f16::from_f32(v).to_le_bytes()), + 0 => data.extend_from_slice(&(v as u32).to_le_bytes()), + _ => data.extend_from_slice(&v.to_le_bytes()), + } + } + } + } + } + let ct = compress(compression, &table); + let cd = compress(compression, &data); + c.extend_from_slice(&(ct.len() as u64).to_le_bytes()); + c.extend_from_slice(&(cd.len() as u64).to_le_bytes()); + c.extend_from_slice(&(data.len() as u64).to_le_bytes()); + c.extend_from_slice(&ct); + c.extend_from_slice(&cd); + assert_eq!(data.len(), data_len(b)); + chunks.push((bi, c)); + } + let mut pos = out.len() + chunks.len() * 8; + let mut table = Vec::new(); + for (_, c) in &chunks { + table.extend_from_slice(&(pos as u64).to_le_bytes()); + pos += c.len(); + } + out.extend_from_slice(&table); + for (_, c) in chunks { + out.extend_from_slice(&c); + } + out +} + +/// RGBA float channels in chlist (alphabetical) order, no Z (everything composites). +const RGBA_NO_Z: [Chan; 4] = [Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }]; + +/// RGBA(Z) float channels in chlist (alphabetical) order. +const RGBAZ: [Chan; 5] = + [Chan { name: "A", ty: 2 }, Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }, Chan { name: "Z", ty: 2 }]; + +fn sample_rgba(p: usize, ci: usize) -> Vec { + // One sample per pixel: a small checkerboard-ish pattern with varying depth. Deep + // colours are premultiplied, so the stored RGB values include the 0.5 alpha. + let v = [0.25f32, 0.5, 0.75, 1.0][p % 4]; + match ci { + 0 => vec![0.5], // A + 1 => vec![0.125 * v], // B = straight 0.25v, premultiplied + 2 => vec![0.25 * v], // G = straight 0.5v, premultiplied + 3 => vec![0.5 * v], // R = straight v, premultiplied + _ => vec![10.0 + p as f32], // Z + } +} + +const COUNTS_ONE: &[u32] = &[1; 16]; // for a 4x4 image + +fn rgba_flat(bytes: &[u8]) -> Vec { + let img = decode(bytes).expect("decodes"); + let s = img.to_f32_samples().expect("f32"); + assert_eq!(img.layout(), ColorLayout::Rgba); + assert_eq!(img.sample_type(), SampleType::F32); + assert!(img.warnings.iter().any(|w| matches!(w, Warn::DeepFlattened))); + s +} + +// --------------------------------------------------------------------------------------------- +// Oracles + +/// One sample per pixel: the flat image is the stored colour un-premultiplied. +#[test] +fn single_sample_matches_flat() { + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba); + let px = rgba_flat(&bytes); + for p in 0..16 { + let v = [0.25f32, 0.5, 0.75, 1.0][p % 4]; + let (r, g, b, a) = (px[p * 4], px[p * 4 + 1], px[p * 4 + 2], px[p * 4 + 3]); + assert!((r - v).abs() < 1e-6, "r at {p}: {r}"); + assert!((g - 0.5 * v).abs() < 1e-6, "g at {p}: {g}"); + assert!((b - 0.25 * v).abs() < 1e-6, "b at {p}: {b}"); + assert!((a - 0.5).abs() < 1e-6, "a at {p}: {a}"); + } +} + +/// Two samples stored back-to-front: the front (smaller Z) covers the back. Hand-computed: +/// front a=0.5, premultiplied colour (0.5, 0.25, 0); back a=0.25, premultiplied (0.25, 0.25, 0.25). +/// Over (front first): a = 0.5 + (1−0.5)·0.25 = 0.625; c = (0.5 + 0.5·0.25, 0.25 + 0.5·0.25, +/// 0 + 0.5·0.25) = (0.625, 0.375, 0.125); straight = c / a = (1.0, 0.6, 0.2). +#[test] +fn z_order_composites_front_over_back() { + // pixel 0: samples [(z=9, back), (z=1, front)] — the file order is reversed on purpose. + let counts = vec![2u32; 16]; + let sample = |p: usize, ci: usize| -> Vec { + if p != 0 { + let v = sample_rgba(p, ci); + return vec![v[0], v[0]]; + } + match ci { + 0 => vec![0.25, 0.5], // A: back first + 1 => vec![0.25, 0.0], // B + 2 => vec![0.25, 0.25], // G + 3 => vec![0.25, 0.5], // R + _ => vec![9.0, 1.0], // Z + } + }; + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample); + let px = rgba_flat(&bytes); + let a = 0.5f32 + 0.5 * 0.25; + assert!((px[3] - a).abs() < 1e-6, "alpha {}", px[3]); + assert!((px[0] - 0.625 / a).abs() < 1e-6, "r {}", px[0]); + assert!((px[1] - 0.375 / a).abs() < 1e-6, "g {}", px[1]); + assert!((px[2] - 0.125 / a).abs() < 1e-6, "b {}", px[2]); +} + +/// A fully opaque front sample hides everything behind it. +#[test] +fn opaque_front_hides_back() { + let counts = vec![3u32; 16]; + let sample = |p: usize, ci: usize| -> Vec { + if p != 1 { + let v = sample_rgba(p, ci); + return vec![v[0], v[0], v[0]]; + } + match ci { + 0 => vec![1.0, 0.9, 1.0], // front opaque, middle partial, back opaque + 1 => vec![0.0, 1.0, 1.0], + 2 => vec![1.0, 1.0, 1.0], + 3 => vec![1.0, 0.0, 0.0], + _ => vec![1.0, 5.0, 9.0], + } + }; + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample); + let px = rgba_flat(&bytes); + let (r, g, b, a) = (px[4], px[5], px[6], px[7]); + assert!((r - 1.0).abs() < 1e-6 && (g - 1.0).abs() < 1e-6 && (b - 0.0).abs() < 1e-6, "colour ({r},{g},{b})"); + assert!((a - 1.0).abs() < 1e-6, "alpha {a}"); +} + +/// Deep EXR is HDR: colour above 1.0 survives the composite (it used to be clamped), and a +/// tiled file whose edge tiles are narrower than the tile size decodes like the scan lines. +#[test] +fn hdr_colour_is_kept_and_edge_tiles_match_scanlines() { + let (w, h) = (7, 5); + let counts: Vec = (0..w * h).map(|p| (p as u32 * 3) % 4).collect(); + let sample = |p: usize, ci: usize| -> Vec { + (0..(p as u32 * 3) % 4).map(|k| [1.0, 0.25 * k as f32, 0.5, 2.0 + p as f32 / 8.0, 10.0 - k as f32][ci]).collect() + }; + for compression in [0u8, 1, 2] { + let scan = rgba_flat(&gen_deep(w, h, &RGBAZ, compression, None, &counts, &sample)); + let tiled = rgba_flat(&gen_deep(w, h, &RGBAZ, compression, Some((3, 2)), &counts, &sample)); + assert_eq!(scan, tiled, "compression {compression}"); + // Pixel 1 has 3 opaque samples; the front one (k = 2, Z = 8) has R = 2.125. + assert!((scan[4] - 2.125).abs() < 1e-5, "compression {compression}: R {}", scan[4]); + } +} + +/// Pixels without samples are transparent black, and the structured counts say so. +#[test] +fn empty_pixels_are_transparent() { + let mut counts = vec![0u32; 16]; + counts[5] = 1; + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &|p, ci| { + if p == 5 { vec![[1.0f32, 0.5, 0.5, 0.5, 1.0][ci]] } else { Vec::new() } + }); + let px = rgba_flat(&bytes); + for p in 0..16 { + let base = p * 4; + if p == 5 { + assert!((px[base + 3] - 1.0).abs() < 1e-6); + } else { + assert!(px[base..base + 4].iter().all(|v| *v == 0.0), "pixel {p} not empty"); + } + } + let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep"); + assert_eq!(deep.total_samples(), 1); + assert_eq!(deep.sample_range(5).end, 1); +} + +/// RLE, ZIPS and NONE decode to identical pixels and identical structured samples. +#[test] +fn rle_and_zips_match_uncompressed() { + let counts: Vec = (0..16).map(|p| if p % 3 == 0 { 2 } else { 1 }).collect(); + let sample = |p: usize, ci: usize| -> Vec { + if counts[p] == 2 { + match ci { + 0 => vec![0.75, 0.25], + 1 => vec![0.1, 0.2], + 2 => vec![0.3, 0.4], + 3 => vec![0.5, 0.6], + _ => vec![7.0, 3.0], + } + } else { + sample_rgba(p, ci) + } + }; + let base = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample); + let px = rgba_flat(&base); + let deep_base = decode_deep_exr(&base, &Limits::none()).expect("deep"); + for kind in [1u8, 2] { + let bytes = gen_deep(4, 4, &RGBAZ, kind, None, &counts, &sample); + let other = rgba_flat(&bytes); + for (i, (a, b)) in px.iter().zip(other.iter()).enumerate() { + assert!((a - b).abs() < 1e-6, "compression {kind}, sample {i}: {a} vs {b}"); + } + let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep"); + assert_eq!(deep.counts, deep_base.counts); + for (a, b) in deep.channels.iter().zip(deep_base.channels.iter()) { + assert_eq!(a.name, b.name); + assert_eq!(a.samples, b.samples, "channel {}", a.name); + } + } +} + +/// Deep tiles decode to the same pixels as deep scanlines of the same data. +#[test] +fn tiles_match_scanlines() { + let counts = vec![1u32, 0, 2, 1, 1, 1, 0, 1, 2, 1, 1, 0, 1, 1, 1, 1]; + let sample = |p: usize, ci: usize| -> Vec { + if counts[p] == 2 { + match ci { + 0 => vec![0.5, 0.5], + 1 => vec![0.2, 0.1], + 2 => vec![0.3, 0.3], + 3 => vec![0.4, 0.4], + _ => vec![2.0, 8.0], + } + } else if counts[p] > 0 { + vec![[0.5, 0.2, 0.4, 0.7, 4.0][ci]] + } else { + Vec::new() + } + }; + let sl = gen_deep(4, 4, &RGBAZ, 0, None, &counts, &sample); + let tiled = gen_deep(4, 4, &RGBAZ, 0, Some((2, 2)), &counts, &sample); + let a = rgba_flat(&sl); + let b = rgba_flat(&tiled); + for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() { + assert!((x - y).abs() < 1e-6, "tile sample {i}: {x} vs {y}"); + } +} + +/// Half-float channels come back as F16 samples with exact values. +#[test] +fn half_channels_round_trip() { + let channels = + [Chan { name: "A", ty: 1 }, Chan { name: "B", ty: 1 }, Chan { name: "G", ty: 1 }, Chan { name: "R", ty: 1 }, Chan { name: "Z", ty: 1 }]; + let bytes = gen_deep(4, 4, &channels, 2, None, COUNTS_ONE, &|p, _ci| { + let v = f16::from_f32(0.5).to_f32(); + vec![v * (p as f32 + 1.0) / 4.0] + }); + let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep"); + for c in &deep.channels { + assert_eq!(c.sample, SampleType::F16, "{}", c.name); + } + let a = deep.channel("A").expect("A"); + assert_eq!(a.samples.len(), 16); +} + +/// Without an alpha channel the front-most sample wins, opaque; luminance maps to gray. +#[test] +fn without_alpha_front_sample_is_opaque() { + let channels = [Chan { name: "B", ty: 2 }, Chan { name: "G", ty: 2 }, Chan { name: "R", ty: 2 }, Chan { name: "Z", ty: 2 }]; + let counts = vec![2u32; 16]; + let bytes = gen_deep(4, 4, &channels, 0, None, &counts, &|p, ci| { + if p % 2 == 0 { + match ci { + 0 => vec![0.9, 0.1], + 1 => vec![0.2, 0.8], + 2 => vec![0.4, 0.6], + _ => vec![2.0, 6.0], + } + } else { + vec![[0.0, 0.0, 1.0, 1.0][ci], [0.0, 0.0, 1.0, 9.0][ci]] + } + }); + let img = decode(&bytes).expect("decodes"); + assert_eq!(img.layout(), ColorLayout::Rgb); + let px = img.to_f32_samples().expect("f32"); + // Even pixels: the front sample (z=2) is (r 0.4, g 0.2, b 0.9). + assert!((px[0] - 0.4).abs() < 1e-6 && (px[1] - 0.2).abs() < 1e-6 && (px[2] - 0.9).abs() < 1e-6); +} + +/// Grayscale deep files (Y + Z) flatten to opaque gray, Y + A + Z keeps its alpha: the alpha +/// must land in the GrayA alpha slot, not a luminance copy (that was the bug). +#[test] +fn grayscale_deep_keeps_its_alpha() { + let channels = [Chan { name: "A", ty: 2 }, Chan { name: "Y", ty: 2 }, Chan { name: "Z", ty: 2 }]; + let bytes = gen_deep(4, 4, &channels, 0, None, COUNTS_ONE, &|p, ci| { + match ci { + 0 => vec![0.5], // A + 1 => vec![0.5], // Y, premultiplied: straight 1.0 × 0.5 + _ => vec![p as f32], // Z + } + }); + let img = decode(&bytes).expect("decodes"); + assert_eq!(img.layout(), ColorLayout::YA); + let px = img.to_f32_samples().expect("f32"); + for (p, s) in px.chunks(2).enumerate() { + let (y, a) = (s[0], s[1]); + assert!((a - 0.5).abs() < 1e-6, "pixel {p}: alpha {a}, want 0.5"); + assert!((y - 1.0).abs() < 1e-6, "pixel {p}: luminance {y}, want the un-premultiplied 1.0"); + } + + // Without an alpha channel the same file is opaque gray. + let channels = [Chan { name: "Y", ty: 2 }, Chan { name: "Z", ty: 2 }]; + let bytes = gen_deep(4, 4, &channels, 0, None, COUNTS_ONE, &|p, ci| match ci { + 0 => vec![0.4], + _ => vec![p as f32], + }); + let img = decode(&bytes).expect("decodes"); + assert_eq!(img.layout(), ColorLayout::Y); + let px = img.to_f32_samples().expect("f32"); + for (p, v) in px.iter().enumerate() { + assert!((v - 0.4).abs() < 1e-6, "pixel {p}: {v}, want 0.4"); + } +} + +/// decode_deep returns the counts and channels in file order. +#[test] +fn decode_deep_structure() { + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba); + let deep = decode_deep_exr(&bytes, &Limits::none()).expect("deep"); + assert_eq!((deep.width, deep.height), (4, 4)); + assert_eq!(deep.channels.iter().map(|c| c.name.as_str()).collect::>(), ["A", "B", "G", "R", "Z"]); + assert_eq!(deep.counts.len(), 17); + assert_eq!(deep.counts[0], 0); + assert_eq!(deep.total_samples(), 16); + for p in 0..16 { + assert_eq!(deep.sample_range(p).end - deep.sample_range(p).start, 1); + } + assert_eq!(deep.channel("Z").expect("Z").samples[5], 15.0); +} + +// --------------------------------------------------------------------------------------------- +// Hostile input: every damaged file errors, nothing panics. + +fn truncate_sweep(bytes: &[u8]) { + // Cut at every offset (cap the count for speed) — a cut file must error, not panic. + let step = (bytes.len() / 96).max(1); + let mut i = step; + while i < bytes.len() { + let cut = &bytes[..i]; + let _ = decode_deep_exr(cut, &Limits::none()); + let _ = decode(cut); + i += step; + } +} + +#[test] +fn truncated_files_error_never_panic() { + for kind in [0u8, 1, 2] { + let bytes = gen_deep(4, 4, &RGBAZ, kind, None, COUNTS_ONE, &sample_rgba); + truncate_sweep(&bytes); + } + let tiled = gen_deep(4, 4, &RGBAZ, 1, Some((2, 2)), COUNTS_ONE, &sample_rgba); + truncate_sweep(&tiled); +} + +#[test] +fn hostile_sizes_and_offsets_error() { + let bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba); + // A tiny sample-data budget rejects the file. + let tight = Limits { max_bytes: 64, ..Limits::none() }; + assert!(matches!(decode_deep_exr(&bytes, &tight), Err(CodecError::LimitExceeded(_)))); + // A non-deep file is unsupported for decode_deep. + let flat = crate::decode::write( + 2, + 2, + &[crate::decode::Channel { name: "R".into(), samples: crate::decode::Samples::F32(vec![0.5; 4]) }], + crate::decode::Compression::None, + ) + .expect("encode"); + assert!(matches!(decode_deep_exr(&flat, &Limits::none()), Err(CodecError::Unsupported(_)))); + // The last chunk's y coordinate destroyed: out of the data window. + let mut broken = bytes.clone(); + let header_end = header_end_of(&broken); + let last_off = u64::from_le_bytes(broken[header_end + 24..header_end + 32].try_into().expect("8")) as usize; + broken[last_off..last_off + 4].copy_from_slice(&i32::MAX.to_le_bytes()); + assert!(decode_deep_exr(&broken, &Limits::none()).is_err()); + assert!(decode(&broken).is_err()); + // Two chunks claiming the same block: duplicate offsets. + let mut dup = bytes.clone(); + let header_end = header_end_of(&dup); + let first: [u8; 8] = dup[header_end..header_end + 8].try_into().expect("8"); + dup[header_end + 8..header_end + 16].copy_from_slice(&first); + assert!(matches!(decode_deep_exr(&dup, &Limits::none()), Err(CodecError::Malformed(_)))); + // An offset pointing past the file. + let mut far = bytes.clone(); + far[header_end..header_end + 8].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed(_)))); +} + +/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes +/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4 +/// image, its table stored at that size, makes a compressed file of tens of KB whose table +/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a +/// budget smaller than both the table must be refused before it is allocated; with room for +/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of +/// ordinary tiles, still decode under a small budget. +#[test] +fn deep_tile_count_table_is_admitted_against_max_bytes() { + let budget = |max_bytes: u64| Limits { max_bytes, ..Limits::none() }; + let tight = budget(1 << 20); + let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + for compression in [0u8, 1, 2] { + let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true); + assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &tight); + assert!( + matches!(got, Err(CodecError::LimitExceeded(_))), + "compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}", + got.map(|d| d.total_samples()) + ); + let flat = decode_with(&big, tight); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width())); + // The same tile with its table stored at the clipped size fits the small budget. + let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba); + assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}"); + if compression != 0 { + assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len()); + } + // The table and its scratch copy are admitted together, 8 MiB, on top of the 4x4 + // image's sample-count index (17 u64). + let index = 17 * 8; + assert_eq!(decode_deep_exr(&big, &budget(2 * table + index)).expect("room for the table and its copy"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &budget(2 * table + index - 1)); + assert!( + matches!(got, Err(CodecError::LimitExceeded(_))), + "compression {compression}: one byte short gave {:?}", + got.map(|d| d.total_samples()) + ); + } + // Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones. + let counts: Vec = (0..35).map(|p| (p * 3) % 4).collect(); + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true); + let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample); + assert_eq!( + decode_deep_exr(&full, &tight).expect("full-size edge tables"), + decode_deep_exr(&clipped, &tight).expect("clipped edge tables"), + "compression {compression}" + ); + } +} + +/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side, +/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits, +/// where its full size passes the check and is only tried as the data arrives. Each tile is +/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over +/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's +/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for +/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint +/// channels decode after full-size tables like float ones. +#[test] +fn deep_tile_table_admission_edges() { + let budget = |max_bytes: u64| Limits { max_bytes, ..Limits::none() }; + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + let side = (1 << 30) - 2; + for compression in [0u8, 1, 2] { + let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba); + for limits in [Limits::none(), Limits::default(), budget(1 << 20)] { + assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}"); + } + } + let counts: Vec = (0..48).map(|p| (p % 3) as u32).collect(); + let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c }); + let [a, b, g, r, z] = RGBAZ; + let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }]; + for channels in [&RGBAZ[..], &half[..], &with_id[..]] { + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true); + let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample); + let what = format!("{} channels, compression {compression}", channels.len()); + let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables"); + assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}"); + } + } +} + +/// A one-channel data-window check admits only four bytes per pixel, but a compressed count +/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback; +/// the existing multichannel cases have enough pixel allowance to hide this gap. +#[test] +fn clipped_count_tables_include_scratch_in_the_budget() { + let channels = [Chan { name: "Y", ty: 2 }]; + let counts = vec![0u32; 64]; + let sample = |_: usize, _: usize| Vec::new(); + let budget = |max_bytes: u64| Limits { max_bytes, ..Limits::none() }; + for tile in [None, Some((64, 1)), Some((128, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample); + let what = format!("tile {tile:?}, compression {compression}"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + // The table and any scratch, on top of the sample-count index (65 u64). + let index = 65 * 8; + let required = index + if compression == 0 { 256 } else { 512 }; + assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}"); + assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}"); + let flat = decode_with(&bytes, budget(required - 1)); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}"); + if compression == 0 { + assert_eq!(decode_deep_exr(&bytes, &budget(index + 511)).expect("raw table needs no scratch"), want, "{what}"); + } + } + } +} + +/// The per-pixel bookkeeping of a deep decode (the cumulative sample-count index that +/// [`crate::deep::DeepImage::counts`] returns, eight bytes a pixel plus one entry) is +/// charged against `max_bytes`, and the count tables are admitted on top of it. A one-channel +/// image without samples passed the data-window check (four bytes a pixel) and decoded while +/// holding 24 bytes a pixel of bookkeeping: a 4096x4096 file of 65-300 KB held 384 MiB under a +/// 64 or 128 MiB budget. Under that four-byte budget it is now refused; it decodes with room +/// for the index plus the largest count table and its scratch copy, and not one byte less. +#[test] +fn deep_bookkeeping_is_charged_against_max_bytes() { + let (w, h) = (256usize, 256usize); + let n = w * h; + let channels = [Chan { name: "Y", ty: 2 }]; + let counts = vec![0u32; n]; + let sample = |_: usize, _: usize| Vec::new(); + let budget = |max_bytes: u64| Limits { max_bytes, ..Limits::none() }; + let index = (n as u64 + 1) * 8; + for tile in [None, Some((w, h))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(w, h, &channels, compression, tile, &counts, &sample); + let what = format!("tile {tile:?}, compression {compression}"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + assert_eq!(want.counts.len(), n + 1, "{what}"); + let pixel_budget = budget(4 * n as u64); + let got = decode_deep_exr(&bytes, &pixel_budget); + assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "{what}: a four-byte-a-pixel budget gave {:?}", got.map(|d| d.counts.len())); + let flat = decode_with(&bytes, pixel_budget); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}: the flat decode gave {:?}", flat.map(|i| i.width())); + // One block's count table (a scan line, or the single tile), with the scratch copy + // that undoes the byte split when it is stored compressed. + let table = 4 * if tile.is_some() { n } else { w } as u64; + let required = index + table * if compression == 0 { 1 } else { 2 }; + assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("index and table fit exactly"), want, "{what}"); + let got = decode_deep_exr(&bytes, &budget(required - 1)); + assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "{what}: one byte short gave {:?}", got.map(|d| d.counts.len())); + } + } +} + +/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's +/// compression tag, decides whether a scratch buffer is needed. +#[test] +fn raw_count_tables_need_no_scratch_even_with_compression() { + let channels = [Chan { name: "Y", ty: 2 }]; + // The four-byte table on top of the one-pixel sample-count index (two u64). + let limits = Limits { max_bytes: 16 + 4, ..Limits::none() }; + for tile in [None, Some((1, 1)), Some((2, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new()); + let header_end = header_end_of(&bytes); + let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize; + let table_size = chunk + if tile.is_some() { 16 } else { 4 }; + let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size")); + assert_eq!(packed, 4, "the four-byte count table is stored raw"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}"); + } + } +} + +/// Walk the attributes like the format prescribes: the offset table starts after the +/// header's terminating zero byte. +fn header_end_of(bytes: &[u8]) -> usize { + let mut p = 8usize; // magic + version + loop { + let Some(nul) = bytes[p..].iter().position(|&b| b == 0) else { panic!("unterminated attribute name") }; + if nul == 0 { + return p + 1; // the empty name terminates the header + } + p += nul + 1; + let Some(t) = bytes[p..].iter().position(|&b| b == 0) else { panic!("unterminated attribute type") }; + p += t + 1; + let size = i32::from_le_bytes(bytes[p..p + 4].try_into().expect("size")) as usize; + p += 4 + size; + } +} + +#[test] +fn inconsistent_count_table_errors() { + // The declared unpacked size disagrees with the count table. + let mut bytes = gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba); + // Find the first chunk: the offset table follows the header (find the "version" attr end). + let header_end = header_end_of(&bytes); + let first_chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("8")) as usize; + // Scanline chunk: y (4), three u64s (24); bump the unpacked size by one. + let at = first_chunk + 4 + 16; + let v = u64::from_le_bytes(bytes[at..at + 8].try_into().expect("8")); + bytes[at..at + 8].copy_from_slice(&(v + 1).to_le_bytes()); + assert!(matches!(decode_deep_exr(&bytes, &Limits::none()), Err(CodecError::Malformed(_)))); +} + +/// `gen_deep` for a cropped render: the data window sits at `(dwx, dwy)` inside a larger +/// display window of `sw × sh` (the stage the renderer composed onto). Scanline chunk Y +/// coordinates are absolute display rows, so they carry the data-window offset. +#[allow(clippy::too_many_arguments)] +fn gen_deep_cropped( + w: usize, + h: usize, + dwx: i32, + dwy: i32, + sw: usize, + sh: usize, + channels: &[Chan], + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, +) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0x0131_2f76u32.to_le_bytes()); + out.extend_from_slice(&(2u32 | 0x800).to_le_bytes()); + + let mut chlist = Vec::new(); + for c in channels { + chlist.extend_from_slice(c.name.as_bytes()); + chlist.push(0); + chlist.extend_from_slice(&c.ty.to_le_bytes()); + chlist.push(0); + chlist.extend_from_slice(&[0u8; 3]); + chlist.extend_from_slice(&1i32.to_le_bytes()); + chlist.extend_from_slice(&1i32.to_le_bytes()); + } + chlist.push(0); + attr(&mut out, "channels", "chlist", &chlist); + attr(&mut out, "compression", "compression", &[0u8]); + // box2i is inclusive: the windows end at position + size - 1. + let dw: Vec = [dwx, dwy, dwx + w as i32 - 1, dwy + h as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect(); + attr(&mut out, "dataWindow", "box2i", &dw); + let disp: Vec = [0i32, 0, sw as i32 - 1, sh as i32 - 1].iter().flat_map(|v| v.to_le_bytes()).collect(); + attr(&mut out, "displayWindow", "box2i", &disp); + attr(&mut out, "lineOrder", "lineOrder", &[0]); + attr(&mut out, "pixelAspectRatio", "float", &1f32.to_le_bytes()); + attr(&mut out, "screenWindowCenter", "v2f", &[0f32.to_le_bytes(), 0f32.to_le_bytes()].concat()); + attr(&mut out, "screenWindowWidth", "float", &1f32.to_le_bytes()); + attr(&mut out, "type", "string", b"deepscanline"); + attr(&mut out, "version", "int", &1i32.to_le_bytes()); + out.push(0); + + // One scanline block per data row; the pixel indexing stays data-relative. + let mut chunks: Vec> = Vec::new(); + for y in 0..h { + let pixels: Vec = (y * w..y * w + w).collect(); + let mut c = Vec::new(); + c.extend_from_slice(&(dwy + y as i32).to_le_bytes()); + let mut table = Vec::with_capacity(pixels.len() * 4); + let mut acc = 0u32; + for &p in &pixels { + acc += counts[p]; + table.extend_from_slice(&acc.to_le_bytes()); + } + let mut data = Vec::new(); + for (ci, _) in channels.iter().enumerate() { + for &p in &pixels { + for v in sample(p, ci) { + data.extend_from_slice(&v.to_le_bytes()); + } + } + } + c.extend_from_slice(&(table.len() as u64).to_le_bytes()); + c.extend_from_slice(&(data.len() as u64).to_le_bytes()); + c.extend_from_slice(&(data.len() as u64).to_le_bytes()); + c.extend_from_slice(&table); + c.extend_from_slice(&data); + chunks.push(c); + } + let mut pos = out.len() + chunks.len() * 8; + let mut table = Vec::new(); + for c in &chunks { + table.extend_from_slice(&(pos as u64).to_le_bytes()); + pos += c.len(); + } + out.extend_from_slice(&table); + for c in chunks { + out.extend_from_slice(&c); + } + out +} + +/// A cropped render opens on the full display-window stage: MY27-style renders carry a +/// `display_window` of the full frame (e.g. 4000×3000) while the deep data window is the +/// render pass's crop inside it. The image must open at stage size with the data at its +/// crop offset, transparent elsewhere — not cropped to the data window. +#[test] +fn cropped_deep_data_opens_on_the_display_stage() { + let (w, h, dwx, dwy, sw, sh) = (24usize, 16usize, 8i32, 6i32, 48usize, 32usize); + let counts = vec![1u32; w * h]; + let sample = |p: usize, ci: usize| -> Vec { + // chlist order (RGBA_NO_Z): A, B, G, R. + let x = p % w; + let y = p / w; + match ci { + 0 => vec![1.0], // A + 1 => vec![0.5], // B + 2 => vec![y as f32 / h as f32], // G + _ => vec![x as f32 / w as f32], // R + } + }; + let bytes = gen_deep_cropped(w, h, dwx, dwy, sw, sh, &RGBA_NO_Z, &counts, &sample); + let img = decode(&bytes).expect("cropped deep EXR decodes"); + assert_eq!((img.width(), img.height()), (sw as u32, sh as u32), "opens on the display window stage"); + assert!(img.warnings.iter().any(|w| matches!(w, Warn::DisplayWindowStage { origin } if *origin == (dwx, dwy)))); + + // The data sits at its crop offset: the first data pixel's green channel carries its + // data-row fraction, everything outside the data region stays transparent black. + let s = img.to_f32_samples().expect("f32"); + let nc = s.len() / (sw * sh); + let px = |x: usize, y: usize| -> Vec { s[y * sw * nc + x * nc..y * sw * nc + x * nc + nc].to_vec() }; + let first = px(dwx as usize, dwy as usize); + assert!( + (first[0] - 0.0).abs() < 1e-5 && (first[1] - 0.0).abs() < 1e-5 && (first[2] - 0.5).abs() < 1e-5 && (first[3] - 1.0).abs() < 1e-5, + "first data pixel at the offset: {first:?}" + ); + let last = px(dwx as usize + w - 1, dwy as usize + h - 1); + assert!( + (last[0] - (w - 1) as f32 / w as f32).abs() < 1e-5 && (last[1] - (h - 1) as f32 / h as f32).abs() < 1e-5, + "last data pixel keeps its coordinates: {last:?}" + ); + let outside = px(0, 0); + assert_eq!(outside, vec![0.0, 0.0, 0.0, 0.0], "outside the data region: transparent"); +} diff --git a/crates/exr/src/lib.rs b/crates/exr/src/lib.rs index c9e64236..b28cfa03 100644 --- a/crates/exr/src/lib.rs +++ b/crates/exr/src/lib.rs @@ -14,10 +14,18 @@ //! //! - [`decode`]: bounded part listing, flat decoding (scan lines and tiles, every compression the //! `exr` crate reads) and writing, on top of the `exr` crate. +//! - [`deep`]: deep scan lines and tiles (NONE, RLE, ZIPS) and their flattening. +//! - [`cryptomatte_samples`]: a file's Cryptomatte layers and their per-pixel (ID, coverage) ranks. pub mod channels; pub mod cryptomatte; #[cfg(feature = "decode")] +pub mod cryptomatte_samples; +#[cfg(feature = "decode")] pub mod decode; #[cfg(all(test, feature = "decode"))] mod decode_tests; +#[cfg(feature = "decode")] +pub mod deep; +#[cfg(all(test, feature = "decode"))] +mod deep_tests;