lightcraft/crates/codecs/README.md

116 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# lightcraft-codecs (L1)
Standard image formats for LightCraft: format sniffing, decoding to **linear light** with colour
management, fast thumbnails, and encoders with metadata embedding. Pure Rust, no C dependencies,
builds for `wasm32-unknown-unknown`.
## API at a glance
```rust
let fmt: Option<Format> = sniff(&bytes); // JPEG/PNG/TIFF/WebP/AVIF/HEIF/JXL/GIF/BMP/PSD/raw
let d: Decoded = decode(&bytes, DecodeOptions::default())?;
// d.image: Rgb32f, linear light in the source primaries (d.space: SourceSpace)
// d.alpha, d.bit_depth, d.float, d.orientation (EXIF value, NOT applied), d.exif/xmp/icc (raw blobs)
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…)
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) };
encode_jpeg(&EncodeImage::rgba8(&img), 90, ChromaSubsampling::S444, &meta)?;
encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
encode_webp_lossless(&img, &meta)?;
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
```
Thumbnail fallback decoding limits the source to 64 million pixels by default.
Non-JPEG decoders allocate at source resolution before resizing, even for a tiny
thumbnail. Use `decode_thumbnail_with` and `ThumbnailOptions::max_pixels` to set
a different source budget. Oversized sources return `Error::TooLarge`; embedded
JPEG previews can still be used without decoding the full source image.
`Format::RawTiffLike` (DNG, CR2, NEF, ARW, PEF, ORF, RW2, SRW, …) and `Format::RawOther` (CR3, RAF,
CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `decode` returns
`Error::Unsupported` for them.
## Formats
| Format | Decode | Encode | Notes |
|---|---|---|---|
| JPEG | yes (zune-jpeg; jpeg-decoder for DCT scaling, CMYK/YCCK, 12-bit, non-interleaved scans) | yes (jpeg-encoder, baseline, 4:4:4/4:2:2/4:2:0) | EXIF (APP1), XMP (APP1, ≤ 64 KiB), ICC (APP2, multi-chunk), Adobe APP14, MPF previews |
| PNG | yes, 1–16-bit, palette, tRNS, interlaced | 8/16-bit, gray/GA/RGB/RGBA | iCCP, eXIf, iTXt XMP; sRGB/cICP/cHRM/gAMA honoured when no ICC |
| TIFF | 8/16-bit int (32/64-bit int kept to 16 bits), 16/32/64-bit float, gray/RGB/CMYK/palette, alpha (assoc./unassoc.), planar, LZW/Deflate/PackBits/JPEG | 8/16-bit, 32-bit float; none/LZW/Deflate/PackBits | ICC (34675), XMP (700), Orientation; first IFD only; EXIF not written (use `lightcraft-tiff`) |
| WebP | lossy + lossless, alpha, first frame of animations | lossless only | ICCP/EXIF/XMP chunks |
| GIF / BMP | yes (first GIF frame) | — | via `image` |
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer |
| HEIC/HEIF | **no** (sniff only) | — | see gaps |
## Colour
Decoders never guess silently; `SourceSpace::origin` says where the interpretation came from:
`IccMatrixTrc` (profile applied exactly: TRC linearization + colorants → XYZ D50), `IccCms` (LUT-based
or CMYK profile converted by the CMS straight to linear Rec.2020), `Container` (PNG chunks, JXL enum,
EXIF `R03` Adobe RGB hint), `Untagged` (assumed sRGB; untagged float data assumed linear),
`IccUnsupported` (profile present but unusable → **sRGB fallback**), `Naive` (CMYK without profile).
`decode_jpeg_with_fallback(bytes, opts, space)` lets an enclosing RAW container supply the colour
space of a JPEG with no ICC or EXIF metadata. It applies the transfer curve before linear-light
resizing; a JPEG carrying its own metadata retains its existing interpretation.
ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Display P3,
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
## Dependencies and licences
All pure Rust, verified with `cargo info` / `cargo tree`:
| Crate | Licence | Use |
|---|---|---|
| zune-jpeg, zune-core | MIT OR Apache-2.0 OR Zlib | JPEG decode |
| jpeg-decoder | MIT OR Apache-2.0 | scaled / CMYK JPEG decode |
| jpeg-encoder | (MIT OR Apache-2.0) AND IJG | JPEG encode (IJG: permissive, attribution in docs) |
| png | MIT OR Apache-2.0 | PNG |
| tiff | MIT | TIFF |
| image-webp | MIT OR Apache-2.0 | WebP |
| image (gif, bmp only) | MIT OR Apache-2.0 | GIF/BMP |
| moxcms | BSD-3-Clause OR Apache-2.0 | ICC parse/write, CMS |
| jxl-oxide | MIT OR Apache-2.0 | JPEG XL decode |
| ravif (+ rav1e, avif-serialize) | BSD-3-Clause / BSD-2-Clause | AVIF encode (native only; heavy to compile) |
Deliberately **not** used: anything AGPL from imazen (heic, zenjpeg, jxl-encoder, rav1d-safe),
jpegxl-rs / imagequant (GPL / C), dav1d / libheif (C).
## Performance (Apple M-series, release, synthetic 24 MP JPEG q90 4:2:0)
Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture`.
| Operation | Time |
|---|---|
| full decode → linear `Rgb32f` | ~92 ms (≈ 260 MP/s) |
| decode fit 2048 (DCT 1/2) | ~81 ms |
| thumbnail 256, scaled decode (DCT 1/8) | ~54 ms (entropy decoding bound) |
| thumbnail 256 from embedded EXIF/MPF preview | ~0.6 ms |
| `to_working` matrix | ~23 ms |
| PNG / TIFF-deflate encode (fast levels) | ~430 ms / ~900 ms |
## Gaps / limitations
- **AVIF and HEIC decode**: no permissively licensed, pure-Rust AV1/HEVC decoder exists today
(dav1d/libheif are C; rav1d-safe and imazen's heic are AGPL). Files are sniffed and rejected with
`Error::Unsupported`; a raw-style embedded-preview path could be added for HEIC thumbnails.
- JPEG: arithmetic coding unsupported (as in both decoders); extended XMP (> 64 KiB) neither read
nor written; EXIF > 64 KiB not written. The encoder uses standard Huffman tables (interleaved
baseline) for maximum compatibility.
- Thumbnails under 15 ms need an embedded preview ≥ the requested edge (EXIF IFD1 or MPF). Most
cameras store a 160 px EXIF thumbnail: use `decode_thumbnail_with` with `min_embedded_edge: 160`
for an instant placeholder, otherwise the DCT-scaled path takes ~50 ms for 24 MP.
- TIFF: first image only; EXIF is not re-emitted on encode; Lab/uncompressed-YCbCr unsupported.
- PSD: only the merged composite (no layers); ZIP-compressed composites unsupported.
- Panics inside third-party decoders are caught (`catch_unwind`) on unwinding targets; on wasm32
(`panic = abort`) we rely on the property tests of our own code plus the decoders' own hardening.