From fa59a060dc5d7fbeae24920c38b205bcb84c5545 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 12:26:05 -0400 Subject: [PATCH 1/2] M1.1: codecs fuzz/corpus/bench tests, thumbnail options, README Property tests (random bytes behind every magic, mutated/truncated files), corpus tests (corpus/images, pngsuite, raw routing, system ICC profiles), ignored 24 MP JPEG benchmark, decode_thumbnail_with(min_embedded_edge), fast deflate levels for PNG/TIFF, README with formats, licences, gaps. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/README.md | 104 ++++++++++++++++++++++ crates/codecs/src/encode.rs | 4 +- crates/codecs/src/jpeg.rs | 10 ++- crates/codecs/src/lib.rs | 31 ++++++- crates/codecs/tests/bench.rs | 88 +++++++++++++++++++ crates/codecs/tests/corpus.rs | 146 +++++++++++++++++++++++++++++++ crates/codecs/tests/fuzz.rs | 81 +++++++++++++++++ crates/codecs/tests/roundtrip.rs | 27 ++++++ 8 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 crates/codecs/README.md create mode 100644 crates/codecs/tests/bench.rs create mode 100644 crates/codecs/tests/corpus.rs create mode 100644 crates/codecs/tests/fuzz.rs diff --git a/crates/codecs/README.md b/crates/codecs/README.md new file mode 100644 index 000000000..4b815a6cd --- /dev/null +++ b/crates/codecs/README.md @@ -0,0 +1,104 @@ +# 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 = 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 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` +``` + +`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). + +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. diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 03127b324..89dadeed3 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -147,7 +147,7 @@ pub fn encode_png(img: &EncodeImage, meta: &EncodeMeta) -> Result> { let e = |e: png::EncodingError| Error::Encode(e.to_string()); { let mut enc = png::Encoder::with_info(&mut out, info).map_err(e)?; - enc.set_compression(png::Compression::Balanced); + enc.set_compression(png::Compression::Fast); if let Some(xmp) = meta.xmp { enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?; } @@ -179,7 +179,7 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod let comp = match compression { TiffCompression::None => Compression::Uncompressed, TiffCompression::Lzw => Compression::Lzw, - TiffCompression::Deflate => Compression::Deflate(DeflateLevel::Balanced), + TiffCompression::Deflate => Compression::Deflate(DeflateLevel::Fast), TiffCompression::PackBits => Compression::Packbits, }; let float = matches!(img.samples, Samples::F32(_)); diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index 1e2595e7f..988065eb3 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -179,7 +179,7 @@ fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) { /// Fast path: an embedded JPEG preview (EXIF IFD1 thumbnail, or the largest MPF preview) whose long /// edge is ≥ `max_edge`, decoded with DCT scaling and fitted. -pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32) -> Option { +pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> Option { let m = parse_markers(bytes)?; let summary = m.exif.as_deref().map(exif::summarize).unwrap_or_default(); let orientation = summary.orientation.unwrap_or(1); @@ -202,7 +202,13 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32) -> Option 0 && pm.height > 0 && ((pm.width as f64 / pm.height as f64) / (m.width as f64 / m.height as f64) - 1.0).abs() < 0.02; - if long >= max_edge && aspect_ok && best.as_ref().is_none_or(|b| long < b.0) { + // Prefer the smallest preview covering `max_edge`; otherwise the largest one ≥ `min_edge`. + let better = match &best { + None => true, + Some(b) if b.0 >= max_edge => long >= max_edge && long < b.0, + Some(b) => long > b.0, + }; + if long >= min_edge && aspect_ok && better { best = Some((long, data, src)); } } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index d75701994..5dde3f5ce 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -206,12 +206,35 @@ pub struct Thumbnail { pub source_height: u32, } -/// Fast thumbnail: an embedded JPEG preview that is at least `max_edge` on its long side if one exists, -/// else a scaled decode. The result fits within `max_edge × max_edge`. +/// Thumbnail request. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThumbnailOptions { + /// Fit the result within `max_edge × max_edge`. + pub max_edge: u32, + /// Accept an embedded preview whose long edge is at least this (it is then returned at its own, + /// smaller size). Defaults to `max_edge`, i.e. only previews that need no upscaling. Lower it + /// (e.g. 160 for typical EXIF thumbnails) for an instant placeholder. + pub min_embedded_edge: u32, +} + +impl ThumbnailOptions { + pub fn new(max_edge: u32) -> Self { + ThumbnailOptions { max_edge, min_embedded_edge: max_edge } + } +} + +/// Fast thumbnail: an embedded JPEG preview (EXIF IFD1 thumbnail or MPF preview) at least `max_edge` +/// on its long side if one exists, else a scaled decode. The result fits within `max_edge × max_edge`. pub fn decode_thumbnail(bytes: &[u8], max_edge: u32) -> Result { - let max_edge = max_edge.max(1); + decode_thumbnail_with(bytes, &ThumbnailOptions::new(max_edge)) +} + +/// [`decode_thumbnail`] with options. +pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result { + let max_edge = opts.max_edge.max(1); + let min_edge = opts.min_embedded_edge.clamp(1, max_edge); if sniff(bytes) == Some(Format::Jpeg) - && let Some(t) = jpeg::embedded_thumbnail(bytes, max_edge) + && let Ok(Some(t)) = std::panic::catch_unwind(|| jpeg::embedded_thumbnail(bytes, max_edge, min_edge)) { return Ok(t); } diff --git a/crates/codecs/tests/bench.rs b/crates/codecs/tests/bench.rs new file mode 100644 index 000000000..8bae69012 --- /dev/null +++ b/crates/codecs/tests/bench.rs @@ -0,0 +1,88 @@ +//! Benchmark-ish, ignored by default. Run with: +//! `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture` + +use lightcraft_codecs::*; +use std::time::Instant; + +fn synthetic(w: usize, h: usize) -> Vec { + // Smooth gradients + deterministic noise, roughly photo-like entropy. + let mut s: u32 = 0x1234_5678; + let mut v = Vec::with_capacity(w * h * 3); + for y in 0..h { + for x in 0..w { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + let n = (s & 15) as i32 - 8; + let r = (x * 255 / w) as i32 + n; + let g = (y * 255 / h) as i32 + n; + let b = (((x / 37) ^ (y / 29)) & 0xFF) as i32 / 2 + 64 + n; + v.extend_from_slice(&[r.clamp(0, 255) as u8, g.clamp(0, 255) as u8, b.clamp(0, 255) as u8]); + } + } + v +} + +fn time(label: &str, mp: f64, reps: usize, mut f: impl FnMut() -> T) -> T { + let mut out = f(); + let t = Instant::now(); + for _ in 0..reps { + out = f(); + } + let dt = t.elapsed().as_secs_f64() / reps as f64; + if mp > 0.0 { + println!("{label:<48} {:>8.2} ms {:>7.1} MP/s", dt * 1e3, mp / dt); + } else { + println!("{label:<48} {:>8.2} ms", dt * 1e3); + } + out +} + +#[test] +#[ignore] +fn bench_24mp_jpeg() { + let (w, h) = (6000usize, 4000usize); + let mp = (w * h) as f64 / 1e6; + let px = synthetic(w, h); + let img = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px)); + let plain = + time("encode JPEG q90 4:2:0 (jpeg-encoder)", mp, 1, || encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap()); + println!(" size {:.1} MB", plain.len() as f64 / 1e6); + + time("decode full → linear Rgb32f", mp, 3, || decode(&plain, DecodeOptions::default()).unwrap()); + time("decode fit 2048 (DCT 1/2 + resample)", mp, 3, || decode(&plain, DecodeOptions::fit(2048, 2048)).unwrap()); + time("decode_thumbnail 256 (DCT 1/8, no preview)", mp, 5, || decode_thumbnail(&plain, 256).unwrap()); + + // Camera-style file: 320×213 EXIF thumbnail covers a 256 px request. + let small: Vec = synthetic(320, 213); + let thumb = encode_jpeg(&EncodeImage::new(320, 213, 3, Samples::U8(&small)), 80, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let exif = exif_with_thumbnail(&thumb); + let cam = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap(); + let t = time("decode_thumbnail 256 (EXIF preview)", mp, 20, || decode_thumbnail(&cam, 256).unwrap()); + assert_eq!(t.source, ThumbnailSource::ExifThumbnail); + + let d = decode(&plain, DecodeOptions::default()).unwrap(); + time("to_working (sRGB → Rec.2020 matrix)", mp, 3, || to_working(&d)); + let rgba = d.to_srgb8(); + time("encode PNG 8-bit", mp, 1, || encode_png(&EncodeImage::rgba8(&rgba), &EncodeMeta::default()).unwrap()); + time("encode TIFF 8-bit deflate", mp, 1, || encode_tiff(&EncodeImage::rgba8(&rgba), TiffCompression::Deflate, &EncodeMeta::default()).unwrap()); +} + +fn exif_with_thumbnail(jpeg: &[u8]) -> Vec { + let mut v = b"II*\0".to_vec(); + v.extend_from_slice(&8u32.to_le_bytes()); + v.extend_from_slice(&0u16.to_le_bytes()); + v.extend_from_slice(&14u32.to_le_bytes()); + // IFD1 at 14: 2 entries, data at 44 + v.extend_from_slice(&2u16.to_le_bytes()); + for (tag, val) in [(0x0201u16, 44u32), (0x0202, jpeg.len() as u32)] { + v.extend_from_slice(&tag.to_le_bytes()); + v.extend_from_slice(&4u16.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + v.extend_from_slice(&val.to_le_bytes()); + } + v.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(v.len(), 44); + v.extend_from_slice(jpeg); + v +} diff --git a/crates/codecs/tests/corpus.rs b/crates/codecs/tests/corpus.rs new file mode 100644 index 000000000..d3b2ceba3 --- /dev/null +++ b/crates/codecs/tests/corpus.rs @@ -0,0 +1,146 @@ +//! Corpus tests over `corpus/images/**`, `corpus/pngsuite/**` and `corpus/raw/**` (git-ignored; fetched +//! with `cargo xtask corpus --download`). Each test skips cleanly when its directory is absent. +//! `LIGHTCRAFT_CORPUS` overrides the corpus root. + +use lightcraft_codecs::*; +use std::path::{Path, PathBuf}; + +fn corpus_root() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +fn files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { continue }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else { + out.push(p); + } + } + } + out.sort(); + out +} + +#[test] +fn corpus_images_decode() { + let dir = corpus_root().join("images"); + if !dir.is_dir() { + eprintln!("skip: {} absent", dir.display()); + return; + } + let (mut ok, mut unsupported) = (0, 0); + for p in files(&dir) { + let Ok(bytes) = std::fs::read(&p) else { continue }; + let Some(f) = sniff(&bytes) else { continue }; + if !f.can_decode() { + unsupported += 1; + assert!(decode(&bytes, DecodeOptions::default()).is_err()); + continue; + } + let d = decode(&bytes, DecodeOptions::default()).unwrap_or_else(|e| panic!("{}: {e}", p.display())); + assert!(d.width > 0 && d.height > 0); + assert!(d.image.data.iter().all(|px| px.iter().all(|v| v.is_finite())), "{}: non-finite", p.display()); + let t = decode_thumbnail(&bytes, 256).unwrap_or_else(|e| panic!("{} thumb: {e}", p.display())); + assert!(t.image.width <= 256 && t.image.height <= 256); + ok += 1; + } + eprintln!("corpus/images: {ok} decoded, {unsupported} unsupported formats"); +} + +#[test] +fn corpus_pngsuite() { + let dir = corpus_root().join("pngsuite"); + if !dir.is_dir() { + eprintln!("skip: {} absent", dir.display()); + return; + } + let mut n = 0; + for p in files(&dir) { + if p.extension().and_then(|e| e.to_str()) != Some("png") { + continue; + } + let bytes = std::fs::read(&p).unwrap(); + let name = p.file_name().unwrap().to_string_lossy().to_string(); + let r = decode(&bytes, DecodeOptions::default()); + if name.starts_with('x') { + // Deliberately corrupt files: must fail gracefully (never panic). + continue; + } + let d = r.unwrap_or_else(|e| panic!("{name}: {e}")); + assert!(d.width > 0); + n += 1; + } + eprintln!("pngsuite: {n} valid files decoded"); +} + +#[test] +fn corpus_raw_is_routed() { + let dir = corpus_root().join("raw"); + if !dir.is_dir() { + eprintln!("skip: {} absent", dir.display()); + return; + } + for p in files(&dir) { + let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + if matches!(ext.as_str(), "txt" | "md" | "xmp" | "json") { + continue; + } + let Ok(bytes) = std::fs::read(&p) else { continue }; + let f = sniff(&bytes); + assert!(f.is_some_and(|f| f.is_raw()), "{}: sniffed {f:?}", p.display()); + } +} + +/// Parse the OS-provided ICC profiles when present (macOS ColorSync; read-only, nothing copied). +#[test] +fn system_icc_profiles() { + use lightcraft_codecs::icc::{IccColorModel, IccKind, parse}; + let dir = Path::new("/System/Library/ColorSync/Profiles"); + if !dir.is_dir() { + eprintln!("skip: no system ICC profiles"); + return; + } + let expect = [ + ("sRGB Profile.icc", Some(NamedSpace::Srgb)), + ("Display P3.icc", Some(NamedSpace::DisplayP3)), + ("AdobeRGB1998.icc", Some(NamedSpace::AdobeRgb)), + ("ROMM RGB.icc", Some(NamedSpace::ProPhoto)), + ("ITU-2020.icc", Some(NamedSpace::Rec2020)), + ]; + for (name, named) in expect { + let Ok(b) = std::fs::read(dir.join(name)) else { continue }; + let info = parse(&b).unwrap_or_else(|| panic!("{name}")); + assert_eq!(info.named, named, "{name}"); + assert!(matches!(info.kind, IccKind::MatrixTrc { .. }), "{name}"); + } + if let Ok(b) = std::fs::read(dir.join("Generic CMYK Profile.icc")) { + let info = parse(&b).unwrap(); + assert_eq!(info.model, IccColorModel::Cmyk); + assert_eq!(info.kind, IccKind::NeedsCms); + // CMYK TIFF tagged with it: paper white stays near white, 100% K near black. + use tiff::encoder::{TiffEncoder, colortype}; + let mut cur = std::io::Cursor::new(Vec::new()); + { + let mut enc = TiffEncoder::new(&mut cur).unwrap(); + let mut im = enc.new_image::(2, 1).unwrap(); + im.encoder().write_tag(tiff::tags::Tag::IccProfile, &b[..]).unwrap(); + im.write_data(&[0u8, 0, 0, 0, 0, 0, 0, 255]).unwrap(); + } + let d = decode(&cur.into_inner(), DecodeOptions::default()).unwrap(); + assert_eq!(d.space.origin, SpaceOrigin::IccCms); + let w = d.image.get(0, 0); + let k = d.image.get(1, 0); + assert!(w.iter().all(|&v| v > 0.8), "{w:?}"); + assert!(k.iter().all(|&v| v < 0.1), "{k:?}"); + } + for p in files(dir) { + let b = std::fs::read(&p).unwrap(); + let _ = parse(&b); + } +} diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/fuzz.rs new file mode 100644 index 000000000..fff23e18d --- /dev/null +++ b/crates/codecs/tests/fuzz.rs @@ -0,0 +1,81 @@ +//! Decoders must never panic on malformed input: random bytes behind every magic number, and +//! mutated/truncated valid files. Uses `decode_unguarded` so panics in our own code are not masked +//! by the public entry point's panic guard. + +use lightcraft_codecs::exif::minimal_exif; +use lightcraft_codecs::icc::write_named; +use lightcraft_codecs::*; +use lightcraft_raster::Rgba8; +use proptest::prelude::*; +use std::sync::OnceLock; + +const MAGICS: &[&[u8]] = &[ + b"\xFF\xD8\xFF", + b"\x89PNG\r\n\x1a\n", + b"II*\0", + b"MM\0*", + b"RIFF\0\0\0\0WEBP", + b"GIF89a", + b"BM\0\0\0\0\0\0\0\0\0\0\0\0\x28\0\0\0", + b"8BPS\0\x01", + b"\xFF\x0A", + b"\0\0\0\x0CJXL \x0D\x0A\x87\x0A", +]; + +fn small_opts() -> DecodeOptions { + DecodeOptions { max_size: None, max_pixels: 1 << 22 } +} + +fn try_all(bytes: &[u8]) { + if let Some(f) = sniff(bytes) { + let _ = decode_unguarded(bytes, f, &small_opts()); + let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 }); + } + let _ = decode(bytes, small_opts()); + let _ = decode_thumbnail(bytes, 16); + let _ = lightcraft_codecs::icc::parse(bytes); +} + +fn seeds() -> &'static Vec> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(|| { + let img = Rgba8::from_fn(24, 16, |x, y| [(x * 10) as u8, (y * 15) as u8, ((x ^ y) * 8) as u8, (x * y) as u8]); + let icc = write_named(NamedSpace::DisplayP3); + let exif = minimal_exif(6); + let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some("") }; + let e = EncodeImage::rgba8(&img); + let u16s: Vec = (0..24 * 16 * 3).map(|i| (i * 331) as u16).collect(); + let f32s: Vec = (0..24 * 16 * 3).map(|i| i as f32 * 0.01).collect(); + vec![ + encode_jpeg(&e, 80, ChromaSubsampling::S420, &meta).unwrap(), + encode_png(&e, &meta).unwrap(), + encode_png(&EncodeImage::new(24, 16, 3, Samples::U16(&u16s)), &meta).unwrap(), + encode_tiff(&e, TiffCompression::Lzw, &meta).unwrap(), + encode_tiff(&EncodeImage::new(24, 16, 3, Samples::F32(&f32s)), TiffCompression::Deflate, &meta).unwrap(), + encode_webp_lossless(&e, &meta).unwrap(), + ] + }) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })] + + #[test] + fn random_after_magic(m in 0usize..MAGICS.len(), tail in proptest::collection::vec(any::(), 0..600)) { + let mut b = MAGICS[m].to_vec(); + b.extend_from_slice(&tail); + try_all(&b); + } + + #[test] + fn mutated_valid_files(which in 0usize..6, flips in proptest::collection::vec((any::(), any::()), 1..12), cut in any::()) { + let mut b = seeds()[which].clone(); + for (i, v) in flips { + let i = i.index(b.len()); + b[i] = v; + } + try_all(&b); + let n = cut.index(b.len()); + try_all(&b[..n]); + } +} diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index bec61dcae..22d7ea952 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -450,3 +450,30 @@ fn raw_routing() { b.extend_from_slice(&0u32.to_le_bytes()); assert!(matches!(decode(&b, DecodeOptions::default()), Err(Error::Unsupported(Format::RawTiffLike, _)))); } + +#[test] +fn thumbnail_accepts_smaller_preview_when_asked() { + let main = Rgba8::filled(800, 600, [200, 30, 30, 255]); + let thumb = Rgba8::filled(160, 120, [30, 30, 200, 255]); + let thumb_jpeg = encode_jpeg(&EncodeImage::rgba8(&thumb), 80, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let exif = exif_with_thumbnail(&thumb_jpeg, 1); + let bytes = + encode_jpeg(&EncodeImage::rgba8(&main), 80, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap(); + let t = decode_thumbnail(&bytes, 256).unwrap(); + assert_eq!(t.source, ThumbnailSource::Scaled); + let t = decode_thumbnail_with(&bytes, &ThumbnailOptions { max_edge: 256, min_embedded_edge: 160 }).unwrap(); + assert_eq!(t.source, ThumbnailSource::ExifThumbnail); + assert_eq!((t.image.width, t.image.height), (160, 120)); +} + +#[test] +fn unusable_icc_falls_back_to_srgb_with_flag() { + let bogus = vec![0x42u8; 300]; + let data = [128u8, 64, 32]; + let bytes = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&data)), &EncodeMeta { icc: Some(&bogus), ..Default::default() }).unwrap(); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!(d.space.origin, SpaceOrigin::IccUnsupported); + assert_eq!(d.space.named, Some(NamedSpace::Srgb)); + assert_eq!(d.icc.as_deref(), Some(&bogus[..])); + assert!((d.image.get(0, 0)[0] - srgb_to_linear(128.0 / 255.0)).abs() < 1e-6); +} From 52e48099952f1030e4292d3e327d837f9426687b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 12:27:09 -0400 Subject: [PATCH 2/2] M1.1: codecs MPF preview thumbnail test Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/roundtrip.rs | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 22d7ea952..e4e338ce1 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -477,3 +477,60 @@ fn unusable_icc_falls_back_to_srgb_with_flag() { assert_eq!(d.icc.as_deref(), Some(&bogus[..])); assert!((d.image.get(0, 0)[0] - srgb_to_linear(128.0 / 255.0)).abs() < 1e-6); } + +/// Splice an MPF APP2 segment right after SOI and append `preview` after the primary image. +fn with_mpf_preview(primary: &[u8], preview: &[u8]) -> Vec { + // MPF segment payload: "MPF\0" + TIFF (LE) with one IFD holding 3 entries + 2 MP entries. + let mut t = b"II*\0".to_vec(); + t.extend_from_slice(&8u32.to_le_bytes()); + t.extend_from_slice(&3u16.to_le_bytes()); + let ifd_end = 8 + 2 + 3 * 12 + 4; // 50 + let entries_off = ifd_end as u32; + let push = |t: &mut Vec, tag: u16, typ: u16, count: u32, val: [u8; 4]| { + t.extend_from_slice(&tag.to_le_bytes()); + t.extend_from_slice(&typ.to_le_bytes()); + t.extend_from_slice(&count.to_le_bytes()); + t.extend_from_slice(&val); + }; + push(&mut t, 0xB000, 7, 4, *b"0100"); + push(&mut t, 0xB001, 4, 1, 2u32.to_le_bytes()); + push(&mut t, 0xB002, 7, 32, entries_off.to_le_bytes()); + t.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(t.len(), ifd_end); + let seg_len = 2 + 4 + t.len() + 32; + let mpf_header_pos = 2 + 4 + 4; // SOI, FFE2+len, "MPF\0" + let primary_len = primary.len() + 2 + seg_len; + let preview_off = (primary_len - mpf_header_pos) as u32; + let mut e = Vec::new(); + for (size, off) in [(primary_len as u32, 0u32), (preview.len() as u32, preview_off)] { + e.extend_from_slice(&0u32.to_le_bytes()); + e.extend_from_slice(&size.to_le_bytes()); + e.extend_from_slice(&off.to_le_bytes()); + e.extend_from_slice(&[0, 0, 0, 0]); + } + t.extend_from_slice(&e); + let mut out = primary[..2].to_vec(); + out.extend_from_slice(&[0xFF, 0xE2]); + out.extend_from_slice(&(seg_len as u16).to_be_bytes()); + out.extend_from_slice(b"MPF\0"); + out.extend_from_slice(&t); + out.extend_from_slice(&primary[2..]); + assert_eq!(out.len(), primary_len); + out.extend_from_slice(preview); + out +} + +#[test] +fn thumbnail_from_mpf_preview() { + let main = Rgba8::filled(1600, 1200, [200, 30, 30, 255]); + let prev = Rgba8::filled(640, 480, [30, 200, 30, 255]); + let enc = |i: &Rgba8| encode_jpeg(&EncodeImage::rgba8(i), 80, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let bytes = with_mpf_preview(&enc(&main), &enc(&prev)); + // Still a valid JPEG for the full decoder. + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (1600, 1200)); + let t = decode_thumbnail(&bytes, 256).unwrap(); + assert_eq!(t.source, ThumbnailSource::MpfPreview); + assert_eq!((t.image.width, t.image.height), (256, 192)); + assert!(t.image.get(5, 5)[1] > 150); +}