From 5d4b68f04cfefada1404fb709eb9ca2d3575f303 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 01:59:37 -0500 Subject: [PATCH] Use craft-heif from craft-libs for HEIC/HEIF (#13076) * Use craft-heif from craft-libs; remove crates/heif PhotoCraft's HEIF decoder had drifted from LightCraft's by about 700 lines; both now use craft-heif (storytold/craft-libs#784), built from LightCraft's copy. HEIC colours now follow the HEVC VUI's range and matrix and libheif's chroma upsampling. 8-bit files still open as 8-bit documents; a colour description the VUI specifies now tags the image as an nclx did. The codecs -> heif standalone exception ends with the local crate, and the HEIF nextest step drops -p photocraft-heif, which no longer exists. * Pin craft-heif to the merge of storytold/craft-libs#794 * heif: count the decoder's 16-bit buffer against max_alloc * heif: match Apple's chroma upsampling; pin craft-heif to storytold/craft-libs#799 * heif: free the decoder's buffer before the HDR conversion and count that conversion's peak * heif: hold the canvas the decoder really allocates to max_alloc --- .github/workflows/ci.yml | 2 +- Cargo.lock | 18 +- Cargo.toml | 4 +- apps/photocraft-cli/Cargo.toml | 2 +- apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft/Cargo.toml | 2 +- book/src/formats/raster-formats.md | 2 +- crates/codecs/Cargo.toml | 8 +- crates/codecs/README.md | 2 +- crates/codecs/src/codecs/heif.rs | 89 ++++- crates/codecs/src/format.rs | 2 +- crates/codecs/tests/it/heif.rs | 6 + crates/heif/Cargo.toml | 19 -- crates/heif/README.md | 34 -- crates/heif/src/lib.rs | 529 ----------------------------- crates/raw/src/jxl.rs | 2 +- docs/architecture.md | 5 +- docs/scorecard.md | 4 +- xtask/src/corpus_pins.rs | 2 +- xtask/src/layers.rs | 17 +- xtask/src/main.rs | 2 +- 21 files changed, 119 insertions(+), 634 deletions(-) delete mode 100644 crates/heif/Cargo.toml delete mode 100644 crates/heif/README.md delete mode 100644 crates/heif/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 800f464bae..522b76dc57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,7 +320,7 @@ jobs: tool: cargo-nextest@0.9.150 - name: Test without heif - run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci + run: cargo nextest run -p photocraft-codecs -p photocraft-io --locked --profile ci corpus: if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' diff --git a/Cargo.lock b/Cargo.lock index df19473077..f16fc58341 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1413,6 +1413,15 @@ name = "craft-fonts-build" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +[[package]] +name = "craft-heif" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=081078bb1b44aeedfa5bef3d4f13d978dc77510a#081078bb1b44aeedfa5bef3d4f13d978dc77510a" +dependencies = [ + "heic-rs", + "rayon", +] + [[package]] name = "craft-i18n" version = "0.1.0" @@ -5463,6 +5472,7 @@ dependencies = [ name = "photocraft-codecs" version = "0.20.1" dependencies = [ + "craft-heif", "craft-jpeg", "exr", "flate2", @@ -5471,7 +5481,6 @@ dependencies = [ "image-webp", "jpeg-encoder", "jxl-oxide", - "photocraft-heif", "png 0.18.1", "proptest", "rayon", @@ -5613,13 +5622,6 @@ dependencies = [ "wgpu", ] -[[package]] -name = "photocraft-heif" -version = "0.20.1" -dependencies = [ - "heic-rs", -] - [[package]] name = "photocraft-io" version = "0.20.1" diff --git a/Cargo.toml b/Cargo.toml index f895f5a9a6..3a4bfceeb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,8 @@ photocraft-ipp = { path = "crates/ipp" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" } +# HEIC/HEIF: the shared decoder (replaces crates/heif; craft-libs#784). +craft-heif = { git = "https://github.com/storytold/craft-libs", rev = "081078bb1b44aeedfa5bef3d4f13d978dc77510a" } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["raster"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" } craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" } @@ -139,7 +141,7 @@ opt-level = "s" opt-level = "s" [profile.wasm-release.package.photocraft-raw] opt-level = "s" -[profile.wasm-release.package.photocraft-heif] +[profile.wasm-release.package.craft-heif] opt-level = "s" [profile.wasm-release.package.photocraft-vector] opt-level = "s" diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 7640483f44..059e53eb39 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -8,7 +8,7 @@ build = "build.rs" [features] default = ["gpu"] -# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose +# Open HEIC/HEIF photos (the optional craft-heif crate). Not default so distributors choose # it; official builds (CI, packaging/, release) build with `--features heif`. heif = ["photocraft-codecs/heif"] # Optional native GPU rendering backend, enabled by default in native CLI builds. diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 1811aea283..7054d67bf9 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true publish = false [features] -# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose +# Open HEIC/HEIF photos (the optional craft-heif crate). Not default so distributors choose # it; official builds (CI, packaging/, release) build with `--features heif`. heif = ["photocraft-codecs/heif"] diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index b064f00376..46719a2c9c 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -8,7 +8,7 @@ description = "PhotoCraft: an open-source, native image editor" build = "build.rs" [features] -# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose +# Open HEIC/HEIF photos (the optional craft-heif crate). Not default so distributors choose # it; official builds (CI, packaging/, release) build with `--features heif`. heif = ["photocraft-codecs/heif"] diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 13ab740a86..8182c97cab 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -1,6 +1,6 @@ # Raster formats -`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate. Lossless JPEG XL (.jxl) export uses the pure-Rust `zune-jpegxl` encoder for 8/16-bit gray, RGB and RGBA data; gray+alpha is expanded to RGBA at the same bit depth. JPEG XL import is tracked separately in #2868. +`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `craft-heif` (craft-libs) crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate. Lossless JPEG XL (.jxl) export uses the pure-Rust `zune-jpegxl` encoder for 8/16-bit gray, RGB and RGBA data; gray+alpha is expanded to RGBA at the same bit depth. JPEG XL import is tracked separately in #2868. ## Layered TIFF diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 69e3bd78b5..9b79605b43 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -13,10 +13,10 @@ default = [] # AVIF *encode* via ravif (pure Rust). Decoding AVIF needs dav1d (C), so AVIF # stays read-unsupported and therefore asymmetric; it is off by default. avif = ["image/avif"] -# HEIC/HEIF decode via the optional photocraft-heif crate (heic-rs, pure Rust). Off by default so +# HEIC/HEIF decode via the optional craft-heif crate from craft-libs (heic-rs, pure Rust). Off by default so # distributors choose it at build time; official builds and CI enable it (`--features heif` on the # apps). Without it HEIF files are still recognised and opening one is a clear "unsupported" error. -heif = ["dep:photocraft-heif"] +heif = ["dep:craft-heif"] [dependencies] thiserror.workspace = true @@ -35,7 +35,7 @@ jpeg-encoder = "0.6" craft-jpeg.workspace = true # Pure-Rust modular JPEG XL lossless encoder. zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] } -photocraft-heif = { path = "../heif", optional = true } +craft-heif = { workspace = true, optional = true } # Orientation is applied a band of rows per task on native; wasm stays single-threaded. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -47,6 +47,8 @@ exr = { version = "1.73", default-features = false, features = ["rayon"] } proptest.workspace = true # Oracle decoders for cross-checking in tests only. image = { workspace = true, features = ["png", "jpeg", "tiff"] } +# Lossless synthetic HEIC files for the `heif` feature's tests. +craft-heif = { workspace = true, features = ["testdata"] } # Independent JPEG XL decoder oracle; not linked into the application. [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] diff --git a/crates/codecs/README.md b/crates/codecs/README.md index ffd7b655b1..fb56abab3f 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -162,7 +162,7 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes and develops them, and `photocraft-io` routes them there. -* **HEIF/HEIC (read-only, feature `heif`).** Decoding lives in the optional `photocraft-heif` crate +* **HEIF/HEIC (read-only, feature `heif`).** Decoding lives in the optional `craft-heif` crate from craft-libs behind the non-default `heif` feature (official PhotoCraft builds enable it; distributors choose). Without it, HEIF is still detected and decoding returns `CodecError::Unsupported` ("HEIC/HEIF support isn't included in this build"). iPhone and Mac photos decode with `heic-rs` (pure Rust, no `unsafe`): diff --git a/crates/codecs/src/codecs/heif.rs b/crates/codecs/src/codecs/heif.rs index efd9d2073a..f3e9dfc9d8 100644 --- a/crates/codecs/src/codecs/heif.rs +++ b/crates/codecs/src/codecs/heif.rs @@ -1,6 +1,6 @@ //! HEIF / HEIC (the iPhone and Mac photo format), read-only: see [`crate::ASYMMETRIC_EXCEPTIONS`]. //! -//! Decoding lives in the optional `photocraft-heif` crate (heic-rs, a pure-Rust HEVC +//! Decoding lives in the optional `craft-heif` crate from craft-libs (heic-rs, a pure-Rust HEVC //! still-picture decoder), enabled by this crate's `heif` feature. Without it, HEIF files are //! still detected and opening one is an [`CodecError::Unsupported`] error, never a panic. //! @@ -26,11 +26,11 @@ pub(crate) fn decode(_bytes: &[u8], _limits: &Limits, _keep_orientation: bool) - } #[cfg(feature = "heif")] -fn err(e: photocraft_heif::Error) -> CodecError { +fn err(e: craft_heif::Error) -> CodecError { match e { - photocraft_heif::Error::Unsupported(what) => CodecError::unsupported(F, what), - photocraft_heif::Error::Limit(what) => CodecError::LimitExceeded(what), - photocraft_heif::Error::Malformed(what) => CodecError::malformed(F, what), + craft_heif::Error::Unsupported(what) => CodecError::unsupported(F, what), + craft_heif::Error::Limit(what) => CodecError::LimitExceeded(what), + craft_heif::Error::Malformed(what) => CodecError::malformed(F, what), } } @@ -40,26 +40,45 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> R use crate::image::{ChannelLayout, Metadata, SampleType}; // The container alone: the declared size is checked before any pixel is decoded. - let info = photocraft_heif::probe(bytes).map_err(err)?; + let info = craft_heif::probe(bytes).map_err(err)?; let layout = if info.has_alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb }; - let sample = if info.sixteen_bit { SampleType::U16 } else { SampleType::U8 }; + let sample = if info.bit_depth > 8 { SampleType::U16 } else { SampleType::U8 }; let (w, h) = if keep_orientation { (info.coded_width, info.coded_height) } else { (info.width, info.height) }; - limits.check(w, h, layout, sample)?; + // craft-heif hands back 16-bit samples at any depth, and that buffer is still live while the + // output copy below is built, so the peak is both of them, not the output alone. + let peak = (layout.channels() * (2 + sample.bytes())) as u64; + limits.check_bytes(w, h, peak)?; // The delegated decoder allocates the full coded image before applying any clean-aperture // crop or orientation transform, so the coded dimensions must pass the limit policy even // when the displayed (transformed) size is small. Checking only the transformed size let a // tiny visible image hide a large coded allocation. if !keep_orientation && (info.coded_width, info.coded_height) != (w, h) { - limits.check(info.coded_width, info.coded_height, layout, sample)?; + limits.check_bytes(info.coded_width, info.coded_height, peak)?; } - let options = photocraft_heif::Options { max_pixels: limits.max_pixels, apply_transforms: !keep_orientation }; - let decoded = photocraft_heif::decode(bytes, &options).map_err(err)?; + // Photoshop on macOS opens HEIC through Apple's decoder, so match its chroma upsampling. + // A grid's payload can declare a larger canvas than the `ispe` probed above. craft-heif holds + // the canvas it really decodes to `max_pixels`, so `max_alloc` is passed down as pixels too. + let max_pixels = limits.max_pixels.min(limits.max_alloc / peak); + let options = craft_heif::Options { max_pixels, apply_transforms: !keep_orientation, chroma: craft_heif::Chroma::Apple }; + let mut decoded = craft_heif::decode(bytes, &options).map_err(err)?; // The decoder re-reads the container; trust its output's shape, not the probe's. let layout = if decoded.has_alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb }; - let sample = if decoded.sixteen_bit { SampleType::U16 } else { SampleType::U8 }; - let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, decoded.data)?; + // The decoder returns full-scale 16-bit samples at any coded depth; an 8-bit file stays 8-bit. + // Taken, so the decoder's buffer is freed here and not held through the HDR conversion below. + let raw = std::mem::take(&mut decoded.data); + let (sample, data) = if decoded.bit_depth > 8 { + (SampleType::U16, raw.iter().flat_map(|v| v.to_ne_bytes()).collect()) + } else { + (SampleType::U8, raw.iter().map(|&v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect()) + }; + drop(raw); + let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, data)?; img.icc = decoded.icc; - img.cicp = decoded.nclx.map(|n| Cicp { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix, full_range: n.full_range }); + // `colour` is always filled in (nclx, else the HEVC VUI, else H.273's "unspecified" 2s); only a + // specified one tags the image, so a file that says nothing still opens untagged. + let c = decoded.colour; + img.cicp = + (c.primaries != 2 || c.transfer != 2).then_some(Cicp { primaries: c.primaries, transfer: c.transfer, matrix: c.matrix, full_range: c.full_range }); img.meta = Metadata { exif: decoded.exif.map(|e| if keep_orientation { e } else { crate::orientation::upright_exif(&e).into_owned() }), xmp: decoded.xmp.map(|x| if keep_orientation { x } else { crate::orientation::upright_xmp(&x).into_owned() }), @@ -69,7 +88,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> R // ICC curve can describe those transfers, and an 8/16-bit document would clip the highlights. match img.cicp.filter(|c| c.is_hdr()) { Some(c) => { - limits.check(decoded.width, decoded.height, layout, SampleType::F32)?; + // `linearize` holds the 16-bit image, its float copy and the float output at once. + let hdr_peak = (layout.channels() * (SampleType::U16.bytes() + 2 * SampleType::F32.bytes())) as u64; + limits.check_bytes(decoded.width, decoded.height, hdr_peak)?; crate::cicp::linearize(img, c) } None => Ok(img), @@ -108,6 +129,44 @@ mod tests { 0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70, 0x00, 0xa8, 0x00, ]; + /// A 10-bit HLG file opens as float: the conversion holds the 16-bit image, its float copy and + /// the float output at once, ten bytes a sample. + #[cfg(feature = "heif")] + #[test] + fn an_hdr_heic_is_checked_against_its_conversion_peak() { + use craft_heif::testdata::{Prop, Spec, build}; + let grey = |_, _| [512u16, 512, 512]; + let mut spec = Spec::new(32, 32, &grey); + spec.bit_depth = 10; + spec.props = vec![Prop::Nclx(9, 18, 9, false)]; + let bytes = build(&spec); + let peak = 32 * 32 * 3 * 10; + let with = |max_alloc| crate::decode_with(&bytes, &crate::DecodeOptions { limits: Limits { max_alloc, ..Default::default() }, ..Default::default() }); + assert!(matches!(with(peak - 1), Err(CodecError::LimitExceeded(_)))); + assert_eq!(with(peak).unwrap().sample_type(), crate::image::SampleType::F32); + } + + /// A grid whose payload declares a larger canvas (64 x 64) than its `ispe` (33 x 33): the + /// byte budget applies to the canvas that is decoded, not the one the container declares. + #[cfg(feature = "heif")] + #[test] + fn a_grid_canvas_larger_than_its_declared_size_is_held_to_max_alloc() { + use craft_heif::testdata::{Spec, build}; + let grey = |_, _| [128u16, 128, 128]; + let mut bytes = build(&Spec::new(33, 33, &grey)); + // The grid item's payload: version, flags, rows - 1, columns - 1, output width and height. + let at = bytes.windows(8).position(|w| w == [0, 0, 1, 1, 0, 33, 0, 33]).unwrap(); + bytes[at + 5] = 64; + bytes[at + 7] = 64; + let with = |max_alloc| crate::decode_with(&bytes, &crate::DecodeOptions { limits: Limits { max_alloc, ..Default::default() }, ..Default::default() }); + // Enough for the declared 33 x 33 (three bytes a sample at the peak), not for 64 x 64. + // Refused by the decoder before it allocates the canvas, not by the size check on its output. + match with(33 * 33 * 3 * 3) { + Err(CodecError::LimitExceeded(m)) => assert!(m.contains("pixel limit"), "{m}"), + r => panic!("{:?}", r.map(|i| i.dimensions())), + } + } + #[cfg(feature = "heif")] #[test] fn a_heic_rs_panic_is_a_malformed_file_error() { diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 55079b07ce..20a520e31a 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -90,7 +90,7 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ (Format::Jxl, "Lossless JPEG XL export uses the pure-Rust zune-jpegxl encoder; JPEG XL reading is tracked separately and is not yet available."), ( Format::Heif, - "HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \ + "HEIC decode uses heic-rs (pure Rust, in the optional craft-heif crate behind the \ non-default `heif` feature, which official builds enable), so iPhone and Mac photos open; \ writing needs an HEVC encoder, and the mature ones (x265, libheif) are C, so write stays \ unsupported. Without the feature HEIF is detected but neither read nor written.", diff --git a/crates/codecs/tests/it/heif.rs b/crates/codecs/tests/it/heif.rs index bc1a185ae1..8b390622b4 100644 --- a/crates/codecs/tests/it/heif.rs +++ b/crates/codecs/tests/it/heif.rs @@ -151,6 +151,12 @@ fn limits_are_checked_before_decoding() { assert!(matches!(decode_with(checker_64(), &tight(few)), Err(CodecError::LimitExceeded(_)))); let bytes = Limits { max_alloc: 64 * 64 * 3 - 1, ..Default::default() }; assert!(matches!(decode_with(checker_64(), &tight(bytes)), Err(CodecError::LimitExceeded(_)))); + // An 8-bit file peaks at the decoder's 16-bit buffer plus the 8-bit output: 3 bytes a sample. + // The budget is held against the picture as coded, which this file pads to 10240 pixels. + let peak = 10240 * 3 * 3; + let short = Limits { max_alloc: peak - 1, ..Default::default() }; + assert!(matches!(decode_with(checker_64(), &tight(short)), Err(CodecError::LimitExceeded(_)))); + decode_with(checker_64(), &tight(Limits { max_alloc: peak, ..Default::default() })).unwrap(); } #[test] diff --git a/crates/heif/Cargo.toml b/crates/heif/Cargo.toml deleted file mode 100644 index 18210b0c46..0000000000 --- a/crates/heif/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "photocraft-heif" -description = "Optional HEIF/HEIC still-image decoder for PhotoCraft (a thin, panic-guarded wrapper around heic-rs)" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true - -[dependencies] -# Pure Rust, no unsafe. Pinned exactly: a young, single-owner crate that parses untrusted files; -# moving the pin is a reviewed change (re-run the fuzz target and the heif corpus tests). -heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] } - -# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] } - -[lints] -workspace = true diff --git a/crates/heif/README.md b/crates/heif/README.md deleted file mode 100644 index 9d859e8401..0000000000 --- a/crates/heif/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# photocraft-heif - -The optional HEIF/HEIC decoder of PhotoCraft: iPhone and Mac photos. A thin, panic-guarded wrapper -around [`heic-rs`](https://github.com/tbraun96/heic-rs), a pure-Rust HEVC still-picture decoder -(no `unsafe`, MIT OR Apache-2.0): single pictures and grid-tiled photos, 8- and 10-bit (10-bit -decodes to 16-bit), alpha auxiliary images, the container's rotation/mirror/crop, ICC, EXIF and XMP. -Read-only: writing would need an HEVC encoder. - -```rust -let info = photocraft_heif::probe(&bytes)?; // size, depth, alpha: check limits first -let img = photocraft_heif::decode(&bytes, &photocraft_heif::Options::default())?; -// img.width, img.height, img.has_alpha, img.sixteen_bit, img.data (RGB/RGBA), img.icc, img.exif, img.xmp -``` - -Errors are `Error::Unsupported` (image sequences, overlays, …), `Error::Limit` or -`Error::Malformed`. It never panics: every call into heic-rs runs under `catch_unwind`, and a panic -inside it (fuzzing found two in 0.1.1) becomes `Error::Malformed`. - -## Why it is a separate, optional crate - -- **A young decoder.** heic-rs is new and has a single maintainer. It is pinned exactly - (`=0.1.1`); moving the pin is a reviewed change (re-run `cargo fuzz run decode_heif` in - `crates/codecs` and `cargo xtask test-corpus`). -- **HEVC patents are a distributor's call.** HEVC is patent-encumbered in some jurisdictions, so - whether a build includes an HEVC decoder is a build-time choice. `photocraft-codecs` uses this - crate only behind its non-default `heif` feature; the apps forward it - (`cargo build -p photocraft --features heif`). Official PhotoCraft builds and CI enable it. - Without it, HEIC files are still recognised and opening one is a clear "HEIC/HEIF support isn't - included in this build" error. - -The crate depends on no other PhotoCraft crate and knows nothing of `photocraft-codecs`' types; -`codecs` → `heif` is the one allowed dependency between standalone crates (`cargo xtask layers`). -Tests on real files live in `photocraft-codecs` (`tests/it/heif.rs`, features `corpus,heif`, files -fetched by `cargo xtask corpus --heif`). diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs deleted file mode 100644 index 218ccde5eb..0000000000 --- a/crates/heif/src/lib.rs +++ /dev/null @@ -1,529 +0,0 @@ -//! `photocraft-heif`: the optional HEIF / HEIC (iPhone and Mac photo) decoder. -//! -//! A thin wrapper around `heic-rs`, a pure-Rust HEVC still-picture decoder: single pictures and -//! grid-tiled photos, 8- and 10-bit (10-bit decodes to 16-bit), alpha auxiliary images, ICC, -//! EXIF and XMP. Its API is plain data ([`Info`], [`Decoded`], [`Error`]) so the crate knows -//! nothing about the rest of PhotoCraft; `photocraft-codecs` adapts it behind its `heif` feature. -//! -//! A grid's ICC profile is inherited from its tiles when the grid item itself carries no `colr`, -//! which is how phone cameras write it: see [`icc_for_item`]. -//! -//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF. -//! [`Options::apply_transforms`] applies them; with it off, the pixels come back as coded. -//! -//! Never panics: heic-rs is young, so every call into it runs under `catch_unwind` and a panic -//! inside it becomes [`Error::Malformed`]. - -#![forbid(unsafe_code)] -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] - -use std::borrow::Cow; - -use heic_rs::PixelLayout; - -/// What a HEIF file declares, read from the container alone (no pixel is decoded). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Info { - /// Size after the container transforms (rotation, mirror, crop): what a viewer shows. - pub width: u32, - pub height: u32, - /// Size as coded, before the transforms. - pub coded_width: u32, - pub coded_height: u32, - /// The primary image has an alpha auxiliary image. - pub has_alpha: bool, - /// More than 8 bits per sample: it decodes to 16-bit. - pub sixteen_bit: bool, -} - -/// Decode settings. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Options { - /// Refuse images with more pixels than this (checked before decoding). - pub max_pixels: u64, - /// Apply the container's rotation, mirror and crop (`irot`/`imir`/`clap`). - pub apply_transforms: bool, -} - -impl Default for Options { - fn default() -> Self { - Options { max_pixels: 1 << 28, apply_transforms: true } - } -} - -/// A decoded image: interleaved RGB or RGBA samples, row-major, no padding. 16-bit samples are -/// native-endian `u16`s. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Decoded { - pub width: u32, - pub height: u32, - /// Four channels (RGBA) instead of three (RGB). - pub has_alpha: bool, - /// Two bytes per sample instead of one. - pub sixteen_bit: bool, - pub data: Vec, - /// The primary image's ICC profile. - pub icc: Option>, - /// The primary image's `nclx` colour description (inherited from its tiles like [`Self::icc`]). - /// The decoder has already applied its matrix and range; its primaries and transfer - /// characteristics describe the RGB samples returned. - pub nclx: Option, - /// EXIF as a TIFF structure (without HEIF's offset header). Its Orientation tag only mirrors - /// what the container says; it is returned verbatim. - pub exif: Option>, - /// The XMP packet. - pub xmp: Option, -} - -/// A `colr` box of type `nclx`: ITU-T H.273 code points (CICP), as written. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Nclx { - /// Colour primaries: 1 BT.709, 9 BT.2020, 12 P3-D65… - pub primaries: u16, - /// Transfer characteristics: 13 sRGB, 1/6/14/15 BT.709, 16 PQ, 18 HLG… - pub transfer: u16, - /// Matrix coefficients (already applied by the decoder). - pub matrix: u16, - /// Full-range samples (already applied by the decoder). - pub full_range: bool, -} - -/// Why a file could not be decoded. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Error { - /// A valid file using something this decoder does not handle (image sequences, overlays…). - Unsupported(String), - /// The image is larger than [`Options::max_pixels`] or holds an oversized box. - Limit(String), - /// Broken or truncated data (or a decoder bug, reported the same way). - Malformed(String), -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Error::Unsupported(m) => write!(f, "unsupported HEIF: {m}"), - Error::Limit(m) => write!(f, "limit exceeded: {m}"), - Error::Malformed(m) => write!(f, "malformed HEIF: {m}"), - } - } -} - -impl std::error::Error for Error {} - -const SEQUENCE: &str = "this file holds an image sequence; only HEIF still images can be opened"; - -fn err(e: heic_rs::Error) -> Error { - match e { - heic_rs::Error::Unsupported(what) => Error::Unsupported(what.to_string()), - // The primary item is neither an HEVC picture nor a grid of them. - heic_rs::Error::MissingBox("hvcC") => Error::Unsupported("the image is an overlay, an identity derivation or not HEVC-coded".into()), - heic_rs::Error::PixelLimit { .. } | heic_rs::Error::BoxTooLarge { .. } => Error::Limit(e.to_string()), - e => Error::Malformed(e.to_string()), - } -} - -/// Runs `f`, turning a heic-rs panic into an error. Fuzzing found two out-of-range slices in -/// heic-rs 0.1.1 on malformed files: in its box parser (`boxes.rs:130`) and during reconstruction -/// (`hevc/decode/recon.rs:70`). The codecs fuzz target also calls heic-rs directly, so its panics -/// stay visible there. -fn guarded(f: impl FnOnce() -> Result) -> Result { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Err(Error::Malformed("the HEIF decoder failed on this file".into()))) -} - -/// Reads the container's declared size, depth and alpha without decoding pixels, so callers can -/// check their limits first. -pub fn probe(bytes: &[u8]) -> Result { - guarded(|| probe_unguarded(bytes)) -} - -fn probe_unguarded(bytes: &[u8]) -> Result { - let info = heic_rs::probe(bytes).map_err(|e| match e { - // No `meta` box: no still image, only a `moov` image sequence (a video track). - heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE.into()), - e => err(e), - })?; - Ok(Info { - width: info.width, - height: info.height, - coded_width: info.coded_width, - coded_height: info.coded_height, - has_alpha: info.has_alpha, - sixteen_bit: info.bit_depth > 8, - }) -} - -/// Decodes the primary image, with its metadata. -pub fn decode(bytes: &[u8], options: &Options) -> Result { - guarded(|| decode_unguarded(bytes, options)) -} - -fn decode_unguarded(bytes: &[u8], options: &Options) -> Result { - let info = probe_unguarded(bytes)?; - let pixel_layout = match (info.has_alpha, info.sixteen_bit) { - (true, true) => PixelLayout::Rgba16, - (true, false) => PixelLayout::Rgba8, - (false, true) => PixelLayout::Rgb16, - (false, false) => PixelLayout::Rgb8, - }; - let raw = heic_rs::DecodeOptions::default() - .with_layout(pixel_layout) - .with_max_pixels(Some(options.max_pixels)) - .with_transforms(options.apply_transforms) - .with_alpha(info.has_alpha); - let decoded = heic_rs::decode(bytes, &raw).map_err(err)?; - let (icc, nclx, exif, xmp) = metadata(bytes); - Ok(Decoded { - width: decoded.width, - height: decoded.height, - has_alpha: info.has_alpha, - sixteen_bit: info.sixteen_bit, - data: decoded.data, - icc, - nclx, - exif, - xmp, - }) -} - -type Metadata = (Option>, Option, Option>, Option); - -/// The primary image's ICC profile, `nclx`, EXIF (TIFF structure) and XMP packet. Metadata is a -/// courtesy: a broken metadata item never fails a decode whose pixels came out fine. -fn metadata(bytes: &[u8]) -> Metadata { - let Ok(ctx) = heic_rs::context::Context::open(bytes) else { - return (None, None, None, None); - }; - let id = ctx.meta.primary; - let icc = icc_for_item(&ctx, id); - let nclx = nclx_for_item(&ctx, id); - let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); - let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { - Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), - Cow::Owned(b) => String::from_utf8(b).ok(), - }); - (icc, nclx, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) -} - -/// The ICC profile describing `item`, inherited from the coded tiles when the item itself carries -/// none. -/// -/// `colr` is optional on a `grid` derivation, and phone cameras write it on the tiles instead: a -/// OnePlus 11 photograph carries one `prof` (Display P3) on each of its 48 `hvc1` tiles and -/// nothing on the grid. Asking the primary item alone therefore loses the profile and the photo -/// opens as if it were sRGB. The tiles are what the mosaic is composed from, so their profile is -/// the composed image's profile. -/// -/// heic-rs resolves `nclx` from the primary item in the same way and has the same gap -/// (tbraun96/heic-rs#6). The sample range cannot be recovered here as well: `DecodeOptions` has no -/// override for it, so that half has to be fixed upstream. -fn icc_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option> { - inherited(ctx, item, |p| ctx.icc(p).map(<[u8]>::to_vec)) -} - -/// The `nclx` colour description of `item`, inherited from its tiles as [`icc_for_item`] does. -/// A 10-bit HDR HEIC declares its BT.2020 primaries and HLG or PQ transfer only here (#6752). -fn nclx_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option { - inherited(ctx, item, |p| { - p.nclx.map(|n| Nclx { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix_code, full_range: n.range == heic_rs::Range::Full }) - }) -} - -/// `get` of `item`'s properties, or of the first of its grid tiles that has it. -fn inherited(ctx: &heic_rs::context::Context<'_>, item: u32, get: impl Fn(&heic_rs::props::ItemProps<'_>) -> Option) -> Option { - let own = ctx.props(item).ok().and_then(|p| get(&p)); - if own.is_some() { - return own; - } - // `Ok(None)` is a coded picture rather than a derivation: no tiles to inherit from. - let (_, tiles) = ctx.grid(item).ok()??; - tiles.iter().find_map(|&t| ctx.props(t).ok().and_then(|p| get(&p))) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Found by the `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice out of - /// range (`boxes.rs:130`). It must be an error, not a crash. - const HEIC_RS_BOX_PANIC: [u8; 72] = [ - 0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50, 0x72, 0x6d, - 0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70, 0x00, 0xa8, 0x00, - ]; - - #[test] - fn a_heic_rs_panic_is_a_malformed_error() { - for apply_transforms in [true, false] { - let r = decode(&HEIC_RS_BOX_PANIC, &Options { apply_transforms, ..Default::default() }); - assert!(matches!(r, Err(Error::Malformed(_))), "{r:?}"); - } - } - - #[test] - fn image_sequence_is_unsupported_with_a_clear_message() { - // An ftyp and nothing else: a HEIF image sequence would have a moov here instead of a meta. - let bytes = b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc"; - for r in [probe(bytes).map(|_| ()), decode(bytes, &Options::default()).map(|_| ())] { - assert!(matches!(&r, Err(Error::Unsupported(m)) if m.contains("sequence")), "{r:?}"); - } - } - - #[test] - fn garbage_is_an_error() { - for bytes in [&b""[..], b"\0\0\0\x18ftypheic\0\0\0\0mif1heic", &[0xFF; 64]] { - assert!(probe(bytes).is_err()); - assert!(decode(bytes, &Options::default()).is_err()); - } - } - - #[test] - fn errors_display_their_kind() { - assert!(Error::Unsupported("x".into()).to_string().contains("unsupported")); - assert!(Error::Limit("x".into()).to_string().contains("limit")); - assert!(Error::Malformed("x".into()).to_string().contains("malformed")); - } - - // A grid whose colour description lives on its tiles, not on the grid item. - // - // The fixtures below are synthetic containers with no coded data at all: resolving a profile - // never decodes a pixel. Each test first asserts the fixture is the shape it claims to be, so - // a mistake in the builder cannot look like a passing fix. - - /// Stands in for a profile. `colr` hands its payload back without parsing it. - const TILE_ICC: &[u8] = b"tile-profile"; - const GRID_ICC: &[u8] = b"grid-profile"; - - const GRID_ID: u16 = 1; - const PICTURE_ID: u16 = 2; - - fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec { - let mut v = u32::try_from(payload.len() + 8).unwrap().to_be_bytes().to_vec(); - v.extend_from_slice(kind); - v.extend_from_slice(payload); - v - } - - /// A box whose first four payload bytes are a version and 24 bits of flags. - fn full(kind: &[u8; 4], version: u8, payload: &[u8]) -> Vec { - let mut p = vec![version, 0, 0, 0]; - p.extend_from_slice(payload); - bx(kind, &p) - } - - fn hdlr() -> Vec { - let mut p = vec![0; 4]; - p.extend_from_slice(b"pict"); - p.extend_from_slice(&[0; 12]); - p.push(0); - full(b"hdlr", 0, &p) - } - - fn infe(id: u16, item_type: &[u8; 4]) -> Vec { - let mut p = id.to_be_bytes().to_vec(); - p.extend_from_slice(&0u16.to_be_bytes()); - p.extend_from_slice(item_type); - p.push(0); - full(b"infe", 2, &p) - } - - fn iref_dimg(from: u16, to: u16) -> Vec { - let mut p = from.to_be_bytes().to_vec(); - p.extend_from_slice(&1u16.to_be_bytes()); - p.extend_from_slice(&to.to_be_bytes()); - full(b"iref", 0, &bx(b"dimg", &p)) - } - - fn colr_prof(icc: &[u8]) -> Vec { - let mut p = b"prof".to_vec(); - p.extend_from_slice(icc); - bx(b"colr", &p) - } - - /// A `colr` box of type `nclx`, laid out as ISO/IEC 23008-12 says: three big-endian `u16` - /// code points, then the full-range flag in the top bit of a byte. - fn colr_nclx(primaries: u16, transfer: u16, matrix: u16, full_range: bool) -> Vec { - let mut p = b"nclx".to_vec(); - for v in [primaries, transfer, matrix] { - p.extend_from_slice(&v.to_be_bytes()); - } - p.push(if full_range { 0x80 } else { 0 }); - bx(b"colr", &p) - } - - fn ispe(w: u32, h: u32) -> Vec { - let mut p = w.to_be_bytes().to_vec(); - p.extend_from_slice(&h.to_be_bytes()); - full(b"ispe", 0, &p) - } - - fn ipma(entries: &[(u16, &[u8])]) -> Vec { - let mut p = u32::try_from(entries.len()).unwrap().to_be_bytes().to_vec(); - for (item, assoc) in entries { - p.extend_from_slice(&item.to_be_bytes()); - p.push(u8::try_from(assoc.len()).unwrap()); - p.extend_from_slice(assoc); - } - full(b"ipma", 0, &p) - } - - /// `item`'s bytes live in `idat` (construction method 1) at offset 0. - fn iloc_in_idat(item: u16, length: u32) -> Vec { - let mut p = vec![0x44, 0x00]; - p.extend_from_slice(&1u16.to_be_bytes()); - p.extend_from_slice(&item.to_be_bytes()); - p.extend_from_slice(&1u16.to_be_bytes()); - p.extend_from_slice(&0u16.to_be_bytes()); - p.extend_from_slice(&1u16.to_be_bytes()); - p.extend_from_slice(&0u32.to_be_bytes()); - p.extend_from_slice(&length.to_be_bytes()); - full(b"iloc", 1, &p) - } - - fn file(meta: &[u8]) -> Vec { - let mut ftyp = b"heic".to_vec(); - ftyp.extend_from_slice(&0u32.to_be_bytes()); - ftyp.extend_from_slice(b"mif1heic"); - let mut out = bx(b"ftyp", &ftyp); - out.extend_from_slice(&full(b"meta", 0, meta)); - out - } - - /// A 1x1 64x64 grid, with `grid_icc` on the grid item and `tile_icc` on its single tile. - /// - /// `ipco` children are numbered from 1 in file order; an association byte's top bit marks the - /// property essential, as macOS writes it. - fn grid_file(grid_icc: Option<&[u8]>, tile_icc: Option<&[u8]>) -> Vec { - grid_file_colr(grid_icc.map(colr_prof), tile_icc.map(colr_prof)) - } - - /// [`grid_file`] with whole `colr` boxes. - fn grid_file_colr(grid_colr: Option>, tile_colr: Option>) -> Vec { - let mut ipco = ispe(64, 64); - let (mut grid_assoc, mut tile_assoc, mut index) = (vec![0x81], vec![0x81], 1u8); - if let Some(colr) = grid_colr { - index += 1; - ipco.extend_from_slice(&colr); - grid_assoc.push(0x80 | index); - } - if let Some(colr) = tile_colr { - index += 1; - ipco.extend_from_slice(&colr); - tile_assoc.push(0x80 | index); - } - let mut iprp = bx(b"ipco", &ipco); - iprp.extend_from_slice(&ipma(&[(GRID_ID, grid_assoc.as_slice()), (PICTURE_ID, tile_assoc.as_slice())])); - - let mut items = 2u16.to_be_bytes().to_vec(); - items.extend_from_slice(&infe(GRID_ID, b"grid")); - items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); - - // Version 0, flags 0, one row, one column, 64x64 output. - const DESCRIPTOR: &[u8] = &[0, 0, 0, 0, 0, 64, 0, 64]; - - let mut meta = hdlr(); - meta.extend_from_slice(&full(b"pitm", 0, &GRID_ID.to_be_bytes())); - meta.extend_from_slice(&full(b"iinf", 0, &items)); - meta.extend_from_slice(&iref_dimg(GRID_ID, PICTURE_ID)); - meta.extend_from_slice(&bx(b"iprp", &iprp)); - meta.extend_from_slice(&bx(b"idat", DESCRIPTOR)); - meta.extend_from_slice(&iloc_in_idat(GRID_ID, u32::try_from(DESCRIPTOR.len()).unwrap())); - file(&meta) - } - - /// One coded picture rather than a derivation, with `icc` on the item itself. - fn picture_file(icc: Option<&[u8]>) -> Vec { - picture_file_colr(icc.map(colr_prof)) - } - - /// [`picture_file`] with a whole `colr` box. - fn picture_file_colr(colr: Option>) -> Vec { - let mut ipco = ispe(64, 64); - let mut assoc = vec![0x81]; - if let Some(colr) = colr { - ipco.extend_from_slice(&colr); - assoc.push(0x82); - } - let mut iprp = bx(b"ipco", &ipco); - iprp.extend_from_slice(&ipma(&[(PICTURE_ID, assoc.as_slice())])); - - let mut items = 1u16.to_be_bytes().to_vec(); - items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); - - let mut meta = hdlr(); - meta.extend_from_slice(&full(b"pitm", 0, &PICTURE_ID.to_be_bytes())); - meta.extend_from_slice(&full(b"iinf", 0, &items)); - meta.extend_from_slice(&bx(b"iprp", &iprp)); - file(&meta) - } - - fn ctx_of(bytes: &[u8]) -> heic_rs::context::Context<'_> { - heic_rs::context::Context::open(bytes).expect("the synthetic container must parse") - } - - #[test] - fn a_grid_inherits_its_tiles_icc_profile() { - let bytes = grid_file(None, Some(TILE_ICC)); - let ctx = ctx_of(&bytes); - let primary = ctx.meta.primary; - assert_eq!(primary, u32::from(GRID_ID)); - let (grid, tiles) = ctx.grid(primary).unwrap().expect("a grid derivation"); - assert_eq!((grid.rows, grid.columns, tiles.len()), (1, 1, 1)); - assert!(ctx.icc(&ctx.props(primary).unwrap()).is_none(), "the grid item carries no colr of its own"); - assert_eq!(ctx.icc(&ctx.props(tiles[0]).unwrap()), Some(TILE_ICC), "the tile carries the profile"); - - assert_eq!(icc_for_item(&ctx, primary).as_deref(), Some(TILE_ICC)); - } - - #[test] - fn a_grids_own_profile_wins_over_its_tiles() { - let bytes = grid_file(Some(GRID_ICC), Some(TILE_ICC)); - let ctx = ctx_of(&bytes); - assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); - } - - #[test] - fn an_unlabelled_grid_has_no_profile() { - let bytes = grid_file(None, None); - let ctx = ctx_of(&bytes); - assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); - } - - #[test] - fn a_coded_picture_resolves_its_own_profile() { - let labelled = picture_file(Some(GRID_ICC)); - let ctx = ctx_of(&labelled); - assert!(ctx.grid(ctx.meta.primary).unwrap().is_none(), "not a derivation"); - assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); - - let bare = picture_file(None); - let ctx = ctx_of(&bare); - assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); - } - /// An iPhone 10-bit HDR HEIC's description (#6752): BT.2020 primaries, ARIB STD-B67 (HLG) - /// transfer, BT.2020 non-constant-luminance matrix, full range. - const HLG: Nclx = Nclx { primaries: 9, transfer: 18, matrix: 9, full_range: true }; - - #[test] - fn a_coded_pictures_nclx_is_read() { - let bytes = picture_file_colr(Some(colr_nclx(9, 18, 9, true))); - let ctx = ctx_of(&bytes); - assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), Some(HLG)); - - let sdr = picture_file_colr(Some(colr_nclx(1, 13, 6, false))); - let ctx = ctx_of(&sdr); - assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), Some(Nclx { primaries: 1, transfer: 13, matrix: 6, full_range: false })); - - let bare = picture_file(Some(TILE_ICC)); - let ctx = ctx_of(&bare); - assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), None, "an ICC profile is not an nclx"); - } - - #[test] - fn a_grid_inherits_its_tiles_nclx() { - let bytes = grid_file_colr(None, Some(colr_nclx(9, 18, 9, true))); - let ctx = ctx_of(&bytes); - assert!(ctx.props(ctx.meta.primary).unwrap().nclx.is_none(), "the grid item carries no colr of its own"); - assert_eq!(nclx_for_item(&ctx, ctx.meta.primary), Some(HLG)); - } -} diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 8ec7611923..801b5964cf 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -3,7 +3,7 @@ //! Each strip or tile holds one JPEG XL codestream (bare or in the ISO/IEC 18181-2 container) //! whose image is the segment's samples: one channel for CFA data, three for LinearRaw. Samsung //! Expert RAW writes LinearRaw this way. Decoding is done by `jxl-oxide`, a pure-Rust decoder; -//! like heic-rs in `photocraft-heif`, every call into it runs under `catch_unwind` so a decoder +//! like heic-rs in `craft-heif`, every call into it runs under `catch_unwind` so a decoder //! panic on a malformed file becomes [`RawError::Malformed`]. //! //! The browser build (wasm32) leaves the decoder out: jxl-oxide added 0.88 MB to a wasm that diff --git a/docs/architecture.md b/docs/architecture.md index 43c59711f5..603fd95f69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,7 @@ What exists on `main` today. The numbered sections below are the original design map to them (`crates/ui-egui/src/menu_catalog.rs`). - **Rendering:** CPU compositor (`compose`, the oracle) and wgpu compositor (`gpu`, ≤ 1/255 of the CPU); colour through our own ICC engine (`cms`), GPU display conversion through matrix/shaper stages or a 3D LUT fallback. -- **Files:** own PSD/PSB crate (`psd`), flat codecs (`codecs`), camera raw (`raw`), HEIF (`heif`), +- **Files:** own PSD/PSB crate (`psd`), flat codecs (`codecs`), camera raw (`raw`), HEIF (`craft-heif`, from craft-libs), Affinity (`affinity`), document mapping (`io`), native `.pcraft` (`format`, atomic saves). Coverage: [`file-format-parity.md`](file-format-parity.md). - **Not built (yet) from the plan:** the `ml` crate (no ML runtime; see gap G15), plug-in ABI v2. @@ -127,7 +127,6 @@ photocraft/ │ ├─ psd/ photocraft-psd PSD/PSB read + write; its OWN format-level model; depends on nothing in this workspace │ ├─ adobe-assets/ photocraft-adobe-assets .abr .asl .aco/.ase .grd .pat .csh .atn .cube/.3dl, ACR .xmp presets (standalone, like psd) │ ├─ codecs/ photocraft-codecs flat raster decode/encode at format-supported depths; AVIF encode only (feature `avif`), HEIF/HEIC decode only (feature `heif`); no JPEG XL -│ ├─ heif/ photocraft-heif optional HEIF/HEIC decoder (heic-rs, pure Rust); used only by codecs behind its `heif` feature (standalone) │ ├─ affinity/ photocraft-affinity bounded native Affinity reader: archive, object stream, layers, vectors, text, pixels (standalone; read only) │ ├─ raw/ photocraft-raw clean-room camera RAW decode (DNG, CR2, TIFF/EP) + develop pipeline (standalone, like psd) │ ├─ format/ photocraft-format native document format (.pcraft bundle): manifest + content-addressed tiles @@ -180,7 +179,7 @@ photocraft/ 1. A crate may depend only on crates in **lower** layers. No cycles and no sideways dependencies, except where listed. 2. **Nothing below L6 may depend on any UI toolkit, winit, or a `platform` implementation.** Platform services reach the engine through traits defined in `engine` (or in `platform`'s trait-only core), and are injected at startup. -3. **`photocraft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects. The same holds for every standalone crate, with one documented exception: `codecs` → `heif` (both standalone and publishable; `STANDALONE_EXCEPTIONS` in `xtask/src/layers.rs`). +3. **`photocraft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects. The same holds for every standalone crate; `STANDALONE_EXCEPTIONS` in `xtask/src/layers.rs` is empty since HEIF decoding moved to craft-libs' `craft-heif`. 4. **wasm gate:** every crate in L0–L5 (except feature-gated native backends) must build for `wasm32-unknown-unknown`. CI runs `cargo build -p photocraft-engine --target wasm32-unknown-unknown --no-default-features --features web`. 5. `gpu` is optional for `engine`. Engine features are `gpu` (default on) and `cpu-only`, and `cpu-only` builds are what the headless CLI and CI tests use. 6. **C dependencies** (optional LibRaw) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target. diff --git a/docs/scorecard.md b/docs/scorecard.md index 9664109d78..b7fb8babd4 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -12,7 +12,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 8 of 199 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 4 | 2 | 664 of 664 menu items live (100.0%) | -| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | +| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 5 | 1 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -277,7 +277,7 @@ Menu wiring (`docs/parity-checklist.md`): 664 of 664 menu items live (100.0%). T ## Reliability -Never-crash attribute on 30 of 30 crate roots. +Never-crash attribute on 29 of 29 crate roots. | Id | Target | Status | Issue | Note | |---|---|---|---|---| diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index 641d1ef263..263cb926f3 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -22,7 +22,7 @@ pub const PSD_TOOLS_COMMIT: &str = "96eb134c17b2c65edf4c4151c0f00b802ada86c2"; /// https://github.com/Agamnentzar/ag-psd (MIT): `test/` (master, 2026-07-02). pub const AG_PSD_COMMIT: &str = "387049670cb89b88fb8fe1b7c01aeacf98dd2e3b"; /// https://github.com/tbraun96/heic-rs (MIT OR Apache-2.0): `tests/fixtures` at the v0.1.1 tag, the -/// version photocraft-heif pins (2026-09-12). +/// version first pinned (2026-09-12); craft-heif now pins 0.1.4. pub const HEIC_RS_COMMIT: &str = "4f0d4df474c773dfc3b14fa80e7219be6898866e"; /// https://github.com/bigcat88/pillow_heif (BSD-3-Clause): `tests/images` (master, 2026-09-25). pub const PILLOW_HEIF_COMMIT: &str = "b16be1196dfa465a342d68894696e685ca3655cb"; diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index f2e6f53514..bf1a6f2b88 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -40,7 +40,6 @@ pub const TABLE: &[(&str, Class)] = &[ ("psd", Class::Standalone), ("codecs", Class::Standalone), // Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature. - ("heif", Class::Standalone), // IPP client for CUPS printing (pure Rust, no dependencies), used by `engine` on Unix. ("ipp", Class::Standalone), ("raw", Class::Standalone), @@ -90,9 +89,9 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool { } /// The only workspace dependencies a standalone crate may have: (from, to), both standalone and -/// both publishable. `codecs` uses the optional `heif` decoder crate behind its `heif` feature, so -/// distributors can leave HEVC decoding out of a build; `heif` itself depends on no workspace crate. -pub const STANDALONE_EXCEPTIONS: &[(&str, &str)] = &[("codecs", "heif")]; +/// both publishable. None at present: the one there was, `codecs` → `heif`, ended when the HEIF +/// decoder moved to craft-libs as `craft-heif` (craft-libs#784). +pub const STANDALONE_EXCEPTIONS: &[(&str, &str)] = &[]; fn standalone_exception(from: &str, to: &str) -> bool { let (from, to) = (short_name(from), short_name(to)); @@ -323,15 +322,13 @@ mod tests { } #[test] - fn standalone_exception_is_exactly_codecs_to_heif() { - assert!(check(&[c("photocraft-codecs", &[("photocraft-heif", Normal, true)])]).is_empty()); - assert!(check(&[c("photocraft-codecs", &[("photocraft-heif", Dev, true)])]).is_empty()); - // Not the other way round, not for other standalone crates, and heif stays dependency-free. - for (from, to) in [("photocraft-heif", "photocraft-codecs"), ("photocraft-psd", "photocraft-heif"), ("photocraft-heif", "photocraft-geom")] { + fn standalone_crates_have_no_exceptions() { + // Standalone crates may not use each other, in either direction. + for (from, to) in [("photocraft-codecs", "photocraft-psd"), ("photocraft-psd", "photocraft-codecs")] { let v = check(&[c(from, &[(to, Normal, true)])]); assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), "{from} -> {to}"); } - // The exception does not open codecs up to layered crates. + // Nor layered crates. let v = check(&[c("photocraft-codecs", &[("photocraft-geom", Normal, true)])]); assert!(matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }])); } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c9d18a90c2..6eec299bef 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -225,7 +225,7 @@ fn cmd_test() -> Result<(), String> { run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) } -/// What the CI gate adds to every build: `--locked`, and the optional photocraft-heif crate that +/// What the CI gate adds to every build: `--locked`, and the optional craft-heif decoder that /// official builds open HEIC photos with. const GATE_ARGS: [&str; 4] = ["--workspace", "--locked", "--features", "heif"];