photocraft/crates/codecs
Brandon Thomas d3b540234c
Large images: adapter texture limits, banded compositing/export, proxy thumbnails, history byte budget (#71)
* Compose: render in place and in bands; area-reduced and proxy thumbnails

render_tiled wrote every tile into a Vec and then copied them into the output:
two full-size float copies (6.3 GB for a 14000² document). Tiles are now
written straight into the output band by band.

New streaming APIs so large documents never need a full-size float
composite: render_bands (sink per band of ~8 MP), render_reduced (banded
area average, bit-identical to the old flatten-then-average thumbnail) and
flatten_to_surface (Flatten Image, Merge Visible, Copy Merged and the other
flattened-surface users).

thumbnail() reduces a downsampled proxy (moved from ui-egui to
compose::proxy, now also scaling spot channels) for documents over 16 MP
when that is faithful (no layer effects or vector masks), so navigator,
channel and histogram thumbnails of a 196 MP document cost milliseconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* GPU canvas: request the adapter's real limits; banded CPU fallback; navigator thumbnail

egui-wgpu asks for an 8192 px texture limit, so the wgpu compositor rejected
every document wider or taller than 8192 px and each refresh fell back to a
full CPU flatten. The desktop and web apps now request the adapter's own
max_texture_dimension_2d and max_buffer_size (gpu_canvas::use_adapter_limits);
the benches use the same limits.

The CPU fallback (documents beyond the GPU limit, or features it lacks) is
GpuCanvas::refresh: it renders and uploads the composite band by band
instead of a full float composite plus a full RGBA8 copy. The CPU canvas
texture is reduced in bands too, and the Navigator on the GPU canvas uses a
cached thumbnail instead of a full-resolution CPU composite per revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* IO: banded export and import; no full-size copies in encoders; larger decode limits

- Flat export renders the composite in bands and quantises each band; alpha
  is dropped in place when every pixel is opaque. The single-layer native
  path copies surface rows a band at a time (no f32 read_region and no
  per-pixel Vec). Buffers come from try_reserve: Err, not an abort.
- PSD export encodes the merged image band by band into its planes (no
  flatten, no second plane copy); near-opaque documents are re-encoded
  without the white matte so the output is unchanged. Layer interleaved
  bytes are freed before their planes are compressed.
- Flat import converts in ~32 MB bands (and not at all when already RGBA);
  the PSD merged-image un-matte works per tile row.
- Encoders borrow the image when no conversion is needed (Image::converted)
  instead of cloning it; PNG only copies 16-bit data for the byte swap.
- Decode limits: 2^30 pixels and 8 GiB on 64-bit targets (a 20000² JPEG
  was refused at 2^28).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Filters: write tile results in memory-bounded groups

apply_tiled collected every tile's float result before writing any (3.1 GB
for a 14000² layer). Results are now produced and written in groups of about
256 MB (at least one tile per core).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* History: bound pixel memory by Preferences > Performance > Memory Usage

History::max_bytes caps the current document's tiles plus the tiles only
history holds (layers, masks, alpha channels); History::trim drops the
oldest states beyond it after every recorded step and when preferences
change, always keeping the last step undoable. The engine sets it from
memory_usage_mb, which was unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Large-image benchmark (14k / 20k): open, refresh, thumbnails, filter, stroke, saves

One operation per run on a cached synthetic photo, with the GPU device created
the way the app creates it, so /usr/bin/time -l reports that operation's peak
memory. Documented in docs/development.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:45:17 -04:00
..
fuzz Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms, attribution 2026-10-04 10:06:17 -04:00
src Large images: adapter texture limits, banded compositing/export, proxy thumbnails, history byte budget (#71) 2026-10-04 16:45:17 -04:00
tests Never crash: psd, codecs, format, io are panic-free (Rule 9) (#34) 2026-10-04 13:25:17 -04:00
Cargo.toml Never crash: psd, codecs, format, io are panic-free (Rule 9) (#34) 2026-10-04 13:25:17 -04:00
README.md Large images: adapter texture limits, banded compositing/export, proxy thumbnails, history byte budget (#71) 2026-10-04 16:45:17 -04:00

README.md

photocraft-codecs

Codecs for flat raster image formats, written in pure Rust. The crate is standalone: it depends on no other workspace crate and defines its own Image type. It also builds for wasm32-unknown-unknown and does no I/O: decoders take &[u8] and encoders return Vec<u8>.

It follows the "avoid GIMP's hole" rules in plan/architecture.md §1.1:

  • Symmetric. Every format we write, we can also read. The only exceptions are listed in ASYMMETRIC_EXCEPTIONS, each with a reason. The test suite checks this.
  • Depth-preserving. An image can hold U8, U16, F16 or F32 samples, and each format keeps what it can hold.
  • Metadata-preserving. ICC, EXIF, XMP, DPI and text are kept wherever the format supports them.
  • Honest. caps(format) says what each format holds, and fidelity_warnings(&image, format) lists what an export will lose before you write it. Both functions use the same encode plan as encode, so a warning appears exactly when the file changes.
use photocraft_codecs::*;

let img = decode(&bytes)?;                               // detect + decode (default Limits)
for w in fidelity_warnings(&img, Format::Jpeg) {         // e.g. "16-bit will be reduced to 8-bit"
    println!("{w}");
}
let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;

Capability matrix (default build)

Format Read Write Depths (native) Layouts (native) Alpha ICC EXIF XMP DPI Text Lossy write Backend
PNG yes yes U8, U16 Gray, GrayA, RGB, RGBA yes yes (iCCP) yes (eXIf) yes (iTXt XML:com.adobe.xmp) yes (pHYs) yes (tEXt/zTXt/iTXt) no png
JPEG yes yes U8 Gray, RGB, CMYK no yes (multi-segment APP2) yes (APP1) yes (APP1) yes (JFIF) no yes zune-jpeg / jpeg-encoder
TIFF yes yes U8, U16, F32 all six (CMYK, CMYK+A included) yes yes (tag 34675) no yes (tag 700) yes yes (Description, Make, Model, Software, DateTime, Artist, Copyright) no tiff
WebP yes yes (lossless only) U8 RGB, RGBA yes yes yes yes no no no image-webp
GIF yes yes U8 RGBA 1-bit no no no no no yes (256-colour palette) image
BMP yes yes U8 RGB, RGBA yes no no no no no no image
TGA yes yes U8 Gray, GrayA, RGB, RGBA yes no no no no no no image
ICO yes yes U8 RGBA (at most 256×256) yes no no no no no no image
Netpbm (PBM/PGM/PPM/PAM/PFM) yes yes U8, U16, F32 (float: PFM gray/RGB only) all six (PAM CMYK/CMYK_ALPHA) yes (PAM) no no no no no no built in
QOI yes yes U8 RGB, RGBA yes no no no no no no image
OpenEXR yes yes F16, F32 Y, YA, RGB, RGBA yes no no no no no no exr
Radiance HDR yes yes F32 RGB no no no no no no yes (RGBE) image
AVIF no only with feature avif U8 RGB, RGBA yes no no no no no yes image + ravif

"Native" means the data is stored and read back without conversion. Anything else is converted by the encode plan, and fidelity_warnings reports the conversion when it loses information:

  • A float image written to an integer format goes to U16 where the format supports it. Values outside 0..1 raise RangeClipped.
  • Integer data written to a float-only format (EXR, HDR) becomes F32 without loss.
  • CMYK is converted to RGB with a naive formula that is not colour-managed. CmykConverted is raised and the ICC profile is dropped, because a CMYK profile does not describe RGB data.
  • Gray written to an RGB-only format is expanded to RGB without loss, so no warning is raised.

Image::convert applies the same ICC rule: it keeps the profile only when the colour model stays the same.

Documented asymmetries and limitations

  • Camera raw files (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with CodecError::Unsupported: they are sensor data, not flat images. photocraft-raw decodes and develops them, and photocraft-io routes them there.

  • AVIF (the only asymmetric format). Encoding uses ravif, which is pure Rust. Decoding needs dav1d, which is C. AVIF is therefore read-unsupported, and write support is gated behind the non-default avif feature. In a default build it is neither readable nor writable, so the symmetric guarantee holds. It is listed in ASYMMETRIC_EXCEPTIONS.

  • Lossy WebP. There is no pure-Rust lossy WebP encoder. We always write lossless WebP, and webp_lossless: false returns CodecError::Unsupported. We can read both lossy and lossless files.

  • Animation and multi-page files (APNG, animated GIF/WebP, multi-page TIFF): only the first frame or page is decoded, and a single frame is written. FormatCaps::animation marks containers that can hold more frames.

  • EXIF in TIFF is stored as a sub-IFD rather than a blob, so it is not preserved yet. caps.exif = false for TIFF, and a warning is raised.

  • EXIF orientation is preserved as data but not applied to the pixels.

  • JPEG

    • 8-bit only. Neither decoder backend supports 12-bit.
    • CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an APP14 marker.
    • Text is not written because there is no COM-segment support.
    • EXIF and XMP must each fit in one 64 KiB APP1 segment, otherwise the encoder returns an error. Extended XMP is not written.
  • TIFF decode

    • Supports 1, 2 and 4-bit gray (best effort, not yet covered by tests), 8/16/32-bit integers (32-bit is reduced to U16), F16, F32 and F64 (converted to F32), and WhiteIsZero.
    • Palette and YCbCr TIFFs, and compressions that the tiff crate lacks (CCITT, JPEG-in-TIFF), return Unsupported.
  • EXR

    • Reads the first valid layer at full resolution, from its data window.
    • Channel names are matched by suffix, so layer.R counts as R.
    • Subsampled channels and deep data are unsupported.
    • Only lossless compressions are offered for writing: None, RLE, ZIP1, ZIP16 and PIZ.
  • Netpbm writing picks the subtype from the image: float gives PFM, gray gives P5, RGB gives P6, and anything with alpha or CMYK gives P7 (PAM). Writing a .pbm file therefore produces whichever of these fits the data.

  • Colour conversions (CMYK↔RGB, RGB→gray) are naive. Colour-managed conversion belongs in photocraft-color.

Limits (decompression bombs)

DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc } } guards decoding. Header dimensions are checked before the pixel buffer is allocated, and the budget is also passed to the underlying decoders. A violation returns CodecError::LimitExceeded. The defaults are 262144 px per side, 2^30 pixels and 8 GiB (2 GiB on 32-bit targets such as wasm).

Tests

cargo test -p photocraft-codecs covers:

  • the full round-trip matrix (every writable format × 4 sample types × 6 layouts), exact for lossless formats and above a PSNR threshold for lossy ones;
  • metadata preservation per format, checked against caps;
  • magic-number detection, including negatives;
  • fidelity warnings, and a cross-check that the warnings match the actual round-trip result;
  • the symmetric-capability guarantee;
  • malformed input: truncation, bit flips and proptest random bytes;
  • Adam7 PNG against progressive PNG and against the image crate as an oracle;
  • limit enforcement.

If corpus/pngsuite/*.png exists at the repo root, every file in it is compared against the image crate's decoder. Without it, that test is skipped.