mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-06 18:34:01 +00:00
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:
14 files changed
+739
-7
No files matched your search
@@ -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 }))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user