Merge pull request #5331 from vistaero/codex/psd-exr-streaming

Stream native PSD/PSB and EXR imports through bounded bands
This commit is contained in:
FredApps authored and GitHub committed 2026-11-03 10:07:47 +01:00
commit d0bff086d6
14 files changed
+739 -7

No files matched your search

+294
View File
@@ -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<const N: usize>(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<u8>) -> Result<bool, CodecError> {
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<Vec<u8>, 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<u8>,
}
pub struct Decoder<R> {
source: R,
meta: MetaData,
info: Info,
offsets: Vec<u64>,
seen: Vec<bool>,
channels: Vec<Option<usize>>,
next: usize,
file_len: u64,
chunks_start: u64,
raw_limit: usize,
rows: usize,
}
impl<R: Read + Seek> Decoder<R> {
/// Read only bounded metadata and offsets; allocate no full-image or channel buffer.
pub fn new(mut source: R, cancel: &dyn Fn() -> bool) -> Result<Self, CodecError> {
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<String> = 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<Option<usize>> = 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<Option<Band>, 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 }))
}
}
+4 -2
View File
@@ -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<u8>` 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;
+161
View File
@@ -0,0 +1,161 @@
use photocraft_codecs::{exr_stream::Decoder, *};
use std::io::Cursor;
fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompression) -> Vec<u8> {
let n = 31 * 35 * layout.channels();
let values: Vec<f32> = (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<f32> = 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 { .. })));
}
+1
View File
@@ -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;
+32
View File
@@ -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<f32> = (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]
+98
View File
@@ -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<R: Read + Seek>(source: R, ctl: &Interrupt<'_>) -> Result<Option<ImportResult>, 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 }))
}
+22 -1
View File
@@ -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);
}
+1
View File
@@ -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;
+92
View File
@@ -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<u8> {
let values: Vec<f32> = (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::ImportResult, photocraft_io::IoError> {
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();
}
+2
View File
@@ -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).
+1 -1
View File
@@ -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 |
+3 -2
View File
@@ -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.
+27
View File
@@ -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.
+1 -1
View File
@@ -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"