* fix(codecs): checked sizes in encoders, EXR name lookup, FITS float limit, VP8 plane copies, EXIF thumbnail bytes
* address review: thumbnail range must be one SHORT/LONG tag and clear of IFD0
* address review: keep the TIFF header and Exif/GPS sub-IFDs out of the blanked thumbnail range
* address review: bound the Exif/GPS sub-IFD overlap scan with a total budget
* address review: keep the Interoperability IFD out of a blanked thumbnail range
* codecs: list each Cryptomatte layer once and index streams by name
layers() cloned the cached stream list for every cryptomatte/<key>/name
attribute that named a layer, so many keys naming one layer (in any
case or _/- spelling) retained keys x channels strings. Streams are now
indexed by normalized layer name in one pass over the channels, each
layer's list is moved into the first key that names it, and later keys
naming the same layer are skipped (decode resolves a name to its first
layer anyway).
decode() searched the layer's stream list for every decoded channel;
it now builds a stream-name -> slot map once.
* address review: compare cryptomatte layer names normalized in decode
* address review: prefer exact Cryptomatte layer name
* address review: sort cryptomatte keys so layer names are stable
* address review: prefer the key with a usable manifest when keys name one cryptomatte layer
#9781 and #10983 each defined TAG_JPEG_TABLES and TAG_PREDICTOR in tiff/decode.rs and
merged minutes apart, so main stopped compiling (E0428). Keep one definition, and move the
structural-tag checks and the held-bytes sum out of prepare, whose cognitive complexity the
two changes together pushed past clippy's limit.
* Use craft-heif from craft-libs; remove crates/heif
PhotoCraft's HEIF decoder had drifted from LightCraft's by about 700 lines;
both now use craft-heif (storytold/craft-libs#784), built from LightCraft's
copy. HEIC colours now follow the HEVC VUI's range and matrix and libheif's
chroma upsampling. 8-bit files still open as 8-bit documents; a colour
description the VUI specifies now tags the image as an nclx did.
The codecs -> heif standalone exception ends with the local crate, and the
HEIF nextest step drops -p photocraft-heif, which no longer exists.
* Pin craft-heif to the merge of storytold/craft-libs#794
* heif: count the decoder's 16-bit buffer against max_alloc
* heif: match Apple's chroma upsampling; pin craft-heif to storytold/craft-libs#799
* heif: free the decoder's buffer before the HDR conversion and count that conversion's peak
* heif: hold the canvas the decoder really allocates to max_alloc
## Summary
`decode_deep` (deep OpenEXR, `deepscanline` / `deeptile`) admitted the data window at four bytes a pixel per channel, then built 24 bytes a pixel of bookkeeping that `max_alloc` never saw: a `u32` count per pixel, per-chunk `Vec<usize>` pixel indices and `Vec<u32>` prefix tables kept until the second pass, and the returned `u64` index. A 4096x4096 deep file of 65 to 300 KB held 384 MiB live under a 64 or 128 MiB budget and returned `Ok`.
This change:
- keeps one per-pixel structure, the cumulative `u64` index the result returns anyway (`DeepImage::counts`, 8 B/px plus one entry), allocated fallibly and **charged against `max_alloc` before anything else is allocated**;
- has each chunk record only its block bounds, parses the count tables straight into that index, and places each block row's samples as one contiguous range in the second pass (a block row is a run of consecutive raster pixels), so no per-chunk per-pixel vectors remain;
- admits the count tables (with their scratch copies) and the sample buffers against what the index leaves of the budget, so the live total stays within `max_alloc`;
- requires the offset table to list exactly one chunk per block (a duplicate or missing chunk was already malformed), so `seen` and the chunk list stay proportional to the offset table in the file rather than to the header's geometry;
- allocates the sample channels fallibly (`alloc_zeroed`) instead of with `vec!`.
Existing tests that pin exact budgets (`deep_tile_count_table_is_admitted_against_max_alloc`, `clipped_count_tables_include_scratch_in_the_budget`, `raw_count_tables_need_no_scratch_even_with_compression`) now add the index's size to their boundaries; their table-and-scratch semantics are unchanged.
Closes#9785
## Tests
- New `deep_exr::deep_bookkeeping_is_charged_against_max_alloc` (`crates/codecs/tests/it/deep_exr.rs`): a synthetic 256x256 one-channel deep image without samples, scan lines and one tile, NONE / RLE / ZIPS. Under a four-byte-a-pixel budget both `decode_deep_exr` and `decode_with` must return `LimitExceeded`; it decodes at exactly index + count table (+ scratch) and fails one byte below.
- Before the fix: **fails** (`tile None, compression 0: a four-byte-a-pixel budget gave Ok(65537)`).
- After the fix: passes.
- Allocation audit (counting global allocator; local only, not committed since it needs `unsafe`), 4096x4096, one float channel, ZIPS, zero counts:
| File | max_alloc | Before | After |
|---|---|---|---|
| deepscanline, 307,525 bytes | 64 MiB | `Ok`, peak 384 MiB (24.0 B/px), largest request 128 MiB | `LimitExceeded` (index needs 134,217,736 bytes), peak 0 MiB |
| deeptile, 65,597 bytes | 128 MiB | `Ok`, peak 384 MiB (24.0 B/px), largest request 128 MiB | `LimitExceeded` (index needs 134,217,736 bytes), peak 0 MiB |
- `cargo test -p photocraft-codecs --test it -- deep exr`: 81 passed.
- `cargo test -p photocraft-codecs --lib exr`: 3 passed.
- `cargo clippy -p photocraft-codecs --lib --tests -- -D warnings`: clean.
- `cargo fmt -p photocraft-codecs -- --check`: clean.
## Not covered
- The second pass still holds one chunk's decompressed sample data (and its byte-split scratch copy) next to the sample buffers; each is bounded by the samples already admitted, but the sum is not charged as one total.
- `flatten` (the flat `decode` path) admits its output and display-stage buffers one at a time while the deep image is still live.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **Bug Fixes**
* Improved handling of deep EXR images when memory limits are configured. Decoding now accounts for additional sample-count data when checking available memory, and rejects inputs whose required allocations exceed the limit.
* Improved validation of deep-image count tables, including tables associated with tiled images.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
* codecs: check TIFF structural tags, predicted rows and JPEG chunk frames
- Duplicate structural tags: the directory checks read a tag's first
entry and the tiff crate its last, so a small SamplesPerPixel first
and a large one last was admitted with one value and sized with the
other. A repeated structural tag is now refused.
- Floating-point predictor: the tiff crate reads each tile row at its
full stored width into a buffer of its own, outside the scratch cap.
That row (TileWidth x samples x sample size) must now fit the cap.
- JPEG-compressed chunks: the tiff crate decodes each chunk into a
whole JPEG image sized by the embedded frame header, with no limits.
Each chunk's frame (read from the chunk or JPEGTables, at most 256
marker segments in) must now fit the nominal strip or tile, as
libtiff requires, and its decoded size must fit the scratch cap.
* address review: JPEG chunk dedup by count, offset-2 frame, shared fill budget
* address review: bound JPEG chunk list by scratch cap
* address review: reserve JPEG chunk list at the budgeted length
read_iccp stopped at the first iCCP whose CRC did not match, although its own
doc comment promised the first CRC-valid one. The sibling walk for sTER three
lines above does `continue` in the same place.
Which behaviour is right is png's to decide, so the test measures it rather
than asserting mine: it corrupts an iCCP in place, inserts it before the valid
one, lets png read the chunk itself (no set_ignore_iccp_chunk), and requires
read_iccp to give the same answer. png reports the later valid profile, so
stopping dropped one it would have kept — `continue` is an alignment with the
reference, not a loosening of it.
A PNG carries at most one iCCP (spec 11.3.3.3), so this only decides what
happens to an already malformed file, and there it now answers as png does.
The doc comment said both "the first CRC-valid" and "no later chunk is looked
for", which cannot both be true; it says what the code does now.
The completeness test from the previous commit counted decoders over the whole
file, so the measuring test's deliberate unflagged decoder broke it. It counts
the production half only, which is the claim it was making.
Checked by putting `return None` back:
left: None
right: Some(2048)
our answer against png's, which is the bug.
cargo test -p photocraft-codecs --lib: 166 passed, 0 failed, 1 ignored.
cargo clippy -p photocraft-codecs --all-targets -- -D warnings: clean.
cargo fmt --check clean.
clippy::bool-assert-comparison, which -D warnings makes an error, so the gate
stopped at clippy and skipped everything after it. Clippy's own correction.
My own miss: I ran fmt and the tests before pushing but not clippy, which the
quality gate names alongside them.
cargo clippy -p photocraft-codecs --all-targets -- -D warnings: clean.
cargo test -p photocraft-codecs --lib: 165 passed, 0 failed, 1 ignored.
Merging main brought a second rewrite of png.rs (PngDecodeRows, SharedInput,
Tracked) and, with has_additional_images, a third decoder that unpacks iCCP
without a cap. main's file is the base here and this change is ported onto it,
rather than the conflicts being decided line by line: the file has changed
shape twice under the branch.
What the port carries: read_iccp with MAX_ICC_BYTES, the capped read in
extras(), and set_ignore_iccp_chunk(true) on all four decoders —
PngDecodeRows::open, has_additional_images, palette and the one-call test
helper. palette and has_additional_images read headers from untrusted bytes and
never look at the profile, so skipping it costs them nothing and closes the
same gigabyte inflation decode had.
A new test pins the completeness rather than the three sites: it compares how
many png::Decoder values this file builds with how many skip iCCP, so a decoder
added later without the flag fails it. Checked by removing the flag from
palette:
assertion left == right failed: every png::Decoder in this file skips iCCP
left: 5
right: 4
cargo test -p photocraft-codecs --lib png: 23 passed, 0 failed, 1 ignored.
cargo fmt --check clean.
PngImport decoded the whole image and then copied it into a second, tile surface (about
+400 MB for a 100 MP RGBA8 file, enough to kill a browser tab). A PNG whose rows come in
final order now streams: PngDecodeRows::step_stream hands each group of rows on, they
become tiles at once (the PngStream the one-shot streamed import also uses) and are
dropped, then the prune scan runs in batches. decode_rows is the same step_stream driven
to the end, so there is one path. Interlaced, animated and early-eXIf-oriented files are
decoded whole as before; a late eXIf sends a streamed import back to a whole decode.
The turn upright of an EXIF orientation 2-8 was one whole-buffer pass in the poll of the
last rows; OrientSteps (photocraft-codecs) does it in bands of 64 rows through the same
band writer as Image::oriented, and PngImport runs it as a stage of its own.
Memory-budget errors keep their type and text: the stream is checked as the one-shot
streamed import is, and, like the whole-image path, a layer under 32 MiB is not.
Docs for the stepped place updated.
The remaining commits of the stack, applied on top of the squash-merged #10147:
- PNG decodes in resumable steps of rows (PngDecodeRows, shared with decode_rows)
- PNG imports as a document in resumable steps (io PngImport, text metadata and
document build in bounded steps)
- Jobs can run a slice at a time where there are no threads (StepWork,
cooperative sessions, Session::poll_jobs)
- Place decodes, flattens and resizes in slices (PlaceSteps), reading a path
and keeping the frame, memory gate and typed memory-budget errors main has
- The web build places dropped and picked files a slice per frame, with a
bounded place inbox
- A measurement of the stepped place; docs for jobs without threads
The `tiff` crate reads both strip or tile tables in full while
`Decoder::new` opens the directory, under its own default limits
(`with_limits` applies only after it returns). The band-worker budget
charged those tables only afterwards, so a page with many strips and a
small `max_alloc` allocated them before it was refused. `prepare` now
charges the tables (DECODER_BYTES_PER_CHUNK per chunk, as every worker
holds them) before opening the decoder and returns LimitExceeded when
they alone are over the budget.
New test `strip_tables_are_refused_before_the_decoder_opens`: a 1x65536
gray page in one-row strips (1 MiB of tables) under a 512 KiB budget.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pre-decode SOF scan read every JPEG strip or tile from its start, so
chunks that overlap (65536 tiles over the same 512 KiB without a 0xFF)
made opening a file step through tens of billions of bytes. The walk now
takes one step per byte it steps over, from a budget of the file's length
plus 64 steps per chunk shared by every chunk and JPEGTables. Chunks that
don't overlap fit in the file's length; running out refuses the image
with InvalidImage.
A JPEG chunk with no SOF is now charged its compressed stream, which the
tiff crate copies whole before zune-jpeg reads it, and the floating-point
predictor row is charged for strips too (the tiff crate decodes a strip
row by row when it holds samples it does not return).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(io): build JPEG/PNG tiles straight from the decoded rows, in parallel (#10259)
Surface::try_write_rows_with fills tiles row by row on all cores; image_surface
converts from the borrowed decoded rows into it (no per-band copy, no staging
Image). Opaque sources skip the prune scan. import_flat_with checks cancel after
the decode and the tile build and reports progress.
* fix(io): store an F16 target as F32 so its opaque alpha is 1.0
An image without alpha converted to an F16 target got the alpha 0.0: the
opaque-alpha path copied two bytes of 1.0f32. The surface keeps 16-bit float
as 32-bit float anyway, so image_surface now converts an F16 target to F32.
* Format fused tile import integration
* perf(io): decode PNG rows straight into the layer's tiles (#10259)
* test(codecs): PngSuite rows test skips files without a PNG signature (#10259)
* perf(io): stream PNG rows only for images of 32 MiB or more (#10259)
* Update crates/codecs/src/codecs/png.rs
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
* fix(io): name the image, not the failed reservation, in PNG/JPEG allocation errors (#10259)
A failed surface reservation is one tile or row band, so reporting its bytes as
"a N MB image" was misleading (usually "0 MB"). Report the image's pixel size.
* refactor(codecs): the caller picks the PNG row-group height (#10259)
decode_png_rows takes the rows per group instead of a fixed 256, and io
passes TILE_SIZE, so the groups can't drift from the tile rows they fill.
A group of 0 counts as 1.
* Complete PNG streaming integration with upstream TIFF imports
* Preserve F16 conversion and route default imports through interrupt wrapper
* fix(io): check free memory before PNG tiles, and cancel TIFF imports after the decode (#10259)
import_png_streamed refuses an image whose tiles and one row group the machine
can't hold before the first tile exists, as tiff_to_document does; the
whole-image path (the base PNG path among it) checks the layer's tiles before
allocating them, for images above the streaming size, so a small image still
opens where the reserve alone would have refused it.
import_flat_with now honours the interrupt after a successful TIFF import, as
the whole-image path does; a decode error is still reported as itself.
* fix(io): pass lazy memory probe to PNG importer
* fix(io): handle scratch read errors during flat import
* test(io): respect early TIFF import cancellation
* fix(io): thread ImportOptions decode limits into PNG row streaming
After merging main, the whole-image open path respects
ImportOptions::flat_max_pixel_bytes via decode_limits, but the
streamed PNG path still used DecodeOptions::default(). Pass opts
through so both paths share the same decoder limits.
* fix(io): silence clippy needless_update on DecodeOptions; rustfmt
DecodeOptions only has limits + keep_orientation, so specifying both
and ..Default::default() trips -D clippy::needless_update. Also apply
rustfmt to the decode_png_rows call that gate's fmt check rejected.
---------
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <fred@apps.local>
Engine: droplet dependency capture skips disabled steps; a nested Batch
that was cancelled or could not write its error log fails the caller;
runDroplet forwards overrideOpen; actions.rename follows named Batch
steps; Batch steps inside a script count against MAX_SCRIPT_STEPS;
output claims compare destination identity; Save and Close never
replaces a read-only source. ATN: Md must be a BlnM enumeration,
adjustment-layer names must round-trip, Calculations exports a missing
blending as Multiply, Apply Image refuses unnamed-layer transparency.
Filters: Shape Blur records its resolved geometry, reserves its kernel
memory first, and reports a preparation failure as an error, not a
cancel; Crop keeps an artboard preset when the size is unchanged and
fits the board on a turned crop.
Codecs/cms/algo: zero-axis FITS image HDUs are empty; a GIF walk that
breaks off before the trailer fails closed; two-value .3dl shaper rows
are applied; fragmented custom kernels are refused and their work polls
cancellation.
UI: Conditional branches play through panel playback; paused actions
refuse to resume after being replaced; Playback Options OK validates
its fields; transform double-click ignores a hidden pivot; path boxes
snap to a stationary selection; modal blocking uses the effective tool;
a picker-consumed release clears snap lines and ends a Move drag; the
Crop preset control shows Selection and checks only it; Crop box undo
restores frame_from_selection; Bosnian fill labels.
Tests: timing asserts moved to an ignored test, plus the review's
coverage nitpicks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(io): build JPEG/PNG tiles straight from the decoded rows, in parallel (#10259)
Surface::try_write_rows_with fills tiles row by row on all cores; image_surface
converts from the borrowed decoded rows into it (no per-band copy, no staging
Image). Opaque sources skip the prune scan. import_flat_with checks cancel after
the decode and the tile build and reports progress.
* fix(io): store an F16 target as F32 so its opaque alpha is 1.0
An image without alpha converted to an F16 target got the alpha 0.0: the
opaque-alpha path copied two bytes of 1.0f32. The surface keeps 16-bit float
as 32-bit float anyway, so image_surface now converts an F16 target to F32.
* Format fused tile import integration
* Route default flat imports through interrupt wrapper
* Remove unused flat import cancellation wrapper
* Exercise options import API in cancellation tests
* Stop flat image tile construction on cancellation
---------
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
* codecs: admit animated GIF screens, sparse RLE BMPs and icon PNG profiles
- Animated GIF decoding (decode_gif_frames) skipped the logical-screen
plausibility check the first-frame decode applies, although the
backend initializes the whole screen before reading a frame. Both now
share gif_plausible().
- RLE BMPs skipped the file-size plausibility check entirely, so a
header-only file could make the decoder allocate and clear a
multi-gigabyte canvas. Like a GIF's screen, 256 MiB of canvas is now
free of data and the rest must be backed by the file.
- The image crate's ICO decoder opens an embedded PNG with no limits,
and png inflates an iCCP profile while the decoder is built. Icon
PNG profiles are now inflated into a sink first and refused past
255 x 65519 bytes (or max_alloc), with a 64 MiB total across entries.
* address review: visit each ICO profile chunk offset once
* codecs: charge dropped PNG text against the metadata budget
read_text_metadata debited the shared text allowance only for text it
kept. A compressed iTXt chunk that inflated up to the whole remaining
allowance and then failed UTF-8 validation (or hit a corrupt or
truncated stream) was dropped without being charged, so every further
such chunk could repeat the same inflation. bounded_text now reports
the bytes it produced and the caller charges them whether or not the
text is kept.
* codecs: charge the output a failing text decompress wrote
* address review: charge Latin-1 zTXt error batch at UTF-8 length
* codecs: leave Extended XMP fragments out of zune-jpeg's header parse
zune-jpeg 0.5 keeps every Extended XMP APP1 fragment until its GUID's
set is complete, and re-sorts and re-checks the whole pending list
after every marker it parses. A file with many small, consistent but
incomplete fragments (or a few followed by many other markers) costs
quadratic time before any pixel is decoded.
Nothing in PhotoCraft reads zune's reassembled Extended XMP (metadata
comes from scan_metadata over the original bytes), so the decoder now
hands zune a copy without those segments when a file has any. Files
without Extended XMP are passed through unchanged.
* codecs: reserve only the bytes kept after dropping Extended XMP segments
Main split the TIFF decoder into Prepared and TiffBands. The scratch
budget now lives there: prepare() works out one band worker's working
memory, and decode_ifd and TiffBands::decode run only as many workers
as fit in max_alloc.
The worker charge also covers the two tiff-crate allocations that
ebosido444-ai reported. The first is the frame zune-jpeg decodes from
each JPEG chunk's SOF, plus its coefficients and compressed stream. A
frame larger than its strip or tile is refused, as libtiff refuses it.
The second is the declared-width row the floating-point predictor
decodes into: a hostile TileWidth is now a LimitExceeded, not an
aborting allocation. bands_per_task allows dead_code on wasm32, and the
new tests have doc comments.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Offer TIFF compression, image pyramid and transparency options on Save As and Save a Copy, with matching CLI and automation parameters.
Follow-up: preserve the unsaved state when a copy discards layers over the document's own file, and check the other export formats for the same issue.
Closes#8827.
The exr crate sizes a preview attribute's pixel buffer from its
declared dimensions (up to 4 MiB at once, zero-filled) before it finds
the attribute too short, and in the non-pedantic mode every reader here
uses, it then skips the attribute and continues. A header with
thousands of tiny malformed previews therefore cost thousands of 4 MiB
allocations before any size check ran.
check_previews walks the raw headers and refuses a preview whose
declared width x height x 4 exceeds its value size. It runs before
every header read: read_meta, read_layer, the Cryptomatte listing and
decode_deep_exr.
Resolve conflicts with #5331 (EXR streaming): the block-by-block EXR import now
reads from the same identity-verified handle as the rest of the open path, and
docs/scratch-disks.md keeps both the EXR streaming pointer and the RAM/scratch
admission notes.
Resolve against main's save restructure (Services::save_file/SaveCtl, scratch-integrity checks,
ZipWriter::with_entries, exact encoded_len):
- PSD/PSB saves stream the file model through atomic_write_stream_progress; the length used for
progress and reservations is main's exact encoded_len (the PR's size_bound under-counted lazy
data); export_with/encode_with share checked_stages, so main's scratch checks (previews
included) still refuse unreadable scratch.
- Drop ZipWriter::for_entries in favour of main's with_entries.
- TIFF: take_from_psd moves layer data out of the export's own model (never the document);
body() reserves its exact length, failures are OutOfMemory (not LimitExceeded, which would
trigger the PSB retry).
- Streaming writer: AlreadyExists from the writer is a failed save, not a name collision.
- Remove dead Vec put_widened_rle; channel_data_len is test-only.
- import_file_for_open_with decodes single-part flat scanline EXR block by block into
tiles; headers the band decoder refuses fall back to the whole-file decoder, so files
main opened still open. Band-time failures and cancellation publish no document.
- The RAM tile budget and scratch-disk checks live in exr_native (the PSD stream module
they came from is dropped); over-budget opens fail with IoError::OutOfMemory.
- The band decoder picks colour channels with exr_color_channels, like the byte decoder.
- Tests move into the per-crate it binaries and use import_file; an engine open-job test
covers the OpenSource::Path route. The ui-egui sync open test is dropped: main's
synchronous open reads bytes, and the io test covers the oversized header.
- docs/streaming-io.md, roadmap, scratch-disks and REL-212-1 describe EXR only.
Main already opens PSD/PSB from the file in bands (4915f0970, #5106) through
psd_to_document_owned, which keeps damaged-file flagging, failed-layer drops,
type-layer setup and the PSD pixel budget; PSD limits are handled in #10778.
This drops the second PSD/PSB stream reader (psd::stream, io::psd_stream),
the seekable PSB writer and atomic stream helpers, the authorized-workspace
seekable open plumbing and the working-memory pre-check that refused
ordinary exports. The flat scanline EXR band decoder and its tests stay and
are wired into main's path open after merging main.
Keep the PSD memory admission work and upstream's scratch storage
together. Conflicts resolved:
- Cargo.lock: both sysinfo versions are required. 0.40.1 serves
photocraft-io's adaptive import budget; 0.37.2 serves
photocraft-raster's scratch volume and memory supervision. Cargo
metadata resolved the lock with no other changes.
- crates/engine/src/lib.rs: Session keeps pending_preflights and
next_pending_preflight beside scratch_runtime and scratch_epoch.
Session derives Default, so neither set needs a manual initializer.
Upstream: 655295b2a2
Main now takes the manifest envelope from craft-pcraft-format, which has
no full_composite member. The save writes the member after the envelope
when it writes composite/full.png, and read_full_composite reads it back.