* 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> |
||
|---|---|---|
| .. | ||
| fuzz | ||
| src | ||
| tests | ||
| Cargo.toml | ||
| README.md | ||
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, andfidelity_warnings(&image, format)lists what an export will lose before you write it. Both functions use the same encode plan asencode, 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.
CmykConvertedis 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-rawdecodes and develops them, andphotocraft-ioroutes them there. -
AVIF (the only asymmetric format). Encoding uses
ravif, which is pure Rust. Decoding needsdav1d, which is C. AVIF is therefore read-unsupported, and write support is gated behind the non-defaultaviffeature. In a default build it is neither readable nor writable, so the symmetric guarantee holds. It is listed inASYMMETRIC_EXCEPTIONS. -
Lossy WebP. There is no pure-Rust lossy WebP encoder. We always write lossless WebP, and
webp_lossless: falsereturnsCodecError::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::animationmarks 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 = falsefor 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
tiffcrate lacks (CCITT, JPEG-in-TIFF), returnUnsupported.
-
EXR
- Reads the first valid layer at full resolution, from its data window.
- Channel names are matched by suffix, so
layer.Rcounts asR. - 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
.pbmfile 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
imagecrate 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.