mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 04:17:11 -06:00
* 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>
126 lines
7.6 KiB
Markdown
126 lines
7.6 KiB
Markdown
# 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.
|
||
|
||
```rust
|
||
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.
|