Browse / github.com/storytold/photocraft / patch
codecs: charge deep OpenEXR per-pixel bookkeeping against max_alloc
Deep OpenEXR decode ignores max_alloc for its per-pixel bookkeeping (24 bytes a pixel) **Version:** PhotoCraft 0.16.0 (b1794c6e0, 2026-10-25), Windows 11 **Component:** `photocraft-codecs`, deep OpenEXR (`deepscanline` / `deeptile`): `crates/codecs/src/codecs/deep_exr.rs` (`decode_deep`), reached from `decode_deep_exr` and from `decode` / `decode_with` (which flattens deep files). ## Problem `decode_deep` checks the data window against `max_alloc` at four bytes a pixel per channel, then builds bookkeeping that is never charged against the budget: - a `u32` sample count per pixel (4 B/px), - per chunk, a `Vec<usize>` of the block's global pixel indices (8 B/px) and a `Vec<u32
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Higgs32584 (imported from pull request #9786, open)
- Base
52f338e2df44ac73c0bf231fa8d3f8cd496641ec- Size
- +146 −104 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
d0d0381b4db59539031d97a1
How it went
No other operator has reported using it yet.
Diff
raw · crates/codecs/src/codecs/deep_exr.rs crates/codecs/tests/it/deep_exr.rs
@@ -219,16 +219,14 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s // --- the deep chunk stream --------------------------------------------------------------------- -/// One parsed chunk: where its pixels go and its cumulative sample counts, plus where to-/// find the compressed sample data in the second pass.+/// 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 {- /// Global pixel indices of the block's pixels, in block order (row-major within the block).- pixels: Vec<usize>,- /// Pixels per row of the block (the data window width for a scan line, the clipped tile- /// width for a tile).- row_w: usize,- /// Cumulative sample count table of the block: `len == pixels.len() + 1`.- prefix: Vec<u32>,+ /// The block's rows within the data window.+ rows: std::ops::Range<usize>,+ /// The block's columns within the data window (clipped to it for an edge tile).+ cols: std::ops::Range<usize>, /// File offset of the chunk start. pos: u64, /// Size of the compressed sample data in the file.@@ -244,6 +242,15 @@ fn sample_bytes(t: ExrSample) -> usize { } } +/// 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),+ }+}+ fn our_sample_type(t: ExrSample) -> SampleType { match t { ExrSample::F16 => SampleType::F16,@@ -272,6 +279,19 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res 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_alloc {+ return Err(CodecError::LimitExceeded(format!(+ "the sample-count index of {w}x{h} deep pixels needs {index_bytes} bytes, exceeding max_alloc {}",+ limits.max_alloc+ )));+ }+ let budget = limits.max_alloc - 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:@@ -326,7 +346,14 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res } }; - let mut counts = vec![0u32; npixels];+ // 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::<u64>(index_len)?; let mut seen = vec![false; slots]; let mut chunks: Vec<DeepChunk> = Vec::with_capacity(offsets.len()); @@ -341,17 +368,15 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res return Err(err(format!("chunk declares part {part}, expected {header_index}"))); } }- // Block coordinates → the block's pixel bounds and its slot. The per-pixel index is- // built only after the chunk's declared payload sizes are validated below, so a- // truncated or lying chunk cannot drive a large index allocation first.- let (block_rows, block_cols, slot, row_w, scanline_row): (std::ops::Range<usize>, std::ops::Range<usize>, usize, usize, bool) = match &header.blocks {+ // Block coordinates → the block's pixel bounds and its slot.+ let (block_rows, block_cols, slot): (std::ops::Range<usize>, std::ops::Range<usize>, 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, w, true)+ (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)?);@@ -365,7 +390,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res 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, cols - bx, false)+ (by..rows, bx..cols, slot) } }; let block_len = block_rows.len().saturating_mul(block_cols.len());@@ -383,31 +408,18 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) { return Err(err("deep chunk sizes point past the end of the file")); }- // The chunk's payload is present; now materialize the block's global pixel index. For- // scan lines this is one row; for tiles the clipped tile rectangle.- let block_pixels: Vec<usize> = if scanline_row {- let row = block_rows.start;- (0..w).map(|x| row * w + x).collect()- } else {- let mut px = Vec::with_capacity(block_len);- for yy in block_rows.clone() {- for xx in block_cols.clone() {- px.push(yy.saturating_mul(w).saturating_add(xx));- }- }- px- }; let what = "the sample-count table"; let table_bytes = read_exact(&mut cur, packed_table as usize, what)?;- let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;+ 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_alloc`. let buffers = if table_bytes.len() == expected_table { 1 } else { 2 }; let clipped_need = (expected_table as u64).saturating_mul(buffers);- if clipped_need > limits.max_alloc {+ if clipped_need > budget { return Err(CodecError::LimitExceeded(format!(- "{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}",+ "{what} needs {clipped_need} bytes including decompression scratch, on top of the {index_bytes} byte sample-count index, exceeding max_alloc {}", limits.max_alloc ))); }@@ -420,75 +432,73 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res 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) <= limits.max_alloc => {+ 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); CodecError::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, exceeding max_alloc {}",+ "{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_alloc {}", limits.max_alloc )) })?, }; // The table is cumulative within each row of the block (a tile restarts at every row);- // `prefix` is cumulative over the whole block, and each pixel's own count is the difference.- let mut prefix = Vec::with_capacity(block_pixels.len() + 1);- prefix.push(0u32);- let (mut row_base, mut prev) = (0u32, 0u32);- for (i, p) in block_pixels.iter().enumerate() {- let b: [u8; 4] = raw.get(i * 4..i * 4 + 4).and_then(|s| s.try_into().ok()).ok_or_else(|| err("the count table is short"))?;- let v = u32::from_le_bytes(b);- if row_w > 0 && i % row_w == 0 {- row_base = row_base.checked_add(prev).ok_or_else(|| err("the block's sample count overflows"))?;- prev = 0;- }- if v < prev {- return Err(err("the sample-count table decreases (malformed cumulative counts)"));- }- if let Some(c) = counts.get_mut(*p) {- *c = v - prev;+ // 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; }- prev = v;- prefix.push(row_base.checked_add(v).ok_or_else(|| err("the block's sample count overflows"))?);+ total = total.checked_add(u64::from(prev)).ok_or_else(|| err("the block's sample count overflows"))?; }- let total = prefix.last().copied().unwrap_or(0) as u64; 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 { pixels: block_pixels, row_w, prefix, pos: off, packed_data, unpacked_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.- let mut deep_counts = Vec::with_capacity(npixels + 1);- deep_counts.push(0u64);+ // 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 {- acc = acc.checked_add(c as u64).ok_or_else(|| err("the total sample count overflows"))?;- deep_counts.push(acc);+ 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 > limits.max_alloc {- return Err(CodecError::LimitExceeded(format!("deep samples need {sample_bytes_total} bytes, exceeding max_alloc {}", limits.max_alloc)));+ if sample_bytes_total > budget {+ return Err(CodecError::LimitExceeded(format!(+ "deep samples need {sample_bytes_total} bytes on top of the {index_bytes} byte sample-count index, exceeding max_alloc {}",+ limits.max_alloc+ ))); }+ let total_samples = usize::try_from(total_samples).map_err(|_| CodecError::LimitExceeded(format!("{total_samples} deep samples do not fit in memory")))?; - let mut channels: Vec<DeepChannel> = header- .channels- .list- .iter()- .map(|c| DeepChannel { name: c.name.to_string(), sample: our_sample_type(c.sample_type), samples: vec![0f32; total_samples as usize] })- .collect();+ let mut channels: Vec<DeepChannel> = Vec::with_capacity(header.channels.list.len());+ for c in &header.channels.list {+ channels.push(DeepChannel { name: c.name.to_string(), sample: our_sample_type(c.sample_type), samples: alloc_zeroed::<f32>(total_samples)? });+ } - // Second pass: the compressed sample data, scattered per pixel (a block's channel data- // is contiguous in the file but the pixels' samples interleave with other blocks').+ // 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 {@@ -510,43 +520,32 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res 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.+ // 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;- let row_w = chunk.row_w.max(1);- for (r, row) in chunk.pixels.chunks(row_w).enumerate() {- let i0 = r * row_w;- let row_from = chunk.prefix.get(i0).copied().unwrap_or(0) as usize;- let row_to = chunk.prefix.get(i0 + row.len()).copied().unwrap_or(0) as usize;- let row_total = row_to.saturating_sub(row_from);+ 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 end = in_off.checked_add(ch_bytes).ok_or_else(|| err("channel size overflows"))?;- let src = raw.get(in_off..end).ok_or_else(|| err("the sample data is short"))?;- in_off = end;+ 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"))?;- for (j, p) in row.iter().enumerate() {- let from = (chunk.prefix.get(i0 + j).copied().unwrap_or(0) as usize).saturating_sub(row_from);- let n = (chunk.prefix.get(i0 + j + 1).copied().unwrap_or(0) as usize).saturating_sub(row_from).saturating_sub(from);- let to = deep_counts.get(*p).copied().unwrap_or(0) as usize;- for k in 0..n {- let base = (from + k) * sb;- let v = match t {- ExrSample::F16 => src.get(base..base + 2).map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32()),- ExrSample::F32 => src.get(base..base + 4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])),- ExrSample::U32 => src.get(base..base + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f32),- };- let (Some(v), Some(slot)) = (v, out.samples.get_mut(to + k)) else {- return Err(err("the sample data is short"));- };- *slot = v;- }+ 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: deep_counts })+ 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`]@@ -658,9 +658,11 @@ fn deep_tile_count_table_is_admitted_against_max_alloc() { 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.- assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}");- let got = decode_deep_exr(&big, &budget(2 * table - 1));+ // 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.@@ -726,24 +728,65 @@ fn clipped_count_tables_include_scratch_in_the_budget() { 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");- let required = if compression == 0 { 256 } else { 512 };+ // 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 = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() }); assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}"); if compression == 0 {- assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}");+ 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+/// [`photocraft_codecs::DeepImage::counts`] returns, eight bytes a pixel plus one entry) is+/// charged against `max_alloc`, 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_alloc() {+ 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_alloc: u64| Limits { max_alloc, ..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 = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: pixel_budget, ..Default::default() });+ 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 }];- let limits = Limits { max_alloc: 4, ..Limits::none() };+ // The four-byte table on top of the one-pixel sample-count index (two u64).+ let limits = Limits { max_alloc: 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());