diff --git a/crates/codecs/src/exr_stream.rs b/crates/codecs/src/exr_stream.rs new file mode 100644 index 0000000000..1ed8ba1276 --- /dev/null +++ b/crates/codecs/src/exr_stream.rs @@ -0,0 +1,294 @@ +//! Bounded, seekable single-part scanline OpenEXR decoding. Total pixel count does not +//! size a decoded image buffer: only one compression block is returned at a time. +use std::io::{Cursor, Read, Seek, SeekFrom}; + +use crate::{CodecError, Format}; +use exr::{ + block::{ + UncompressedBlock, + chunk::{Chunk, CompressedBlock, CompressedScanLineBlock}, + lines::LineIndex, + }, + meta::{BlockDescription, MetaData, attribute::SampleType}, +}; + +const META_BYTES: usize = 8 << 20; +const BLOCK_BYTES: usize = 64 << 20; +const MAX_CHUNKS: usize = 1 << 20; + +fn invalid(e: impl std::fmt::Display) -> CodecError { + CodecError::malformed(Format::OpenExr, e) +} +fn limit(s: &str) -> CodecError { + CodecError::LimitExceeded(s.into()) +} +fn unsupported(s: &str) -> CodecError { + CodecError::unsupported(Format::OpenExr, s) +} + +fn read(r: &mut impl Read) -> Result<[u8; N], CodecError> { + let mut bytes = [0; N]; + r.read_exact(&mut bytes).map_err(invalid)?; + Ok(bytes) +} + +fn string(r: &mut impl Read, prefix: &mut Vec) -> Result { + for i in 0..256 { + let b = read::<1>(r)?; + prefix.extend(b); + if b == [0] { + return Ok(i != 0); + } + } + Err(limit("EXR attribute name exceeds 255 bytes")) +} + +fn metadata(r: &mut impl Read, cancel: &dyn Fn() -> bool) -> Result, CodecError> { + let start = read::<8>(r)?; + if start.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01]) { + return Err(invalid("EXR signature")); + } + let version = u32::from_le_bytes(start.get(4..).ok_or_else(|| invalid("version"))?.try_into().map_err(invalid)?); + if version & (0x200 | 0x800 | 0x1000) != 0 { + return Err(unsupported("streaming requires a single flat scanline part")); + } + let mut prefix = start.to_vec(); + let mut attributes = 0; + loop { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + if !string(r, &mut prefix)? { + break; + } + attributes += 1; + if attributes > 4096 { + return Err(limit("EXR metadata exceeds 4096 attributes")); + } + string(r, &mut prefix)?; + let length = read::<4>(r)?; + prefix.extend(length); + let n = usize::try_from(i32::from_le_bytes(length)).map_err(invalid)?; + if n > META_BYTES || prefix.len().checked_add(n).is_none_or(|v| v > META_BYTES) { + return Err(limit("EXR metadata exceeds 8 MiB")); + } + let begin = prefix.len(); + prefix.try_reserve_exact(n).map_err(|_| limit("EXR metadata allocation"))?; + prefix.resize(begin + n, 0); + for bytes in prefix.get_mut(begin..).ok_or_else(|| invalid("attribute size"))?.chunks_mut(64 << 10) { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + r.read_exact(bytes).map_err(invalid)?; + } + } + Ok(prefix) +} + +/// Header facts used by the document owner to admit tile and working memory. +#[derive(Clone, Copy, Debug)] +pub struct Info { + pub width: u32, + pub height: u32, + pub gray: bool, + pub has_alpha: bool, + pub all_half: bool, + pub working_bytes: u64, + pub blocks: usize, +} + +/// Interleaved little-endian F32, GrayA or RGBA, at source resolution. +pub struct Band { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub data: Vec, +} + +pub struct Decoder { + source: R, + meta: MetaData, + info: Info, + offsets: Vec, + seen: Vec, + channels: Vec>, + next: usize, + file_len: u64, + chunks_start: u64, + raw_limit: usize, + rows: usize, +} + +impl Decoder { + /// Read only bounded metadata and offsets; allocate no full-image or channel buffer. + pub fn new(mut source: R, cancel: &dyn Fn() -> bool) -> Result { + let file_len = source.seek(SeekFrom::End(0)).map_err(invalid)?; + source.seek(SeekFrom::Start(0)).map_err(invalid)?; + let prefix = metadata(&mut source, cancel)?; + let meta = std::panic::catch_unwind(|| MetaData::read_from_buffered(Cursor::new(&prefix), false)) + .map_err(|_| invalid("invalid EXR metadata"))? + .map_err(invalid)?; + let header = meta.headers.first().ok_or_else(|| invalid("missing header"))?; + if meta.headers.len() != 1 || header.deep || !matches!(header.blocks, BlockDescription::ScanLines) { + return Err(unsupported("streaming requires a single flat scanline part")); + } + let (w, h) = (header.layer_size.0, header.layer_size.1); + if w == 0 || h == 0 || w > 1 << 18 || h > 1 << 18 { + return Err(limit("EXR dimensions exceed the streaming limit of 262144")); + } + let list = &header.channels.list; + if list.is_empty() || list.len() > 64 || list.iter().any(|c| c.sampling.0 != 1 || c.sampling.1 != 1) { + return Err(unsupported("EXR channel count or subsampling")); + } + // The same channels the whole-file decoder shows, so both routes open the same image. + let names: Vec = list.iter().map(|c| c.name.to_string()).collect(); + let (layout, picked) = crate::image::exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| unsupported("no R/G/B or Y channels"))?; + let gray = matches!(layout, crate::ChannelLayout::Gray | crate::ChannelLayout::GrayA); + let mut selected: Vec> = picked.into_iter().map(Some).collect(); + if !matches!(layout, crate::ChannelLayout::GrayA | crate::ChannelLayout::Rgba) { + selected.push(None); + } + if selected.len() != if gray { 2 } else { 4 } { + return Err(unsupported("EXR channel selection")); + } + // The document path currently tags Rec. 709 linear samples. Keep custom + // primaries on the existing decoder until their profiles can be mapped. + if !gray && header.shared_attributes.chromaticities.is_some() { + return Err(unsupported("streaming custom EXR chromaticities requires a color profile")); + } + let all_half = selected.iter().flatten().all(|i| list.get(*i).is_some_and(|c| c.sample_type == SampleType::F16)); + let has_alpha = selected.last().copied().flatten().is_some(); + let channels = (0..list.len()).map(|i| selected.iter().position(|s| *s == Some(i))).collect(); + let rows = header.max_block_pixel_size().1; + let expected = h.div_ceil(rows.max(1)); + if rows == 0 || header.chunk_count != expected || expected > MAX_CHUNKS { + return Err(limit("EXR block count exceeds the streaming budget")); + } + let raw_limit = w + .checked_mul(rows) + .and_then(|n| n.checked_mul(header.channels.bytes_per_pixel)) + .filter(|n| *n <= BLOCK_BYTES) + .ok_or_else(|| limit("EXR compression block exceeds 64 MiB"))?; + let output = (w as u64).saturating_mul(rows as u64).saturating_mul(selected.len() as u64 * 4); + let info = Info { + width: w as u32, + height: h as u32, + gray, + has_alpha, + all_half, + blocks: expected, + working_bytes: (raw_limit as u64).saturating_mul(8).saturating_add(output.saturating_mul(2)).saturating_add(1 << 20), + }; + let chunks_start = source + .stream_position() + .map_err(invalid)? + .checked_add(expected as u64 * 8) + .filter(|v| *v <= file_len) + .ok_or_else(|| invalid("truncated offset table"))?; + let mut offsets = Vec::new(); + offsets.try_reserve_exact(expected).map_err(|_| limit("EXR offset allocation"))?; + for _ in 0..expected { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + let offset = u64::from_le_bytes(read(&mut source)?); + if offset < chunks_start || offset.checked_add(8).is_none_or(|v| v > file_len) { + return Err(invalid("EXR chunk offset")); + } + offsets.push(offset); + } + Ok(Self { source, meta, info, offsets, seen: vec![false; expected], channels, next: 0, file_len, chunks_start, raw_limit, rows }) + } + + pub fn info(&self) -> Info { + self.info + } + + /// Decode one block. Check cancellation while reading and between channel rows. + pub fn next_band(&mut self, cancel: &dyn Fn() -> bool) -> Result, CodecError> { + let Some(offset) = self.offsets.get(self.next).copied() else { + return Ok(None); + }; + if cancel() { + return Err(invalid("EXR import cancelled")); + } + self.source.seek(SeekFrom::Start(offset)).map_err(invalid)?; + let y = i32::from_le_bytes(read(&mut self.source)?); + let n = usize::try_from(i32::from_le_bytes(read(&mut self.source)?)).map_err(invalid)?; + if n > self.raw_limit.saturating_mul(2).saturating_add(64 << 10) + || offset < self.chunks_start + || offset.checked_add(8).and_then(|v| v.checked_add(n as u64)).is_none_or(|v| v > self.file_len) + { + return Err(invalid("EXR compressed block length")); + } + let header = self.meta.headers.first().ok_or_else(|| invalid("missing header"))?; + let row = i64::from(y) - i64::from(header.own_attributes.layer_position.1); + if row < 0 || row as u64 >= u64::from(self.info.height) || !(row as usize).is_multiple_of(self.rows) { + return Err(invalid("EXR block coordinate")); + } + let seen = self.seen.get_mut(row as usize / self.rows).ok_or_else(|| invalid("EXR block coordinate"))?; + if *seen { + return Err(invalid("duplicate EXR block")); + } + *seen = true; + let mut packed = Vec::new(); + packed.try_reserve_exact(n).map_err(|_| limit("EXR block allocation"))?; + packed.resize(n, 0); + for bytes in packed.chunks_mut(64 << 10) { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + self.source.read_exact(bytes).map_err(invalid)?; + } + let chunk = + Chunk { layer_index: 0, compressed_block: CompressedBlock::ScanLine(CompressedScanLineBlock { y_coordinate: y, compressed_pixels_le: packed }) }; + let block = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| UncompressedBlock::decompress_chunk(chunk, &self.meta, false))) + .map_err(|_| invalid("invalid EXR compression block"))? + .map_err(invalid)?; + let (w, h) = (block.index.pixel_size.0, block.index.pixel_size.1); + if w != self.info.width as usize + || block.index.pixel_position.0 != 0 + || block.index.pixel_position.1 != row as usize + || h != self.rows.min(self.info.height as usize - row as usize) + { + return Err(invalid("EXR block shape")); + } + let expected = w.checked_mul(h).and_then(|n| n.checked_mul(header.channels.bytes_per_pixel)).ok_or_else(|| invalid("EXR block size"))?; + if block.data.len() != expected { + return Err(invalid("EXR block sample count")); + } + let nc = if self.info.gray { 2 } else { 4 }; + let count = w.checked_mul(h).and_then(|n| n.checked_mul(nc * 4)).ok_or_else(|| limit("EXR output block size"))?; + let mut data = Vec::new(); + data.try_reserve_exact(count).map_err(|_| limit("EXR output allocation"))?; + data.resize(count, 0); + if !self.info.has_alpha { + for pixel in data.chunks_exact_mut(nc * 4) { + pixel.get_mut((nc - 1) * 4..).ok_or_else(|| invalid("alpha sample"))?.copy_from_slice(&1_f32.to_le_bytes()); + } + } + for (range, line) in LineIndex::lines_in_block(block.index, &header.channels) { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + let Some(channel) = self.channels.get(line.channel).copied().flatten() else { + continue; + }; + let sample = header.channels.list.get(line.channel).ok_or_else(|| invalid("EXR channel"))?.sample_type; + let input = block.data.get(range).ok_or_else(|| invalid("EXR row size"))?; + let y = line.position.1.checked_sub(block.index.pixel_position.1).ok_or_else(|| invalid("EXR row coordinate"))?; + let output = data.get_mut(y * w * nc * 4..(y + 1) * w * nc * 4).ok_or_else(|| invalid("EXR row coordinate"))?; + for (bytes, pixel) in input.chunks_exact(sample.bytes_per_sample()).zip(output.chunks_exact_mut(nc * 4)) { + let value = match sample { + SampleType::F16 => half::f16::from_bits(u16::from_ne_bytes(bytes.try_into().map_err(invalid)?)).to_f32(), + SampleType::F32 => f32::from_ne_bytes(bytes.try_into().map_err(invalid)?), + SampleType::U32 => u32::from_ne_bytes(bytes.try_into().map_err(invalid)?) as f32, + }; + pixel.get_mut(channel * 4..channel * 4 + 4).ok_or_else(|| invalid("EXR channel"))?.copy_from_slice(&value.to_le_bytes()); + } + } + self.next += 1; + Ok(Some(Band { x: 0, y: row as u32, width: w as u32, height: h as u32, data })) + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 4c081ba074..7d71f7ef4e 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -7,8 +7,9 @@ //! metadata (EXIF, XMP, DPI, text) are preserved where the format allows. //! * [`caps`] declares per-format capabilities and [`fidelity_warnings`] //! tells the UI exactly what an export will lose. -//! * The API is I/O free (`&[u8]` in, `Vec` out) and builds for -//! `wasm32-unknown-unknown`. Decoders enforce [`Limits`] before allocating. +//! * APIs use bytes or caller-provided readers, with no ambient filesystem access, +//! and build for `wasm32-unknown-unknown`. Byte decoders enforce [`Limits`]; +//! [`exr_stream`] instead bounds metadata and one decoded compression block. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -17,6 +18,7 @@ pub mod cicp; mod codecs; mod error; pub mod exif_color; +pub mod exr_stream; mod fidelity; mod format; mod image; diff --git a/crates/codecs/tests/it/exr_stream.rs b/crates/codecs/tests/it/exr_stream.rs new file mode 100644 index 0000000000..65be2387b9 --- /dev/null +++ b/crates/codecs/tests/it/exr_stream.rs @@ -0,0 +1,161 @@ +use photocraft_codecs::{exr_stream::Decoder, *}; +use std::io::Cursor; + +fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompression) -> Vec { + let n = 31 * 35 * layout.channels(); + let values: Vec = (0..n) + .map(|i| match i % 5 { + 0 => -0.25, + 1 => 3.5, + 2 => 0.0, + 3 => 0.0001, + _ => 0.5, + }) + .collect(); + let image = Image::from_f32(31, 35, layout, &values).unwrap().convert(layout, sample); + encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: compression, ..Default::default() }).unwrap() +} + +#[test] +fn scanline_blocks_match_whole_decoder_at_each_layout_depth_and_compression() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for sample in [SampleType::F16, SampleType::F32] { + for compression in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] { + let bytes = fixture(layout, sample, compression); + let reference = decode(&bytes).unwrap(); + let target = if layout.is_gray() { ChannelLayout::GrayA } else { ChannelLayout::Rgba }; + let reference = reference.convert(target, SampleType::F32); + let mut stream = Decoder::new(Cursor::new(&bytes), &|| false).unwrap(); + assert_eq!(stream.info().all_half, sample == SampleType::F16); + let row = 31 * target.channels() * 4; + let mut actual = vec![0; reference.data().len()]; + let mut blocks = 0; + while let Some(band) = stream.next_band(&|| false).unwrap() { + actual[band.y as usize * row..(band.y + band.height) as usize * row].copy_from_slice(&band.data); + blocks += 1; + } + assert_eq!(blocks, stream.info().blocks); + assert_eq!(actual, reference.data(), "{layout:?}/{sample:?}/{compression:?}"); + } + } + } +} + +fn change_window(bytes: &mut [u8], width: i32, height: i32) { + let needle = b"dataWindow\0box2i\0\x10\0\0\0"; + let at = bytes.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); + bytes[at + 8..at + 12].copy_from_slice(&(width - 1).to_le_bytes()); + bytes[at + 12..at + 16].copy_from_slice(&(height - 1).to_le_bytes()); +} + +#[test] +fn enormous_header_is_not_a_whole_image_allocation() { + let mut bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + change_window(&mut bytes, 65536, 32768); + assert!(matches!(decode(&bytes), Err(CodecError::LimitExceeded(ref message)) if message.contains("2147483648 pixels"))); + // Streaming gets as far as the now-truncated offset table, without a pixel allocation. + assert!(matches!(Decoder::new(Cursor::new(bytes), &|| false), Err(CodecError::Malformed { .. }))); +} + +#[test] +fn cancellation_truncation_duplicate_offsets_and_forged_lengths_fail() { + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + assert!(Decoder::new(Cursor::new(&bytes), &|| true).is_err()); + let mut decoder = Decoder::new(Cursor::new(&bytes), &|| false).unwrap(); + assert!(decoder.next_band(&|| true).is_err()); + for end in [0, 8, bytes.len() / 2] { + assert!(Decoder::new(Cursor::new(&bytes[..end]), &|| false).and_then(|mut r| r.next_band(&|| false)).is_err()); + } + // Locate the offset table by finding the validated header end. + let mut input = Cursor::new(&bytes); + let _ = exr::meta::MetaData::read_from_buffered(&mut input, false).unwrap(); + let at = input.position() as usize; + let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize; + let mut corrupt = bytes.clone(); + corrupt[offset + 4..offset + 8].copy_from_slice(&i32::MAX.to_le_bytes()); + assert!(Decoder::new(Cursor::new(corrupt), &|| false).unwrap().next_band(&|| false).is_err()); + let mut duplicate = bytes.clone(); + duplicate[at + 8..at + 16].copy_from_slice(&bytes[at..at + 8]); + let mut decoder = Decoder::new(Cursor::new(duplicate), &|| false).unwrap(); + assert!(decoder.next_band(&|| false).unwrap().is_some()); + assert!(decoder.next_band(&|| false).is_err()); +} + +#[test] +fn hostile_metadata_is_bounded_and_mutated_inputs_never_panic() { + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + let mut large = bytes.clone(); + let needle = b"channels\0chlist\0"; + let length = bytes.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); + large[length..length + 4].copy_from_slice(&i32::MAX.to_le_bytes()); + assert!(matches!(Decoder::new(Cursor::new(large), &|| false), Err(CodecError::LimitExceeded(_)))); + let mut many = bytes[..8].to_vec(); + for _ in 0..4097 { + many.extend_from_slice(b"z\0string\0\0\0\0\0"); + } + many.push(0); + assert!(matches!(Decoder::new(Cursor::new(many), &|| false), Err(CodecError::LimitExceeded(_)))); + // Small deterministic mutations cover headers, offsets and compressed payloads. + for i in 0..256 { + let mut corrupt = bytes.clone(); + let at = i * (bytes.len() - 1) / 255; + corrupt[at] ^= 0xff; + assert!( + std::panic::catch_unwind(|| { + if let Ok(mut decoder) = Decoder::new(Cursor::new(corrupt), &|| false) { + while let Ok(Some(_)) = decoder.next_band(&|| false) {} + } + }) + .is_ok(), + "mutation at byte {at}" + ); + } +} + +#[test] +fn mixed_samples_and_offset_data_window_keep_coordinates_and_values() { + use exr::prelude::{AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, Layer, LayerAttributes, LineOrder, WritableImage}; + let channels = AnyChannels::sort( + vec![ + AnyChannel::new("R", FlatSamples::F16(vec![f16::from_f32(-0.25); 31 * 35])), + AnyChannel::new("G", FlatSamples::F32(vec![3.5; 31 * 35])), + AnyChannel::new("B", FlatSamples::U32(vec![42; 31 * 35])), + ] + .into_iter() + .collect(), + ); + let attributes = LayerAttributes { layer_position: exr::math::Vec2(-15, 40), ..Default::default() }; + let layer = + Layer::new((31, 35), attributes, Encoding { compression: Compression::ZIP16, blocks: Blocks::ScanLines, line_order: LineOrder::Decreasing }, channels); + let mut bytes = Cursor::new(Vec::new()); + exr::image::Image::from_layer(layer).write().to_buffered(&mut bytes).unwrap(); + let mut decoder = Decoder::new(bytes, &|| false).unwrap(); + assert!(!decoder.info().all_half); + let mut rows = 0; + while let Some(band) = decoder.next_band(&|| false).unwrap() { + assert_eq!(band.y, rows); + rows += band.height; + for p in band.data.as_chunks::<16>().0 { + let v: Vec = p.as_chunks::<4>().0.iter().map(|s| f32::from_le_bytes(*s)).collect(); + assert_eq!(v, [-0.25, 3.5, 42.0, 1.0]); + } + } + assert_eq!(rows, 35); +} + +#[test] +fn unsupported_variants_stay_on_the_guarded_legacy_path() { + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + for flag in [0x200_u32, 0x800, 0x1000] { + let mut changed = bytes.clone(); + changed[4..8].copy_from_slice(&(2 | flag).to_le_bytes()); + assert!(matches!(Decoder::new(Cursor::new(changed), &|| false), Err(CodecError::Unsupported { .. }))); + } + let mut custom = bytes[..8].to_vec(); + custom.extend_from_slice(b"chromaticities\0chromaticities\0\x20\0\0\0"); + for v in [0.64_f32, 0.33, 0.3, 0.6, 0.15, 0.06, 0.3127, 0.329] { + custom.extend_from_slice(&v.to_le_bytes()); + } + custom.extend_from_slice(&bytes[8..]); + assert!(matches!(Decoder::new(Cursor::new(custom), &|| false), Err(CodecError::Unsupported { .. }))); +} diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs index 4293fb4a48..72e878c44e 100644 --- a/crates/codecs/tests/it/main.rs +++ b/crates/codecs/tests/it/main.rs @@ -10,6 +10,7 @@ mod detect_caps; mod dpx; mod exr_cryptomatte; mod exr_multipart; +mod exr_stream; mod fidelity; mod gif_frames; mod heif; diff --git a/crates/engine/src/jobs/tests.rs b/crates/engine/src/jobs/tests.rs index 0974a5c17a..271162e3c6 100644 --- a/crates/engine/src/jobs/tests.rs +++ b/crates/engine/src/jobs/tests.rs @@ -543,6 +543,38 @@ fn open_runs_as_a_job_and_cancel_adds_nothing() { assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_))); } +/// A flat scanline EXR opened from a path goes through the band decoder in the open job and +/// arrives as the same 32-bit document the whole-file decoder makes; cancelling adds nothing. +#[cfg(any(unix, windows))] +#[test] +fn an_exr_path_open_decodes_in_bands_and_cancels_cleanly() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, ExrCompression, Format, Image, SampleType}; + let values: Vec = (0..64 * 48 * 3).map(|i| [0.25, 3.0, -0.5][i % 3]).collect(); + let image = Image::from_f32(64, 48, ChannelLayout::Rgb, &values).unwrap().convert(ChannelLayout::Rgb, SampleType::F16); + let bytes = photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: ExrCompression::Zip16, ..Default::default() }).unwrap(); + let path = std::env::temp_dir().join(format!("photocraft-open-job-{}.exr", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let reference = photocraft_io::import("band.exr", &bytes).unwrap().document; + let mut s = Session::new(); + let id = job(s.start_open("band.exr", OpenSource::Path(path.to_string_lossy().into_owned())).unwrap()); + let e = wait_event(&mut s, id); + assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}"); + let doc = &s.documents()[0].doc; + assert_eq!((doc.size, doc.mode, doc.depth), (reference.size, reference.mode, reference.depth)); + use photocraft_doc::LayerContent; + let (LayerContent::Raster(a), LayerContent::Raster(r)) = (&doc.layers[0].content, &reference.layers[0].content) else { panic!("raster background") }; + assert_eq!(a.to_interleaved(reference.bounds()), r.to_interleaved(reference.bounds())); + + let id = job(s.start_open("again.exr", OpenSource::Path(path.to_string_lossy().into_owned())).unwrap()); + s.cancel_job(id); + let outcome = wait_event(&mut s, id).outcome; + assert!(matches!(outcome, JobOutcome::Cancelled | JobOutcome::Done(_)), "{outcome:?}"); + if outcome == JobOutcome::Cancelled { + assert_eq!(s.documents().len(), 1, "a cancelled open adds no document"); + } + std::fs::remove_file(path).unwrap(); +} + /// Ask When Opening: a file whose profile doesn't match is decoded but not added; the shell takes /// it once the prompt is answered. Without Ask it opens as usual. #[test] diff --git a/crates/io/src/exr_native.rs b/crates/io/src/exr_native.rs new file mode 100644 index 0000000000..af76690056 --- /dev/null +++ b/crates/io/src/exr_native.rs @@ -0,0 +1,98 @@ +//! Native OpenEXR blocks go straight into original-depth tiled storage. +use crate::{ImportResult, IoError}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size, TILE_SIZE}; +use photocraft_raster::{Interrupt, Surface}; +use std::io::{Read, Seek}; + +/// Tiles the decoded image would add must fit the RAM tile budget when no scratch disk can +/// take cold tiles. +fn ram_tile_capacity(resident: u64, budget: u64, growth: u64) -> Result<(), IoError> { + if resident.checked_add(growth).is_none_or(|required| required > budget) { + return Err(IoError::OutOfMemory("decoded image tiles exceed the available RAM budget; enable a scratch disk or close other documents".into())); + } + Ok(()) +} + +/// A scratch disk that was on when the import started must still be on: its tiles may +/// already have moved there. +fn require_scratch(expected: bool) -> Result<(), IoError> { + if expected && !photocraft_raster::spill::enabled() { + return Err(IoError::Read( + photocraft_raster::spill::last_error().unwrap_or_else(|| "scratch storage was disabled while loading; retry the import".into()), + )); + } + Ok(()) +} + +/// Decode a single-part flat scanline EXR from `source` one compression block at a time. +/// `Ok(None)`: the header is not one the band decoder takes (multi-part, deep, tiled, custom +/// chromaticities, over its block limits or malformed by its stricter checks); the caller +/// falls back to the whole-file decoder. Once pixels are being decoded any failure fails the +/// open, so no partial document is published. +pub(crate) fn import(source: R, ctl: &Interrupt<'_>) -> Result, IoError> { + let _metadata = photocraft_raster::memory::try_reserve(32 << 20).map_err(IoError::OutOfMemory)?; + let mut decoder = match photocraft_codecs::exr_stream::Decoder::new(source, &|| ctl.cancelled()) { + Ok(decoder) => decoder, + Err(_) if ctl.cancelled() => return Err(IoError::Cancelled), + Err(_) => return Ok(None), + }; + let info = decoder.info(); + let mode = if info.gray { ColorMode::Grayscale } else { ColorMode::Rgb }; + let fmt = PixelFormat::new(mode, SampleType::F32, true); + let scratch_expected = photocraft_raster::spill::enabled(); + if !scratch_expected { + let budget = photocraft_raster::memory::refresh(); + let side = TILE_SIZE as u64; + let tiles = u64::from(info.width).div_ceil(side).saturating_mul(u64::from(info.height).div_ceil(side)); + ram_tile_capacity( + photocraft_raster::spill::stats().resident_bytes as u64, + budget.tile_bytes, + tiles.saturating_mul(side * side).saturating_mul(fmt.bytes_per_pixel() as u64), + )?; + } + let _working = photocraft_raster::memory::try_reserve(info.working_bytes).map_err(IoError::OutOfMemory)?; + let mut surface = Surface::new(fmt); + let mut done = 0; + loop { + ctl.check().map_err(|_| IoError::Cancelled)?; + require_scratch(scratch_expected)?; + let band = decoder.next_band(&|| ctl.cancelled()).map_err(|e| if ctl.cancelled() { IoError::Cancelled } else { e.into() })?; + let Some(band) = band else { + break; + }; + let rect = Rect::from_xywh(band.x as i32, band.y as i32, band.width, band.height); + if !photocraft_raster::spill::enabled() { + let missing = rect.tiles().filter(|c| surface.tile(*c).is_none()).count() as u64; + ram_tile_capacity( + photocraft_raster::spill::stats().resident_bytes as u64, + photocraft_raster::memory::stats().1.tile_bytes, + missing.saturating_mul((TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64), + )?; + } + surface.write_interleaved(rect, &band.data); + require_scratch(scratch_expected)?; + done += 1; + ctl.progress(0.05 + 0.9 * done as f32 / info.blocks.max(1) as f32); + } + // Missing alpha is filled with one in every allocated tile. Pruning these opaque + // tiles cannot remove any and would unnecessarily reload the entire scratch store. + if info.has_alpha { + surface.prune(); + } + require_scratch(scratch_expected)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut document = Document::new("", Size::new(info.width, info.height), mode, SampleType::F32); + let mut layer = Layer::new("Background", LayerContent::Raster(surface)); + if !info.has_alpha { + layer.locks.transparency = true; + layer.locks.position = true; + } + document.layers.push(layer); + document.icc_profile = + Some(if mode == ColorMode::Rgb { photocraft_cms::Builtin::LinearSrgb.profile().to_bytes() } else { photocraft_cms::builtin::linear_gray().to_bytes() }); + let warnings = if info.all_half { vec!["16-bit float samples are stored as 32-bit float".into()] } else { Vec::new() }; + ctl.progress(1.0); + Ok(Some(ImportResult { document, warnings, source_read_only: false, preview_only: false })) +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 965d626b1a..2d4371d4c1 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -45,6 +45,8 @@ mod channel_map; pub mod comps_map; pub mod doc_path; pub mod effects_map; +#[cfg(any(unix, windows))] +mod exr_native; pub mod exr_parts; mod flat; mod gradient_bake; @@ -524,7 +526,9 @@ fn import_psd( /// ([`PsdFile::from_source`]) rather than read whole: its channel data is read layer by layer as /// it is decoded, so opening it needs memory for the document, not for the document plus the /// file. A read that fails meanwhile (the file is truncated or rewritten, its drive goes away) -/// fails the open. Other formats are read whole. +/// fails the open. A single-part flat scanline EXR is decoded one compression block at a time +/// into tiles, so its size is bounded by the tile budget (or the scratch disk), not by a +/// whole-file buffer and a decoded image. Other formats are read whole. #[cfg(any(unix, windows))] pub fn import_file_for_open_with( name: &str, @@ -539,6 +543,23 @@ pub fn import_file_for_open_with( let psd = !has_extension(name, photocraft_format::EXTENSION) && photocraft_psd::ReadAt::read_at(&source, 0, &mut magic).is_ok() && is_psd(&magic); if !psd { drop(source); + // A flat scanline EXR is decoded block by block straight into tiles; other EXR + // layouts, and EXR headers the band decoder refuses, are read whole below. + if !has_extension(name, photocraft_format::EXTENSION) && photocraft_codecs::detect(&magic) == Some(photocraft_codecs::Format::OpenExr) { + let before = photocraft_raster::spill::read_error_generation(); + let mut file = std::fs::File::open(path).map_err(read_failed)?; + let original = file.metadata().map_err(read_failed)?; + if let Some(mut result) = exr_native::import(&mut file, ctl)? { + let current = std::fs::metadata(path).map_err(read_failed)?; + if current.len() != original.len() || current.modified().ok() != original.modified().ok() { + return Err(IoError::Read(format!("{}: the file changed while it was being opened; open it again", path.display()))); + } + photocraft_raster::spill::check_since(before).map_err(IoError::Read)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + result.document.name = name.to_string(); + return Ok((result, false)); + } + } let bytes = photocraft_format::read_file(path).map_err(read_failed)?; return import_for_open_with(name, &bytes, opts, ctl); } diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 2280e2d7d9..0c29aa08ea 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -73,6 +73,7 @@ mod resolution_axes; mod roundtrip; mod smart_contents_identity; mod smart_corpus; +mod streaming_exr; mod svg; mod text_corpus; mod text_styles; diff --git a/crates/io/tests/it/streaming_exr.rs b/crates/io/tests/it/streaming_exr.rs new file mode 100644 index 0000000000..3c395bec3f --- /dev/null +++ b/crates/io/tests/it/streaming_exr.rs @@ -0,0 +1,92 @@ +use photocraft_codecs::{ChannelLayout, EncodeOptions, ExrCompression, Format, Image, SampleType}; +use photocraft_doc::LayerContent; +use photocraft_raster::Interrupt; +use std::sync::atomic::{AtomicBool, Ordering}; + +fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompression) -> Vec { + let values: Vec = (0..31 * 35 * layout.channels()).map(|i| [-0.25, 4.0, 0.0, 0.0001, 0.5][i % 5]).collect(); + let image = Image::from_f32(31, 35, layout, &values).unwrap().convert(layout, sample); + photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: compression, ..Default::default() }).unwrap() +} + +fn open(path: &std::path::Path, ctl: &Interrupt) -> Result { + photocraft_io::import_file("HDR", path, &photocraft_io::ImportOptions::default(), ctl) +} + +// One test keeps these imports from competing for the global working allowance. +#[test] +fn native_scanline_import_preserves_hdr_document_and_cancels_without_publication() { + let path = std::env::temp_dir().join(format!("photocraft-exr-stream-{}.exr", std::process::id())); + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for sample in [SampleType::F16, SampleType::F32] { + for compression in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] { + let bytes = fixture(layout, sample, compression); + std::fs::write(&path, &bytes).unwrap(); + let reference = photocraft_io::import("HDR", &bytes).unwrap(); + let actual = open(&path, &Interrupt::NONE).unwrap(); + { + let a = &actual.document; + let r = &reference.document; + assert_eq!(actual.warnings, reference.warnings); + assert_eq!(a.name, r.name); + assert_eq!(a.size, r.size); + assert_eq!(a.mode, r.mode); + assert_eq!(a.depth, photocraft_color::SampleType::F32); + assert_eq!(a.icc_profile, r.icc_profile); + assert_eq!(a.layers[0].locks, r.layers[0].locks); + let (LayerContent::Raster(a), LayerContent::Raster(r)) = (&a.layers[0].content, &r.layers[0].content) else { + panic!("raster background required") + }; + assert_eq!( + a.to_interleaved(reference.document.bounds()), + r.to_interleaved(reference.document.bounds()), + "{layout:?}/{sample:?}/{compression:?}" + ); + } + } + } + } + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + std::fs::write(&path, &bytes).unwrap(); + let cancelled = AtomicBool::new(false); + let cancel = || cancelled.load(Ordering::Relaxed); + let progress = |p| { + if p > 0.2 { + cancelled.store(true, Ordering::Relaxed); + } + }; + assert!(matches!(open(&path, &Interrupt::new(&cancel, &progress)), Err(photocraft_io::IoError::Cancelled))); + std::fs::write(&path, &bytes[..bytes.len() / 2]).unwrap(); + assert!(open(&path, &Interrupt::NONE).is_err()); + // A syntactically complete huge header/table must be refused before reading + // its pixel blocks when scratch is unavailable. No giant fixture is needed. + assert!(!photocraft_raster::spill::enabled()); + let mut header_end = 8; + loop { + if bytes[header_end] == 0 { + header_end += 1; + break; + } + for _ in 0..2 { + while bytes[header_end] != 0 { + header_end += 1; + } + header_end += 1; + } + let length = i32::from_le_bytes(bytes[header_end..header_end + 4].try_into().unwrap()) as usize; + header_end += 4 + length; + } + let mut huge = bytes[..header_end].to_vec(); + let needle = b"dataWindow\0box2i\0\x10\0\0\0"; + let at = huge.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); + huge[at + 8..at + 12].copy_from_slice(&65535_i32.to_le_bytes()); + huge[at + 12..at + 16].copy_from_slice(&32767_i32.to_le_bytes()); + let offset = (header_end + 2048 * 8) as u64; + for _ in 0..2048 { + huge.extend_from_slice(&offset.to_le_bytes()); + } + huge.extend_from_slice(&[0; 8]); + std::fs::write(&path, &huge).unwrap(); + assert!(matches!(open(&path, &Interrupt::NONE), Err(photocraft_io::IoError::OutOfMemory(ref message)) if message.contains("decoded image tiles"))); + std::fs::remove_file(path).unwrap(); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index a47431dac2..d2ca4a8435 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -124,3 +124,5 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on | 2026-10-05 | major | Honest parity assessment added | 2026-10-13: native scratch storage pages current and history tiles through compressed, CRC-checked files. Fresh RAM budgets default to 70%; engine-owned memory supervision stops with its last session. Scratch errors block edit/save commits and permit retry. Unix files are private and stale files swept at startup; Windows files use delete-on-close. See [scratch disks](scratch-disks.md). Streaming import and viewport rendering follow separately. + +2026-10-13: native path opens of single-part flat scanline EXR files decode one compression block at a time into tiles at the original resolution. See [EXR streaming](streaming-io.md). diff --git a/docs/scorecard.md b/docs/scorecard.md index c96a9cbe20..db283ba4be 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -276,7 +276,7 @@ Never-crash attribute on 30 of 30 crate roots. | REL-2 | Every workspace crate carries the never-crash deny(clippy::unwrap_used, ...) attribute | done | | Counted automatically above (crate roots under crates/ and apps/); testkit gained it in #221 | | REL-3 | The app catches escaped panics around command dispatch and import/export | done | | a_panic_in_a_command_... runs the test-only `test.panic` spec through Session::execute and asserts Err plus an unchanged document (engine jobs_tests.rs); the worker guard is covered by a_panic_in_the_worker_...; crash_guard.rs guard_turns_a_panic_into_an_error covers import/export (apps/photocraft) | | REL-4 | Atomic save (temp, fsync, rename) for every document write | done | [#203](https://github.com/storytold/photocraft/issues/203) | photocraft_format::atomic_write (format/src/atomic.rs, #230): same-dir temp, sync_all, rename with Windows PermissionDenied retry, Unix dir fsync; every document write site routes through it and format/src/atomic/tests.rs failure-injects write, rename and read-only-dir errors | -| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit. | +| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Native path opens of flat scanline EXR decode bounded blocks into tiles; save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit. | | REL-212-2 | Export switches to PSB when the estimated file size exceeds 2 GB | done | [#212](https://github.com/storytold/photocraft/issues/212) | PSD export uses checked size estimates for raster, masks, channels, and retained metadata | | REL-212-3 | Tests: an allocation-limit injection makes each command Err with the document unchanged | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Engine failpoint tests cover paste, erase, fill, transform, import and save transaction boundaries; codec-level allocator failure coverage remains open | diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md index 93f314a426..bcc3103bce 100644 --- a/docs/scratch-disks.md +++ b/docs/scratch-disks.md @@ -56,5 +56,6 @@ retry the original bytes after the storage becomes available; zeros from a faile read must never become saved pixels. Limitation: a tile that was already unreadable and is read from outside the edited document (for example a clipboard copy) is not detected. -Scratch storage alone does not make every format, filter or renderer streamable. The native -PSD/EXR import and viewport streaming changes build on these guards and memory reservations. +Scratch storage alone does not make every format, filter or renderer streamable. See +[EXR streaming](streaming-io.md) for the block-by-block EXR import. Viewport rendering is a +separate change built on these guards and memory reservations. diff --git a/docs/streaming-io.md b/docs/streaming-io.md new file mode 100644 index 0000000000..b7a84de6a2 --- /dev/null +++ b/docs/streaming-io.md @@ -0,0 +1,27 @@ +# EXR streaming + +Native path opens (`photocraft_io::import_file_for_open_with`, used by the background open job +and by automation file opens) decode single-part flat scanline EXR files one compression block +at a time straight into guarded raster tiles. The document keeps the source dimensions and +samples; HALF samples are stored as F32, with a warning. Tiles count against the RAM tile +budget, and scratch residency can move cold tiles to disk; without a scratch disk an image whose +tiles cannot fit the budget fails with a clear out-of-memory error before any pixels are +decoded. Layered PSD/PSB files use the existing streaming PSD reader on the same route. + +The block decoder (`photocraft_codecs::exr_stream`) treats the file as untrusted. Header metadata +is bounded (8 MiB, 4096 attributes); dimensions, channel count, chunk count, offsets, block +lengths and sample counts are checked against the file length and the header before anything is +allocated, and one decoded block is capped at 64 MiB. Duplicate, misaligned or truncated blocks +fail the open. It picks the same colour channels as the whole-file decoder. + +Multi-part, deep, tiled, subsampled and custom-chromaticity files, and headers the block decoder +refuses, fall back to the whole-file decoder, under its own limits and working-memory admission. +Web and byte opens always use the whole-file decoder. + +Cancellation is checked between reads and between scanlines, and progress reports completed +lines. A cancelled or failed open publishes no partial document. The open also fails if the +file's size or modification time changed while it was being read. + +Tests compare the streamed document with the whole-file decoder across Gray/GrayA/RGB/RGBA, HALF +and FLOAT, and None/RLE/ZIP/ZIPS/PIZ compression, and cover cancellation, truncation, forged +offsets and lengths, hostile metadata and oversized headers. diff --git a/scorecard/reliability.toml b/scorecard/reliability.toml index d5fc1fb5a7..2939a9152a 100644 --- a/scorecard/reliability.toml +++ b/scorecard/reliability.toml @@ -35,7 +35,7 @@ id = "REL-212-1" target = "Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error" status = "partial" issue = 212 -note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit." +note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Native path opens of flat scanline EXR decode bounded blocks into tiles; save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit." [[item]] id = "REL-212-2"