diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2544a6..7225c40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,19 +71,9 @@ jobs: # `image` crate; encode is pure-Rust gamut-avif), `heic` (pure-Rust # gamut-heic container/pipeline — no system library; pixel decode goes # through rawshift-hwdec, which `full`'s `hw` flag compiles as the - # no-backend stub until the platform backends land), `jxl-encode` + # no-backend stub until the platform backends land), and `jxl-encode` # (builds libjxl hermetically through gamut-jxl-sys — cmake + clang for - # its bindings), and `jpeg-encode-jpegli-vendored` builds the vendored - # google/jpegli submodule the same way (cmake + clang). - - name: Check out jpegli build submodules - # Init the vendored google/jpegli submodule and only its build-time - # dependencies — the 111 MB `testdata` and `googletest` submodules are for - # jpegli's own tests, which we disable, so they are skipped. - run: | - git submodule update --init crates/rawshift-image/third_party/jpegli - git -C crates/rawshift-image/third_party/jpegli submodule update --init \ - third_party/highway third_party/libjpeg-turbo third_party/skcms \ - third_party/lcms third_party/libpng third_party/zlib third_party/sjpeg + # its bindings). - name: Install system libraries run: | sudo apt-get update @@ -94,7 +84,7 @@ jobs: GH_TOKEN: ${{ github.token }} - name: Generate standard format fixtures run: cargo run -p rawshift-image --example generate_test_fixtures - - run: cargo test --workspace --features rawshift-image/full,rawshift-image/jpeg-encode-jpegli-vendored + - run: cargo test --workspace --features rawshift-image/full image-only: name: Build (image only) diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 728de6f..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "crates/rawshift-image/third_party/jpegli"] - path = crates/rawshift-image/third_party/jpegli - url = https://github.com/google/jpegli.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e27d2f..89dfcb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 layer (see `docs/SUPPORT.md`). HEIC pixel decode in `rawshift-image` now works end-to-end on hardware through this backend. +### Changed + +- *(image)* **breaking**: JPEG migrated to gamut-jpeg (pure Rust, baseline + + progressive both ways). Decode replaces `zune-jpeg` (`DecodeOptions::Jpeg`, + codec id `jpeg/gamut`; CMYK/YCCK conversion is bit-identical to the previous + backend); encode replaces `jpeg-encoder` **and** the entire jpegli stack + with one `EncodeOptions::Jpeg(JpegEncodeConfig)` exposing gamut's knobs + (quality, subsampling, progressive, restart interval, JFIF density). + JPEG EXIF/XMP/ICC now embed via the encoder and extract via + `gamut_jpeg::metadata` (JPEG metadata reads now also surface ICC + XMP); + `probe_standard_image` reads the JPEG header through `gamut_jpeg::info`. + +### Removed + +- *(image)* **breaking**: the `jpeg-decode-zune`, `jpeg-encode-jpeg-enc`, + `jpeg-encode-jpegli`, `jpeg-encode-jpegli-vendored`, and `container-embed` + features; the `zune-jpeg`, `jpeg-encoder`, and `img-parts` dependencies; the + vendored `google/jpegli` submodule and the cc/cmake/bindgen build-script + slice; `EncodeError::Jpeg`/`EncodeError::Jpegli`; `ExifContainer::Jpeg` and + the hand-rolled JPEG APP1 scanner (jpegli parity is tracked upstream: + gamut#19/#29/#30). + ## [0.1.1](https://github.com/justin13888/rawshift/compare/v0.1.0...v0.1.1) - 2026-05-29 ### Added diff --git a/Cargo.lock b/Cargo.lock index 151ea3c..c093a46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,26 +128,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.11.0", - "cexpr", - "clang-sys", - "itertools", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -232,15 +212,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-expr" version = "0.20.7" @@ -284,17 +255,6 @@ dependencies = [ "half", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" version = "4.5.60" @@ -744,6 +704,17 @@ dependencies = [ "gamut-core", ] +[[package]] +name = "gamut-jpeg" +version = "0.1.0" +source = "git+https://github.com/justin13888/gamut?rev=dde9f640ab02ec9c3437c3f1181164f6e7c60151#dde9f640ab02ec9c3437c3f1181164f6e7c60151" +dependencies = [ + "gamut-codec-abi", + "gamut-color", + "gamut-core", + "gamut-dsp", +] + [[package]] name = "gamut-jxl" version = "0.3.0" @@ -903,17 +874,6 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" -[[package]] -name = "img-parts" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19734e3c43b2a850f5889c077056e47c874095f2d87e853c7c41214ae67375f0" -dependencies = [ - "bytes", - "crc32fast", - "miniz_oxide", -] - [[package]] name = "indenter" version = "0.3.4" @@ -972,12 +932,6 @@ dependencies = [ "libc", ] -[[package]] -name = "jpeg-encoder" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b0b36cbb4e6704f12f5b5d7b01dac593982c6550859ebd5a66fb15c9ea27fd5" - [[package]] name = "jpegxl-src" version = "0.12.0" @@ -1139,12 +1093,6 @@ dependencies = [ "libc", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1190,16 +1138,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1359,16 +1297,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1466,10 +1394,7 @@ dependencies = [ name = "rawshift-image" version = "0.1.1" dependencies = [ - "bindgen", - "cc", "clap", - "cmake", "criterion", "eyre", "gamut-avif", @@ -1481,16 +1406,14 @@ dependencies = [ "gamut-icc", "gamut-ifd", "gamut-isobmff", + "gamut-jpeg", "gamut-jxl", "gamut-metadata", "gamut-png", "gamut-xmp", "gif", "image", - "img-parts", - "jpeg-encoder", "libwebp-sys", - "pkg-config", "rawshift-core", "rawshift-hwdec", "rayon", @@ -1503,7 +1426,6 @@ dependencies = [ "tracing", "tracing-subscriber", "zune-core 0.5.1", - "zune-jpeg 0.5.12", "zune-png", "zune-ppm", ] @@ -1602,12 +1524,6 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - [[package]] name = "rustversion" version = "1.0.22" diff --git a/Cargo.toml b/Cargo.toml index 4963fa6..a2dd9ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ gamut-dng = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02e gamut-metadata = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151" } gamut-xmp = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151" } gamut-icc = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151" } +gamut-jpeg = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151" } # default-features off: gamut-jxl defaults to decode+encode; rawshift's # `jxl-decode` / `jxl-encode` features select the halves explicitly. gamut-jxl = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151", default-features = false } diff --git a/crates/rawshift-image/Cargo.toml b/crates/rawshift-image/Cargo.toml index b0c740b..3eb4db9 100644 --- a/crates/rawshift-image/Cargo.toml +++ b/crates/rawshift-image/Cargo.toml @@ -13,11 +13,9 @@ categories = ["multimedia::images", "encoding", "parser-implementations"] readme = "README.md" [package.metadata.docs.rs] -# The `jpeg-encode-jpegli*` features are intentionally excluded: each builds a -# C/C++ library from source (or links a system one) and is an alternative -# linking mode, not an additive feature. (`jxl-encode` builds libjxl too — via -# gamut-jxl-sys — but docs.rs builds it fine because the build is hermetic -# under OUT_DIR; it is part of `full`.) +# (`jxl-encode` builds libjxl from source — via gamut-jxl-sys — but docs.rs +# builds it fine because the build is hermetic under OUT_DIR; it is part of +# `full`.) features = ["full"] rustdoc-args = ["--cfg", "docsrs"] @@ -51,6 +49,11 @@ gamut-png = { workspace = true, optional = true } # the `image` crate until #33. gamut-avif = { workspace = true, optional = true } gamut-xmp = { workspace = true, optional = true } +# gamut-jpeg backs JPEG decode (baseline + progressive, grayscale/YCbCr/RGB/ +# CMYK/YCCK presentation) and encode (baseline or progressive 8-bit DCT with +# quality/subsampling/restart/density knobs), plus APP-segment EXIF/XMP/ICC +# metadata in both directions — pure Rust, no C toolchain. +gamut-jpeg = { workspace = true, optional = true } # gamut-heic backs HEIC decode: container parsing, item/metadata/auxiliary # enumeration, grid/iden/iovl derivation, colour conversion, and the # clap/irot/imir transforms. The HEVC codestream itself is decoded through the @@ -73,8 +76,6 @@ rawshift-hwdec = { workspace = true, optional = true } # compiled is selected by the `jxl-decode` / `jxl-encode` features. gamut-jxl = { workspace = true, optional = true } libwebp-sys = { version = "0.14", optional = true } -img-parts = { version = "0.4", optional = true } -jpeg-encoder = { version = "0.7", optional = true } rayon = { workspace = true } image = { version = "0.25", default-features = false, features = ["avif-native"], optional = true } resvg = { version = "0.44", optional = true } @@ -82,23 +83,11 @@ serde = { workspace = true, optional = true } thiserror = { workspace = true } tracing = { workspace = true } zune-core = { version = "0.5", optional = true } -zune-jpeg = { version = "0.5", optional = true } zune-png = { version = "0.5", optional = true } zune-ppm = { version = "0.5", optional = true } gif = { version = "0.13", optional = true } tiff = { version = "0.11", optional = true } -[build-dependencies] -# Compiled only when a `jpeg-encode-jpegli*` feature is enabled (see -# [features]). All permissive (no GPL): bindgen (BSD-3) generates the C-API -# bindings; pkg-config resolves a system libjpegli; cc (MIT/Apache) compiles -# the jpegli setjmp shim; cmake (MIT/Apache) builds the vendored -# google/jpegli submodule. -bindgen = { version = "0.72", optional = true } -pkg-config = { version = "0.3", optional = true } -cc = { version = "1", optional = true } -cmake = { version = "0.1", optional = true } - [dev-dependencies] clap = { version = "4.5", features = ["derive"] } criterion = { version = "0.5", features = ["html_reports"] } @@ -128,12 +117,12 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # tier 1 bundles full, experimental, raw-stabilizing, … # tier 2 format jpeg, png, dng, … (decode + encode) # tier 3 direction jpeg-decode, dng-encode, … (default impl alias) -# tier 4 implementation jpeg-decode-zune, … (compressed only) +# tier 4 implementation png-decode-zune, … (compressed only) # tier 5 infrastructure ifd-parser, serde, zune-runtime, exif, … # # Only tier-4 features (plus RAW tier-3 features and the gamut-backed -# `png-encode` / `jxl-decode` / `jxl-encode` / `avif-encode`, which have a -# single implementation) reference `dep:*`. +# `jpeg-decode` / `jpeg-encode` / `png-encode` / `jxl-decode` / `jxl-encode` / +# `avif-encode`, which have a single implementation) reference `dep:*`. default = ["jpeg", "png", "webp", "jxl-decode", "gif-decode", "tiff-decode", "ppm-decode"] # ── Tier 1: bundle features ─────────────────────────────────────────────────── @@ -167,8 +156,14 @@ raf = ["raf-decode"] # format+direction's DEFAULT implementation (tier 4) — this is where the notion # of a per-format default lives. RAW direction features reference infra/deps # directly: RAW formats have a single in-repo implementation and no tier-4 layer. -jpeg-decode = ["jpeg-decode-zune"] -jpeg-encode = ["jpeg-encode-jpeg-enc"] +# JPEG is backed directly by gamut-jpeg (no tier-4 layer): like `png-encode` +# and the RAW direction features, each half has a single gamut-backed +# implementation, so the format-direction feature itself pulls the dependency. +# Decode covers baseline + progressive streams (grayscale/YCbCr/RGB/CMYK/YCCK); +# encode writes baseline or progressive 8-bit DCT JPEG. `exif` supplies the +# EXIF builder/parser for the APP1 segment gamut-jpeg reads and writes. +jpeg-decode = ["dep:gamut-jpeg", "exif"] +jpeg-encode = ["dep:gamut-jpeg", "exif"] png-decode = ["png-decode-zune"] # PNG encode is backed directly by gamut-png (no tier-4 layer): like the RAW # direction features, it has a single gamut-backed implementation, so the @@ -193,9 +188,9 @@ avif-decode = ["avif-decode-image"] # implementation, so the format-direction feature itself pulls the dependency. # Pure Rust (gamut-av1 AV1 intra + gamut-isobmff container) — 8-bit RGB, # lossless or lossy; 10/12-bit output is pending justin13888/gamut#251. -# `exif` supplies the ExifBuilder and `container-embed` the XMP validation -# used when splicing EXIF/ICC/XMP items into the encoded container. -avif-encode = ["dep:gamut-avif", "exif", "container-embed"] +# `exif` supplies the ExifBuilder and the `gamut-xmp` validation used when +# splicing EXIF/ICC/XMP items into the encoded container. +avif-encode = ["dep:gamut-avif", "exif"] # HEIC decode is backed directly by gamut-heic (no tier-4 layer): container # parsing, metadata, and auxiliary-image enumeration always work; pixel decode # additionally needs a hardware HEVC decoder (`hw`/`hw-*`, see below) and @@ -224,19 +219,11 @@ raf-decode = [] # standalone. Multiple implementations of the same format+direction may be # enabled at once; the active backend is chosen at the API level — see # `DecodeOptions` and `EncodeOptions`. -# `zune-jpeg`/`zune-png` build on the `zune-core` codec primitives, so each -# zune-backed impl feature also pulls `zune-runtime`. -jpeg-decode-zune = ["jpeg-decode", "dep:zune-jpeg", "zune-runtime", "exif"] -jpeg-encode-jpeg-enc = ["jpeg-encode", "dep:jpeg-encoder", "exif", "container-embed"] -# Alternative JPEG encoder via jpegli (libjxl's perceptual JPEG encoder): -# distance-based rate control, XYB mode, and 16-bit input fed at full precision. -# Enabling it keeps `jpeg-encode-jpeg-enc` compiled in too (tier-3 aliases the -# default impl); the active backend is chosen via `EncodeOptions`. Links a system -# libjpegli by default — see `jpeg-encode-jpegli-vendored`. -jpeg-encode-jpegli = ["jpeg-encode", "exif", "container-embed", "dep:bindgen", "dep:cc", "dep:pkg-config"] +# `zune-png` builds on the `zune-core` codec primitives, so each zune-backed +# impl feature also pulls `zune-runtime`. png-decode-zune = ["png-decode", "dep:zune-png", "zune-runtime", "exif"] webp-decode-libwebp = ["webp-decode", "dep:libwebp-sys", "exif"] -webp-encode-libwebp = ["webp-encode", "dep:libwebp-sys", "exif", "container-embed"] +webp-encode-libwebp = ["webp-encode", "dep:libwebp-sys", "exif"] gif-decode-gif = ["gif-decode", "dep:gif"] tiff-decode-tiff = ["tiff-decode", "dep:tiff", "exif"] avif-decode-image = ["avif-decode", "dep:image", "exif"] @@ -256,10 +243,6 @@ zune-runtime = ["dep:zune-core"] # Pulled by format impl features that parse embedded EXIF on decode or write it # on encode. exif = ["dep:gamut-exif", "dep:gamut-metadata", "dep:gamut-xmp"] -# Container segment muxing via `img-parts` (JPEG APP segments, PNG chunks), -# plus `gamut-xmp` for validating XMP packets before they are embedded. -# Pulled by encode impl features that embed EXIF/ICC/XMP metadata. -container-embed = ["dep:img-parts", "dep:gamut-xmp"] # Hardware still-frame decode (HEVC for HEIC; AV1 for AVIF once gamut-avif # exposes the container handoff). These are **verified** feature flags — see # docs/SUPPORT.md for the permanent target/API matrix: @@ -273,12 +256,6 @@ hw = ["dep:rawshift-hwdec", "rawshift-hwdec/hw"] hw-videotoolbox = ["hw", "rawshift-hwdec/videotoolbox"] hw-vaapi = ["hw", "rawshift-hwdec/vaapi"] hw-mediacodec = ["hw", "rawshift-hwdec/mediacodec"] -# jpegli linking mode. `jpeg-encode-jpegli` links a system libjpegli (resolved by -# pkg-config — rarely packaged on distros yet); `jpeg-encode-jpegli-vendored` -# builds the vendored `google/jpegli` submodule from source via cmake and links -# it (+ Highway) statically (needs a C/C++ toolchain + cmake). The vendored mode -# is the normal, tested path. -jpeg-encode-jpegli-vendored = ["jpeg-encode-jpegli", "dep:cmake"] [[bench]] name = "decode" diff --git a/crates/rawshift-image/README.md b/crates/rawshift-image/README.md index 0ce8962..3ee4c24 100644 --- a/crates/rawshift-image/README.md +++ b/crates/rawshift-image/README.md @@ -21,7 +21,7 @@ or an explicit hardware-decode backend pin (`hw-*`). | Adobe DNG | [gamut-dng](https://github.com/justin13888/gamut) (Stabilizing) | Custom TIFF writer (Stabilizing) | Includes Apple ProRAW (DNG 1.7 + JXL). | | Nikon NEF | Custom TIFF parser (Incomplete) | N/A | No test fixtures. | | Fujifilm RAF | Custom RAF parser (Incomplete) | N/A | No test fixtures. | -| JPEG | [zune-jpeg](https://github.com/etemesi254/zune-image/tree/dev/crates/zune-jpeg) (Stable) | [jpeg-encoder](https://github.com/vstroebel/jpeg-encoder) (Stable, default) · [jpegli](https://github.com/google/jpegli) (distance/XYB + 16-bit input, opt-in) | jpegli via `jpeg-encode-jpegli` (system) / `jpeg-encode-jpegli-vendored` (from source). | +| JPEG | [gamut-jpeg](https://github.com/justin13888/gamut) (Stable) | [gamut-jpeg](https://github.com/justin13888/gamut) (Stable) | Pure Rust. Decode: baseline + progressive, grayscale/YCbCr/RGB/CMYK/YCCK. Encode: baseline or progressive 8-bit DCT (quality/subsampling/restart/density); APP1/APP2 EXIF/XMP/ICC both ways. | | PNG | [zune-png](https://github.com/etemesi254/zune-image/tree/dev/crates/zune-png) (Stable) | [gamut-png](https://github.com/justin13888/gamut) (Stable) | Encode via gamut (8/16-bit RGB, eXIf/iCCP/XMP chunks). | | WebP | [libwebp-sys](https://github.com/noxf/libwebp-sys) (Stable) | [libwebp-sys](https://github.com/noxf/libwebp-sys) (Stable) | C FFI bindings to libwebp. | | GIF | [gif](https://github.com/image-rs/image-gif) (Stable) | Not planned | | @@ -46,8 +46,8 @@ implementations are named and selected. Cargo features are organised in five tiers, from high-level bundles down to individual library bindings. Each tier is defined purely in terms of the tier below it; only tier-4 features (plus RAW tier-3 features and the gamut-backed -`png-encode` / `jxl-decode` / `jxl-encode` / `avif-encode`) pull in an -external crate. +`jpeg-decode` / `jpeg-encode` / `png-encode` / `jxl-decode` / `jxl-encode` / +`avif-encode`) pull in an external crate. 1. **Bundle features** — coarse, ready-made groupings. - `default` — `jpeg`, `png`, `webp`, `jxl-decode`, `gif-decode`, `tiff-decode`, `ppm-decode`. @@ -66,10 +66,11 @@ external crate. `tiff-decode`, `avif-decode`, `avif-encode`, `heic-decode`, `svg-decode`, `ppm-decode` — each is an **alias for that format+direction's default implementation**. - This is where the per-format default is defined. Exception: `png-encode`, - `jxl-decode`, `jxl-encode`, `avif-encode`, and `heic-decode` each have a - single gamut-backed implementation (`gamut-png` / `gamut-jxl` / - `gamut-avif` / `gamut-heic`) and pull it directly, with no tier-4 layer + This is where the per-format default is defined. Exception: `jpeg-decode`, + `jpeg-encode`, `png-encode`, `jxl-decode`, `jxl-encode`, `avif-encode`, + and `heic-decode` each have a single gamut-backed implementation + (`gamut-jpeg` / `gamut-png` / `gamut-jxl` / `gamut-avif` / `gamut-heic`) + and pull it directly, with no tier-4 layer below them. (`jxl-encode` wraps the reference libjxl, which `gamut-jxl-sys` cmake-builds and links statically — it needs cmake and a C++ toolchain. `avif-encode` is pure Rust: 8-bit @@ -84,7 +85,6 @@ external crate. only tier that pulls an external crate. Multiple implementations of the same format+direction may be enabled simultaneously; the active backend is chosen at the API level via `DecodeOptions` / `EncodeOptions`. - - `jpeg-decode-zune`, `jpeg-encode-jpeg-enc`, `jpeg-encode-jpegli` - `png-decode-zune` - `webp-decode-libwebp`, `webp-encode-libwebp` - `gif-decode-gif`, `tiff-decode-tiff` @@ -97,8 +97,6 @@ external crate. - `zune-runtime` — `zune-core` codec primitives; pulled by zune-backed impls. - `exif` — typed EXIF read/write via the gamut metadata stack (`gamut-exif`, `gamut-metadata`, `gamut-xmp`); pulled by impls that touch EXIF. - - `container-embed` — container segment muxing (`img-parts`) plus XMP packet - validation (`gamut-xmp`); pulled by encode impls that embed EXIF/ICC/XMP. - `hw` — hardware still-frame decode via `rawshift-hwdec`, selecting the **native** backend for the compile target (VideoToolbox on Apple, VAAPI on linux-gnu, MediaCodec on Android — the permanent matrix in @@ -108,16 +106,10 @@ external crate. backend; **`compile_error!` on any other target** (verified feature flags). Without any `hw` flag, `heic` is a valid container/metadata-only build whose pixel decode returns `RawError::HwDecoderUnavailable`. - - `jpeg-encode-jpegli-vendored` — build the vendored `google/jpegli` submodule - from source via cmake and link it statically, instead of linking a system - libjpegli (`jpeg-encode-jpegli`). Requires a C/C++ toolchain, cmake, and - `libclang`; init the submodule with - `git submodule update --init --recursive crates/rawshift-image/third_party/jpegli`. - The `zune-runtime` / `exif` / `container-embed` features are pulled in - automatically by the format implementations that need them — they exist so - that a minimal `rawshift-image` build links no decoder/metadata crate it does - not use. + The `zune-runtime` / `exif` features are pulled in automatically by the + format implementations that need them — they exist so that a minimal + `rawshift-image` build links no decoder/metadata crate it does not use. Resolution example: enabling `default` pulls in `png` → `png-decode` → `png-decode-zune` → the `zune-png` crate. To use a non-default implementation, diff --git a/crates/rawshift-image/build.rs b/crates/rawshift-image/build.rs index 07b90bd..08e73a5 100644 --- a/crates/rawshift-image/build.rs +++ b/crates/rawshift-image/build.rs @@ -56,165 +56,5 @@ fn main() { ], ); - // jpegli encoder backend: build the vendored google/jpegli static lib (or - // probe a system libjpegli), compile our C++ setjmp shim, and bindgen the - // shim's C ABI. Only compiled when a `jpeg-encode-jpegli*` feature is on; its - // build deps (bindgen / cc / cmake / pkg-config) are optional and pulled by - // those features. All permissive (BSD-3 / MIT-Apache) — no GPL. - #[cfg(feature = "jpeg-encode-jpegli")] - jpegli::generate(); - // TODO: Generate large static tables from constant files } - -/// Build/resolve jpegli, compile the C++ setjmp shim against it, and run -/// `bindgen` over the shim's self-contained C header into -/// `$OUT_DIR/jpegli_bindings.rs` (included by `src/codecs/jpegli.rs`). -/// -/// jpegli's encode API is libjpeg-style (errors via `error_exit`/`setjmp`), so -/// unlike libjxl's return-code C API the whole compress sequence lives in -/// `src/codecs/jpegli_shim.cc`; here we just compile and link it. -#[cfg(feature = "jpeg-encode-jpegli")] -mod jpegli { - use std::env; - use std::path::PathBuf; - - /// Resolved native jpegli: header dirs for the shim + link directives to - /// emit *after* the shim archive (so link order is shim → jpegli → hwy → c++). - struct Native { - include_dirs: Vec, - vendored: bool, - link_search: Vec, - link_libs: Vec, - } - - pub fn generate() { - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by cargo")); - let native = resolve(); - - // Compile the shim FIRST: GNU ld resolves static archives left-to-right, - // so the shim (which references `jpegli_*`) must precede `jpegli-static`. - let mut build = cc::Build::new(); - build - .cpp(true) - .std("c++17") - .file("src/codecs/jpegli_shim.cc") - .include("src/codecs"); - if native.vendored { - build.define("RAWSHIFT_JPEGLI_VENDORED", None); - } - for dir in &native.include_dirs { - build.include(dir); - } - build.compile("rawshift_jpegli_shim"); - - // Then jpegli + Highway + the C++ runtime. - for dir in &native.link_search { - println!("cargo:rustc-link-search=native={}", dir.display()); - } - for lib in &native.link_libs { - println!("cargo:rustc-link-lib={lib}"); - } - link_cpp_runtime(); - - // bindgen over the self-contained shim header (no jpegli includes needed). - let bindings = bindgen::Builder::default() - .rust_edition(bindgen::RustEdition::Edition2024) - .header("src/codecs/jpegli_shim.h") - .allowlist_function("rawshift_jpegli_.*") - .allowlist_type("RawshiftJpegli.*") - .allowlist_var("RAWSHIFT_JPEGLI_.*") - .default_enum_style(bindgen::EnumVariation::Consts) - .prepend_enum_name(false) - .layout_tests(false) - .generate_comments(false) - .merge_extern_blocks(true) - .generate() - .expect("bindgen failed to generate jpegli shim bindings"); - bindings - .write_to_file(out_dir.join("jpegli_bindings.rs")) - .expect("failed to write jpegli_bindings.rs"); - - println!("cargo:rerun-if-changed=src/codecs/jpegli_shim.cc"); - println!("cargo:rerun-if-changed=src/codecs/jpegli_shim.h"); - } - - /// Vendored: build only the static jpegli library (+ its Highway dep) from - /// the `google/jpegli` submodule; everything else (tools, tests, CMS, - /// png/zlib helpers, the libjpeg-compatible shared lib) is disabled. - #[cfg(feature = "jpeg-encode-jpegli-vendored")] - fn resolve() -> Native { - let src = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")) - .join("third_party/jpegli"); - assert!( - src.join("lib/jpegli/encode.h").exists(), - "jpegli submodule not checked out at {}; run \ - `git submodule update --init --recursive`", - src.display() - ); - - let dst = cmake::Config::new(&src) - .define("BUILD_SHARED_LIBS", "OFF") - .define("BUILD_TESTING", "OFF") - .define("JPEGLI_ENABLE_TOOLS", "OFF") - .define("JPEGLI_ENABLE_DEVTOOLS", "OFF") - .define("JPEGLI_ENABLE_FUZZERS", "OFF") - .define("JPEGLI_ENABLE_DOXYGEN", "OFF") - .define("JPEGLI_ENABLE_MANPAGES", "OFF") - .define("JPEGLI_ENABLE_BENCHMARK", "OFF") - .define("JPEGLI_ENABLE_JNI", "OFF") - .define("JPEGLI_ENABLE_SJPEG", "OFF") - .define("JPEGLI_ENABLE_OPENEXR", "OFF") - .define("JPEGLI_ENABLE_JPEGLI_LIBJPEG", "OFF") - .define("JPEGLI_FORCE_SYSTEM_HWY", "OFF") - .build_target("jpegli-static") - .build(); - - // `build_target` skips install, so artifacts stay in the build tree. - let build_dir = dst.join("build"); - let jpegli_lib = build_dir.join("lib"); - assert!( - jpegli_lib.join("libjpegli-static.a").exists(), - "libjpegli-static.a not found under {}", - jpegli_lib.display() - ); - Native { - // Source root resolves `lib/...`-prefixed internal includes; the - // build dir holds the generated libjpeg-compatible jpeglib.h/jconfig.h. - include_dirs: vec![src.clone(), build_dir.join("lib/include/jpegli")], - vendored: true, - link_search: vec![jpegli_lib, build_dir.join("third_party/highway")], - link_libs: vec!["static=jpegli-static".into(), "static=hwy".into()], - } - } - - /// System: resolve libjpegli via pkg-config (rarely packaged). Metadata - /// emission is suppressed so link directives can be ordered after the shim. - #[cfg(not(feature = "jpeg-encode-jpegli-vendored"))] - fn resolve() -> Native { - let lib = pkg_config::Config::new() - .cargo_metadata(false) - .probe("libjpegli") - .unwrap_or_else(|e| { - panic!( - "could not find system `libjpegli` via pkg-config ({e}); install \ - libjpegli development files or enable the \ - `jpeg-encode-jpegli-vendored` feature to build it from source" - ) - }); - Native { - include_dirs: lib.include_paths, - vendored: false, - link_search: lib.link_paths, - link_libs: lib.libs, - } - } - - fn link_cpp_runtime() { - if cfg!(any(target_vendor = "apple", target_os = "freebsd")) { - println!("cargo:rustc-link-lib=c++"); - } else if cfg!(target_os = "linux") { - println!("cargo:rustc-link-lib=stdc++"); - } - } -} diff --git a/crates/rawshift-image/examples/encode_in_memory.rs b/crates/rawshift-image/examples/encode_in_memory.rs index 4a34859..39cb2e2 100644 --- a/crates/rawshift-image/examples/encode_in_memory.rs +++ b/crates/rawshift-image/examples/encode_in_memory.rs @@ -41,10 +41,6 @@ fn main() -> Result<(), Box> { // others, which are 8-bit). Only present when built with `jxl-encode`. #[cfg(feature = "jxl-encode")] options.push(EncodeOptions::jxl()); - // The opt-in jpegli backend: a perceptual JPEG encoder (distance/XYB), fed - // 16-bit input. Only present when built with `jpeg-encode-jpegli`. - #[cfg(feature = "jpeg-encode-jpegli")] - options.push(EncodeOptions::jpeg_jpegli()); for opts in options { let bytes = encode_rgb_image_to_vec(&image, &metadata, &opts)?; diff --git a/crates/rawshift-image/src/codecs/jpegli.rs b/crates/rawshift-image/src/codecs/jpegli.rs deleted file mode 100644 index d90016c..0000000 --- a/crates/rawshift-image/src/codecs/jpegli.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Safe wrapper around jpegli's encode API, via the C++ setjmp shim. -//! -//! All `unsafe`/FFI interaction with jpegli is confined to this module and -//! `jpegli_shim.cc` (the `src/codecs` safety boundary — see `PRINCIPLES.md`). -//! Callers receive a plain `Vec` JPEG and `Result<_, String>`; -//! `src/formats/encode.rs` maps the `String` into -//! [`EncodeError::Jpegli`](crate::error::EncodeError::Jpegli). -//! -//! The shim's C ABI is generated by `build.rs` (bindgen over `jpegli_shim.h`) -//! into `$OUT_DIR/jpegli_bindings.rs` and `include!`d below — jpegli's own -//! libjpeg-style error model (`error_exit`/`setjmp`) is handled entirely in the -//! shim, so this layer is a thin marshal-in / copy-out. - -use std::os::raw::{c_char, c_int}; -use std::ptr; - -/// Generated jpegli shim bindings (see `build.rs`). bindgen output, so the usual -/// non-idiomatic-naming and clippy lints are silenced wholesale. -#[allow( - non_upper_case_globals, - non_camel_case_types, - non_snake_case, - dead_code, - improper_ctypes, - clippy::all, - clippy::pedantic -)] -mod ffi { - include!(concat!(env!("OUT_DIR"), "/jpegli_bindings.rs")); -} - -/// Chroma subsampling preset. Applied only in non-XYB mode (XYB picks its own). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Subsampling { - /// 4:2:0 - Yuv420, - /// 4:2:2 - Yuv422, - /// 4:4:4 - Yuv444, -} - -/// Fully-resolved jpegli encoder settings — the plain-data contract between -/// [`crate::formats::encode`] and the FFI layer. -#[derive(Debug, Clone)] -pub struct JpegliEncodeParams { - /// JPEG-style quality `1..=100`; when `Some`, used instead of `distance`. - pub quality: Option, - /// Butteraugli distance (`0.0` ≈ visually lossless), used when `quality` is `None`. - pub distance: f32, - /// Emit a progressive JPEG. - pub progressive: bool, - /// Encode in jpegli's XYB high-fidelity mode. - pub xyb: bool, - /// Chroma subsampling. - pub subsampling: Subsampling, -} - -impl Default for JpegliEncodeParams { - fn default() -> Self { - Self { - quality: None, - distance: 1.0, - progressive: true, - xyb: false, - subsampling: Subsampling::Yuv420, - } - } -} - -/// Encode interleaved RGB samples to a JPEG via jpegli. -/// -/// `samples` is packed RGB with no alpha: `width * height * 3` bytes for -/// `bits_per_sample == 8`, or `width * height * 3 * 2` native-endian bytes for -/// `bits_per_sample == 16` (jpegli quantizes from the higher precision, reducing -/// banding). The image is encoded as sRGB; EXIF/ICC/XMP are appended by the caller. -pub fn encode( - samples: &[u8], - width: u32, - height: u32, - bits_per_sample: u32, - params: &JpegliEncodeParams, -) -> Result, String> { - let subsampling = match params.subsampling { - Subsampling::Yuv420 => ffi::RAWSHIFT_JPEGLI_SUBSAMPLE_420, - Subsampling::Yuv422 => ffi::RAWSHIFT_JPEGLI_SUBSAMPLE_422, - Subsampling::Yuv444 => ffi::RAWSHIFT_JPEGLI_SUBSAMPLE_444, - }; - let input = ffi::RawshiftJpegliInput { - pixels: samples.as_ptr(), - pixels_len: samples.len(), - width, - height, - bits_per_sample, - use_distance: c_int::from(params.quality.is_none()), - distance: params.distance, - quality: c_int::from(params.quality.unwrap_or(0)), - progressive: c_int::from(params.progressive), - xyb: c_int::from(params.xyb), - subsampling: subsampling as c_int, - }; - - let mut out: *mut u8 = ptr::null_mut(); - let mut out_len: usize = 0; - let mut err = [0 as c_char; 256]; - - // SAFETY: `input` borrows `samples` for the call; `out`/`out_len`/`err` are - // valid out-params. The shim never retains any pointer past return. - let rc = unsafe { - ffi::rawshift_jpegli_encode(&input, &mut out, &mut out_len, err.as_mut_ptr(), err.len()) - }; - - if rc != 0 { - // SAFETY: on failure the shim writes a NUL-terminated message into `err`. - let msg = unsafe { std::ffi::CStr::from_ptr(err.as_ptr()) } - .to_string_lossy() - .into_owned(); - return Err(if msg.is_empty() { - "jpegli encode failed".to_string() - } else { - msg - }); - } - - // SAFETY: on success `out` is a shim-allocated buffer of `out_len` bytes. - let data = unsafe { std::slice::from_raw_parts(out, out_len) }.to_vec(); - // SAFETY: free the shim-allocated buffer with the matching allocator. - unsafe { ffi::rawshift_jpegli_free(out) }; - Ok(data) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// 4x4 RGB test pattern at the requested depth (8 or 16 bits), as bytes. - fn pattern(bits: u32) -> Vec { - let px16: [[u16; 3]; 16] = std::array::from_fn(|i| { - let v = (i as u16) * 4096; - [v, v.wrapping_add(20000), v.wrapping_add(40000)] - }); - let mut out = Vec::new(); - for px in px16 { - for s in px { - if bits == 16 { - out.extend_from_slice(&s.to_ne_bytes()); - } else { - out.push((s >> 8) as u8); - } - } - } - out - } - - /// Assert `data` is a complete JPEG (SOI … EOI). - fn assert_jpeg(data: &[u8]) { - assert!(data.len() > 4, "JPEG output too short"); - assert_eq!(&data[..2], &[0xFF, 0xD8], "missing SOI marker"); - assert_eq!(&data[data.len() - 2..], &[0xFF, 0xD9], "missing EOI marker"); - } - - #[test] - #[cfg_attr(miri, ignore)] // FFI into jpegli cannot run under Miri. - fn encodes_valid_jpeg_8bit() { - let data = encode(&pattern(8), 4, 4, 8, &JpegliEncodeParams::default()).expect("encode"); - assert_jpeg(&data); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn encodes_valid_jpeg_from_16bit_input() { - let data = encode(&pattern(16), 4, 4, 16, &JpegliEncodeParams::default()) - .expect("encode 16-bit input"); - assert_jpeg(&data); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn quality_xyb_and_subsampling_variants_encode() { - for xyb in [false, true] { - for subsampling in [ - Subsampling::Yuv420, - Subsampling::Yuv422, - Subsampling::Yuv444, - ] { - let params = JpegliEncodeParams { - quality: Some(80), - distance: 1.0, - progressive: false, - xyb, - subsampling, - }; - let data = encode(&pattern(8), 4, 4, 8, ¶ms) - .unwrap_or_else(|e| panic!("encode xyb={xyb} ss={subsampling:?}: {e}")); - assert_jpeg(&data); - } - } - } - - #[test] - #[cfg_attr(miri, ignore)] - fn distance_is_honoured_over_default() { - // A larger Butteraugli distance must not error and should not grow the file. - let low = encode( - &pattern(8), - 4, - 4, - 8, - &JpegliEncodeParams { - distance: 0.5, - ..JpegliEncodeParams::default() - }, - ) - .expect("encode d=0.5"); - let high = encode( - &pattern(8), - 4, - 4, - 8, - &JpegliEncodeParams { - distance: 6.0, - ..JpegliEncodeParams::default() - }, - ) - .expect("encode d=6.0"); - assert_jpeg(&low); - assert_jpeg(&high); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn rejects_wrong_buffer_length() { - let err = encode(&[0u8; 10], 4, 4, 8, &JpegliEncodeParams::default()); - assert!(err.is_err(), "short buffer must be rejected"); - } -} diff --git a/crates/rawshift-image/src/codecs/jpegli_shim.cc b/crates/rawshift-image/src/codecs/jpegli_shim.cc deleted file mode 100644 index 03f4570..0000000 --- a/crates/rawshift-image/src/codecs/jpegli_shim.cc +++ /dev/null @@ -1,153 +0,0 @@ -// C ABI shim over jpegli's libjpeg-style encode API. See jpegli_shim.h. -// -// All FFI/`unsafe` interaction with jpegli is confined to this file and -// `jpegli.rs` (the `src/codecs` safety boundary — see PRINCIPLES.md). jpegli's -// public headers are C++ (they `#include `), so this shim is compiled -// as C++; its exported functions use C linkage for a stable ABI. setjmp/longjmp -// is safe here because the guarded scope holds only POD locals. - -#include "jpegli_shim.h" - -#if defined(RAWSHIFT_JPEGLI_VENDORED) -// Vendored build: include from the jpegli source tree (headers use `lib/`-prefixed -// includes internally, so the source root is on the include path). -#include "lib/jpegli/encode.h" -#else -// System build (pkg-config libjpegli): the installed public header. -#include -#endif - -#include -#include -#include - -namespace { - -// jpegli/libjpeg error manager extended with a setjmp target + message buffer. -struct RawshiftErrorMgr { - struct jpeg_error_mgr base; - jmp_buf setjmp_buffer; - char message[JMSG_LENGTH_MAX]; -}; - -// Fatal-error hook: format the message and jump back to the setjmp point rather -// than letting jpegli's default handler call exit(). -void RawshiftErrorExit(j_common_ptr cinfo) { - RawshiftErrorMgr* err = reinterpret_cast(cinfo->err); - (*cinfo->err->format_message)(cinfo, err->message); - longjmp(err->setjmp_buffer, 1); -} - -// Swallow non-fatal trace/warning output so the library never writes to stderr. -void RawshiftEmitMessage(j_common_ptr /*cinfo*/, int /*msg_level*/) {} - -} // namespace - -extern "C" int rawshift_jpegli_encode(const RawshiftJpegliInput* in, - uint8_t** out, size_t* out_len, char* err, - size_t err_cap) { - *out = nullptr; - *out_len = 0; - - if (in->width == 0 || in->height == 0 || in->width > 65535 || - in->height > 65535) { - snprintf(err, err_cap, "invalid JPEG dimensions %ux%u (max 65535)", - in->width, in->height); - return 1; - } - const size_t bytes_per_sample = (in->bits_per_sample == 16) ? 2 : 1; - const size_t expected = - static_cast(in->width) * in->height * 3 * bytes_per_sample; - if (in->pixels_len != expected) { - snprintf(err, err_cap, - "pixel buffer length mismatch: expected %zu, got %zu", expected, - in->pixels_len); - return 1; - } - - struct jpeg_compress_struct cinfo; - struct RawshiftErrorMgr jerr; - unsigned char* buffer = nullptr; // jpegli_mem_dest allocates with malloc - unsigned long size = 0; - - cinfo.err = jpegli_std_error(&jerr.base); - jerr.base.error_exit = RawshiftErrorExit; - jerr.base.emit_message = RawshiftEmitMessage; - - // Any jpegli fatal error longjmps back here. - if (setjmp(jerr.setjmp_buffer)) { - snprintf(err, err_cap, "%s", jerr.message); - jpegli_destroy_compress(&cinfo); - if (buffer) free(buffer); - return 1; - } - - jpegli_create_compress(&cinfo); - jpegli_mem_dest(&cinfo, &buffer, &size); - - cinfo.image_width = in->width; - cinfo.image_height = in->height; - cinfo.input_components = 3; - cinfo.in_color_space = JCS_RGB; - - // `set_input_format` and `set_xyb_mode` must precede `set_defaults`. - if (in->bits_per_sample == 16) { - jpegli_set_input_format(&cinfo, JPEGLI_TYPE_UINT16, JPEGLI_NATIVE_ENDIAN); - } - if (in->xyb) { - jpegli_set_xyb_mode(&cinfo); - } - - jpegli_set_defaults(&cinfo); - - // Rate control must follow `set_defaults`. - if (in->use_distance) { - jpegli_set_distance(&cinfo, in->distance, FALSE); - } else { - jpegli_set_quality(&cinfo, in->quality, FALSE); - } - - jpegli_set_progressive_level(&cinfo, in->progressive ? 2 : 0); - - // Chroma subsampling (XYB mode picks its own). - if (!in->xyb) { - int h = 1, v = 1; - switch (in->subsampling) { - case RAWSHIFT_JPEGLI_SUBSAMPLE_420: - h = 2; - v = 2; - break; - case RAWSHIFT_JPEGLI_SUBSAMPLE_422: - h = 2; - v = 1; - break; - default: // 4:4:4 - break; - } - cinfo.comp_info[0].h_samp_factor = h; - cinfo.comp_info[0].v_samp_factor = v; - for (int c = 1; c < 3; ++c) { - cinfo.comp_info[c].h_samp_factor = 1; - cinfo.comp_info[c].v_samp_factor = 1; - } - } - - jpegli_start_compress(&cinfo, TRUE); - - const size_t row_stride = - static_cast(in->width) * 3 * bytes_per_sample; - while (cinfo.next_scanline < cinfo.image_height) { - JSAMPROW row = const_cast(reinterpret_cast( - in->pixels + static_cast(cinfo.next_scanline) * row_stride)); - jpegli_write_scanlines(&cinfo, &row, 1); - } - - jpegli_finish_compress(&cinfo); - jpegli_destroy_compress(&cinfo); - - *out = buffer; - *out_len = static_cast(size); - return 0; -} - -extern "C" void rawshift_jpegli_free(uint8_t* ptr) { free(ptr); } diff --git a/crates/rawshift-image/src/codecs/jpegli_shim.h b/crates/rawshift-image/src/codecs/jpegli_shim.h deleted file mode 100644 index 710e2f1..0000000 --- a/crates/rawshift-image/src/codecs/jpegli_shim.h +++ /dev/null @@ -1,62 +0,0 @@ -/* C ABI shim over jpegli's libjpeg-style encode API. - * - * jpegli (like libjpeg) signals fatal errors by calling `error_exit`, whose - * default implementation calls `exit()`. That cannot be driven safely from - * Rust, so the whole compress sequence lives here in C++ behind a `setjmp` - * guard and is exposed as a single return-code function — a clean - * `Result`-shaped boundary like the one libjxl's return-code C API provides. - * - * The implementation (jpegli_shim.cc) is the only place that includes jpegli's - * C++ headers; this header is self-contained (stdint/stddef only) so bindgen - * can process it without the jpegli include paths. - */ -#ifndef RAWSHIFT_JPEGLI_SHIM_H -#define RAWSHIFT_JPEGLI_SHIM_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* Chroma subsampling preset. Applied only in non-XYB mode; in XYB mode jpegli - * chooses its own sampling and this field is ignored. */ -typedef enum { - RAWSHIFT_JPEGLI_SUBSAMPLE_420 = 0, /* 4:2:0 */ - RAWSHIFT_JPEGLI_SUBSAMPLE_422 = 1, /* 4:2:2 */ - RAWSHIFT_JPEGLI_SUBSAMPLE_444 = 2 /* 4:4:4 */ -} RawshiftJpegliSubsampling; - -/* Fully-resolved encode request. `pixels` is interleaved RGB with no alpha: - * width*height*3 bytes for `bits_per_sample == 8`, or width*height*3*2 - * native-endian bytes for `bits_per_sample == 16`. */ -typedef struct { - const uint8_t* pixels; - size_t pixels_len; - uint32_t width; - uint32_t height; - uint32_t bits_per_sample; /* 8 or 16 */ - int use_distance; /* 1 => use `distance`; 0 => use `quality` */ - float distance; /* Butteraugli distance (used when use_distance) */ - int quality; /* 1..=100 (used when !use_distance) */ - int progressive; /* 0/1 */ - int xyb; /* 0/1 */ - int subsampling; /* RawshiftJpegliSubsampling */ -} RawshiftJpegliInput; - -/* Encode `in` to a JPEG. On success returns 0 and sets `*out` to a malloc'd - * buffer of `*out_len` bytes (free it with rawshift_jpegli_free). On failure - * returns non-zero and writes a NUL-terminated message into `err` (capacity - * `err_cap`); `*out` is left NULL. */ -int rawshift_jpegli_encode(const RawshiftJpegliInput* in, uint8_t** out, - size_t* out_len, char* err, size_t err_cap); - -/* Free a buffer returned by rawshift_jpegli_encode. */ -void rawshift_jpegli_free(uint8_t* ptr); - -#ifdef __cplusplus -} -#endif - -#endif /* RAWSHIFT_JPEGLI_SHIM_H */ diff --git a/crates/rawshift-image/src/codecs/mod.rs b/crates/rawshift-image/src/codecs/mod.rs index d3f9a5d..ad197ec 100644 --- a/crates/rawshift-image/src/codecs/mod.rs +++ b/crates/rawshift-image/src/codecs/mod.rs @@ -2,8 +2,6 @@ pub(crate) mod arw; #[cfg(any(feature = "arw-decode", feature = "cr2-decode", feature = "nef-decode"))] pub(crate) mod bit_pump; -#[cfg(feature = "jpeg-encode-jpegli")] -pub(crate) mod jpegli; // Lossless JPEG stays in-repo only for the CR2/NEF/ARW paths; the DNG path // uses gamut-dng's internal (and public) lossless-JPEG implementation. #[cfg(any(feature = "arw-decode", feature = "cr2-decode", feature = "nef-decode"))] diff --git a/crates/rawshift-image/src/error.rs b/crates/rawshift-image/src/error.rs index 7822db3..5f94b06 100644 --- a/crates/rawshift-image/src/error.rs +++ b/crates/rawshift-image/src/error.rs @@ -225,25 +225,9 @@ pub enum EncodeError { requested: BitDepth, }, - /// JPEG encoding error. - #[cfg(feature = "jpeg-encode")] - #[error("JPEG encoding error: {0}")] - Jpeg(#[from] jpeg_encoder::EncodingError), - /// WebP encoding error. #[error("WebP error: {0}")] WebP(String), - - /// JPEG (jpegli) encoding error. - #[error("jpegli error: {0}")] - Jpegli(String), -} - -#[cfg(feature = "jpeg-encode")] -impl From for RawError { - fn from(err: jpeg_encoder::EncodingError) -> Self { - RawError::Encode(EncodeError::Jpeg(err)) - } } /// Result type alias using RawError. diff --git a/crates/rawshift-image/src/formats/encode.rs b/crates/rawshift-image/src/formats/encode.rs index 5d8a536..d4072e7 100644 --- a/crates/rawshift-image/src/formats/encode.rs +++ b/crates/rawshift-image/src/formats/encode.rs @@ -38,9 +38,7 @@ pub fn encode_rgb_image_to_vec( #[cfg(feature = "png-encode")] EncodeOptions::PngGamut(cfg) => encode_png(image, metadata, cfg), #[cfg(feature = "jpeg-encode")] - EncodeOptions::JpegJpegEnc(cfg) => encode_jpeg(image, metadata, cfg), - #[cfg(feature = "jpeg-encode-jpegli")] - EncodeOptions::JpegJpegli(cfg) => encode_jpeg_jpegli(image, metadata, cfg), + EncodeOptions::Jpeg(cfg) => encode_jpeg(image, metadata, cfg), #[cfg(feature = "webp-encode")] EncodeOptions::WebpLibwebp(cfg) => encode_webp(image, metadata, cfg), #[cfg(feature = "avif-encode")] @@ -216,132 +214,75 @@ fn encode_png( fn encode_jpeg( image: &RgbImage, metadata: &ImageMetadata, - cfg: &super::export::JpegEncEncodeConfig, + cfg: &super::export::JpegEncodeConfig, ) -> RawResult> { + use super::export::{JpegDensityUnit, JpegSubsampling}; use crate::metadata::exif::ExifBuilder; use crate::metadata::icc::IccProfile; - use jpeg_encoder::{ColorType, Encoder}; - - check_8bit_backend(cfg.common.bit_depth, "JPEG")?; - let data_8bit = pack_rgb8(image); - - let quality = if cfg.quality == 0 { 90 } else { cfg.quality }; - let mut jpeg_buf = Vec::new(); - let encoder = Encoder::new(&mut jpeg_buf, quality); - encoder.encode( - &data_8bit, - image.width() as u16, - image.height() as u16, - ColorType::Rgb, - )?; - - let m = &cfg.common.metadata; - if m.embed_exif { - let exif_builder = ExifBuilder::new(metadata); - match exif_builder.append_to_jpeg(jpeg_buf.clone()) { - Ok(data) => jpeg_buf = data, - Err(e) => tracing::warn!("Failed to embed EXIF in JPEG: {e}"), - } - } - if m.embed_icc { - match IccProfile::srgb().append_to_jpeg(jpeg_buf.clone()) { - Ok(data) => jpeg_buf = data, - Err(e) => tracing::warn!("Failed to embed ICC in JPEG: {e}"), - } - } - if m.embed_xmp - && let Some(xmp_data) = &metadata.xmp - { - use crate::metadata::xmp::append_xmp_to_jpeg; - match append_xmp_to_jpeg(xmp_data, jpeg_buf.clone()) { - Ok(data) => jpeg_buf = data, - Err(e) => tracing::warn!("Failed to embed XMP in JPEG: {e}"), - } - } - - Ok(jpeg_buf) -} - -// ── JPEG (jpegli) ─────────────────────────────────────────────────────────────── - -#[cfg(feature = "jpeg-encode-jpegli")] -fn encode_jpeg_jpegli( - image: &RgbImage, - metadata: &ImageMetadata, - cfg: &super::export::JpegliEncodeConfig, -) -> RawResult> { - use super::export::JpegSubsampling; - use crate::codecs::jpegli::{self, JpegliEncodeParams, Subsampling}; - use crate::metadata::exif::ExifBuilder; - use crate::metadata::icc::IccProfile; - - // jpegli output is always an 8-bit JPEG, but it can quantise from 16-bit - // input to reduce banding. `Eight` is packed; `Sixteen` is passed through - // native-endian (matching the wrapper's `bits_per_sample == 16` contract); - // deeper requests are unsupported. - let (samples, bits_per_sample) = match cfg.common.bit_depth { - BitDepth::Eight => (pack_rgb8(image), 8u32), - BitDepth::Sixteen => { - let mut bytes = Vec::with_capacity(image.data().len() * 2); - for &sample in image.data() { - bytes.extend_from_slice(&sample.to_ne_bytes()); - } - (bytes, 16u32) - } - other => { - return Err(RawError::Encode(EncodeError::UnsupportedBitDepth { - format: "JPEG", - requested: other, - })); - } - }; + use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; + use gamut_jpeg::{ChromaSubsampling, DensityUnit, JpegEncoder}; - let params = JpegliEncodeParams { - quality: cfg.quality, - distance: cfg.distance, - progressive: cfg.progressive, - xyb: cfg.xyb, - subsampling: match cfg.subsampling { - JpegSubsampling::Yuv420 => Subsampling::Yuv420, - JpegSubsampling::Yuv422 => Subsampling::Yuv422, - JpegSubsampling::Yuv444 => Subsampling::Yuv444, - }, + let encoding_error = |e: gamut_core::Error| { + RawError::Encode(EncodeError::Encoding { + format: "JPEG", + message: format!("JPEG encoding error: {e}"), + }) }; - let mut jpeg_buf = jpegli::encode( - &samples, - image.width(), - image.height(), - bits_per_sample, - ¶ms, - ) - .map_err(|e| RawError::Encode(EncodeError::Jpegli(e)))?; + check_8bit_backend(cfg.common.bit_depth, "JPEG")?; + let data_8bit = pack_rgb8(image); - // Metadata embedding mirrors the `encode_jpeg` path exactly. + let dims = Dimensions::new(image.width(), image.height()).map_err(encoding_error)?; + let img = ImageRef::::new(&data_8bit, dims).map_err(encoding_error)?; + + let mut encoder = JpegEncoder::new() + .with_quality(cfg.quality) + .with_subsampling(match cfg.subsampling { + JpegSubsampling::Yuv420 => ChromaSubsampling::Ycbcr420, + JpegSubsampling::Yuv422 => ChromaSubsampling::Ycbcr422, + JpegSubsampling::Yuv444 => ChromaSubsampling::Ycbcr444, + }) + .with_progressive(cfg.progressive) + .with_restart_interval(cfg.restart_interval) + .with_density( + match cfg.density.unit { + JpegDensityUnit::AspectRatio => DensityUnit::AspectRatio, + JpegDensityUnit::Dpi => DensityUnit::Dpi, + JpegDensityUnit::Dpcm => DensityUnit::Dpcm, + }, + cfg.density.x, + cfg.density.y, + ); + + // Metadata is embedded by the encoder itself (APP1 EXIF, APP1 XMP, APP2 + // ICC_PROFILE segments), so it is configured up front — no post-hoc + // segment muxing. let m = &cfg.common.metadata; if m.embed_exif { - let exif_builder = ExifBuilder::new(metadata); - match exif_builder.append_to_jpeg(jpeg_buf.clone()) { - Ok(data) => jpeg_buf = data, + match ExifBuilder::new(metadata).build_bytes() { + Ok(bytes) => encoder = encoder.with_exif(&bytes), Err(e) => tracing::warn!("Failed to embed EXIF in JPEG: {e}"), } } if m.embed_icc { - match IccProfile::srgb().append_to_jpeg(jpeg_buf.clone()) { - Ok(data) => jpeg_buf = data, - Err(e) => tracing::warn!("Failed to embed ICC in JPEG: {e}"), - } + encoder = encoder.with_icc_profile(IccProfile::srgb().as_bytes()); } if m.embed_xmp && let Some(xmp_data) = &metadata.xmp { - use crate::metadata::xmp::append_xmp_to_jpeg; - match append_xmp_to_jpeg(xmp_data, jpeg_buf.clone()) { - Ok(data) => jpeg_buf = data, - Err(e) => tracing::warn!("Failed to embed XMP in JPEG: {e}"), + // Validate the packet with gamut-xmp before embedding, so a malformed + // payload is skipped (with a warning) instead of spliced into the + // output — the same contract the previous muxer enforced. + match gamut_xmp::XmpMeta::from_packet(xmp_data) { + Ok(_) => encoder = encoder.with_xmp(xmp_data), + Err(e) => tracing::warn!("Failed to embed XMP in JPEG (invalid packet): {e}"), } } + let mut jpeg_buf = Vec::new(); + encoder + .encode_image(img, &mut jpeg_buf) + .map_err(encoding_error)?; Ok(jpeg_buf) } diff --git a/crates/rawshift-image/src/formats/export.rs b/crates/rawshift-image/src/formats/export.rs index 6907586..ad30c43 100644 --- a/crates/rawshift-image/src/formats/export.rs +++ b/crates/rawshift-image/src/formats/export.rs @@ -10,8 +10,7 @@ //! (libjpeg-turbo, MozJPEG, SVT-AV1) can be added without a breaking //! change. Their configuration structs are already defined below — see //! [`MozjpegEncodeConfig`] and friends — so the API surface is stable ahead of -//! the implementations. The jpegli ([`JpegliEncodeConfig`]) backend is wired -//! up behind the `jpeg-encode-jpegli` feature. +//! the implementations. #[cfg(feature = "dng-encode")] use crate::formats::dng_export::DngEncodeConfig; @@ -196,22 +195,86 @@ pub struct PngEncodeConfig { pub auto_reduce: bool, } -/// Configuration for the `jpeg-encoder` (pure-Rust) JPEG encoder. +/// Pixel-density unit for the JFIF APP0 segment written by the JPEG encoder +/// (maps to `gamut_jpeg::DensityUnit`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum JpegDensityUnit { + /// No absolute unit; the densities express only the pixel aspect ratio. + #[default] + AspectRatio, + /// Dots per inch. + Dpi, + /// Dots per centimetre. + Dpcm, +} + +/// Pixel density written to the JFIF APP0 segment by the JPEG encoder. +/// +/// The default is a 1:1 aspect ratio with no absolute unit (JFIF `units = 0`), +/// matching gamut-jpeg's encoder default. Densities are clamped to be non-zero +/// at encode time, as T.871 §10.1 requires. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct JpegDensity { + /// The density unit. + pub unit: JpegDensityUnit, + /// Horizontal density. + pub x: u16, + /// Vertical density. + pub y: u16, +} + +impl Default for JpegDensity { + fn default() -> Self { + Self { + unit: JpegDensityUnit::AspectRatio, + x: 1, + y: 1, + } + } +} + +/// Configuration for the `gamut-jpeg` JPEG encoder (pure Rust — baseline or +/// progressive 8-bit DCT). +/// +/// Exposes exactly gamut-jpeg's encoder options: the `1..=100` quality dial +/// (frozen IJG quality→quantization mapping), chroma subsampling, the +/// progressive (SOF2) process, an optional restart interval, and the JFIF +/// pixel density. EXIF / ICC / XMP metadata is written by the encoder itself +/// as APP1/APP2 segments. Output is always an 8-bit JPEG. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct JpegEncEncodeConfig { +pub struct JpegEncodeConfig { /// Encoder-agnostic options. JPEG output is always 8-bit. pub common: CommonEncodeOptions, - /// Quality, `1..=100`. Higher is better quality and larger files (monotonic). - /// Default: `90`. + /// Quality, clamped to `1..=100` at encode time (matching libjpeg's + /// `jpeg_set_quality`). Higher is better quality and larger files + /// (monotonic). Default: `90`. pub quality: u8, + /// Chroma subsampling mode. Default: 4:2:0. + pub subsampling: JpegSubsampling, + /// Emit a progressive (SOF2, multi-scan) JPEG instead of baseline. The + /// decoded image is identical to the baseline encoding at the same + /// quality/subsampling; only the stream structure differs. Default: `false`. + pub progressive: bool, + /// Restart interval in MCUs: a restart marker (RSTn) is inserted every + /// this many MCUs, letting a decoder resynchronize after corruption. + /// `0` (the default) disables restarts. + pub restart_interval: u16, + /// JFIF pixel density written to the APP0 segment. + pub density: JpegDensity, } -impl Default for JpegEncEncodeConfig { +impl Default for JpegEncodeConfig { fn default() -> Self { Self { common: CommonEncodeOptions::default(), quality: 90, + subsampling: JpegSubsampling::Yuv420, + progressive: false, + restart_interval: 0, + density: JpegDensity::default(), } } } @@ -352,45 +415,6 @@ impl Default for JxlEncodeConfig { } } -/// Configuration for the **jpegli** JPEG encoder (libjxl's perceptual encoder). -/// -/// Unlike the pure-Rust default ([`JpegEncEncodeConfig`]), jpegli offers -/// Butteraugli-distance rate control, XYB high-fidelity mode, and quantises from -/// the source's full precision when fed 16-bit input. Output is always an 8-bit -/// JPEG. Requires the `jpeg-encode-jpegli` feature. -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct JpegliEncodeConfig { - /// Encoder-agnostic options. JPEG output is always 8-bit; with - /// `BitDepth::Sixteen`, 16-bit samples are fed to jpegli at full precision. - pub common: CommonEncodeOptions, - /// Butteraugli distance: `0.0` is visually lossless; higher values produce - /// smaller files (monotonic). Used when `quality` is `None`. - pub distance: f32, - /// Optional `1..=100` quality; when `Some`, overrides `distance`. - pub quality: Option, - /// Emit a progressive JPEG. - pub progressive: bool, - /// Encode in the XYB color space (jpegli high-fidelity mode). When set, - /// jpegli chooses its own chroma sampling and `subsampling` is ignored. - pub xyb: bool, - /// Chroma subsampling mode (non-XYB mode only). - pub subsampling: JpegSubsampling, -} - -impl Default for JpegliEncodeConfig { - fn default() -> Self { - Self { - common: CommonEncodeOptions::default(), - distance: 1.0, - quality: None, - progressive: true, - xyb: false, - subsampling: JpegSubsampling::Yuv420, - } - } -} - // ── Planned backend configs (API surface only — implementations pending) ────── // // These structs are defined now so the encode API is feature-complete and @@ -487,12 +511,9 @@ pub enum EncodeOptions { /// PNG via `gamut-png` (requires `png-encode`). #[cfg(feature = "png-encode")] PngGamut(PngEncodeConfig), - /// JPEG via the pure-Rust `jpeg-encoder` (requires `jpeg-encode`). + /// JPEG via `gamut-jpeg` (requires `jpeg-encode`). #[cfg(feature = "jpeg-encode")] - JpegJpegEnc(JpegEncEncodeConfig), - /// JPEG via `jpegli`, libjxl's perceptual encoder (requires `jpeg-encode-jpegli`). - #[cfg(feature = "jpeg-encode-jpegli")] - JpegJpegli(JpegliEncodeConfig), + Jpeg(JpegEncodeConfig), /// WebP via `libwebp` (requires `webp-encode`). #[cfg(feature = "webp-encode")] WebpLibwebp(LibwebpEncodeConfig), @@ -525,13 +546,7 @@ impl EncodeOptions { /// JPEG with default configuration. #[cfg(feature = "jpeg-encode")] pub fn jpeg() -> Self { - Self::JpegJpegEnc(JpegEncEncodeConfig::default()) - } - - /// JPEG via the jpegli encoder, with default configuration. - #[cfg(feature = "jpeg-encode-jpegli")] - pub fn jpeg_jpegli() -> Self { - Self::JpegJpegli(JpegliEncodeConfig::default()) + Self::Jpeg(JpegEncodeConfig::default()) } /// Lossy WebP with default configuration. @@ -570,9 +585,7 @@ impl EncodeOptions { #[cfg(feature = "png-encode")] EncodeOptions::PngGamut(_) => OutputFormat::Png, #[cfg(feature = "jpeg-encode")] - EncodeOptions::JpegJpegEnc(_) => OutputFormat::Jpeg, - #[cfg(feature = "jpeg-encode-jpegli")] - EncodeOptions::JpegJpegli(_) => OutputFormat::Jpeg, + EncodeOptions::Jpeg(_) => OutputFormat::Jpeg, #[cfg(feature = "webp-encode")] EncodeOptions::WebpLibwebp(_) => OutputFormat::WebP, #[cfg(feature = "avif-encode")] @@ -594,9 +607,7 @@ impl EncodeOptions { #[cfg(feature = "png-encode")] EncodeOptions::PngGamut(_) => CodecId::new("png/gamut"), #[cfg(feature = "jpeg-encode")] - EncodeOptions::JpegJpegEnc(_) => CodecId::new("jpeg/jpeg-encoder"), - #[cfg(feature = "jpeg-encode-jpegli")] - EncodeOptions::JpegJpegli(_) => CodecId::new("jpeg/jpegli"), + EncodeOptions::Jpeg(_) => CodecId::new("jpeg/gamut"), #[cfg(feature = "webp-encode")] EncodeOptions::WebpLibwebp(_) => CodecId::new("webp/libwebp"), #[cfg(feature = "avif-encode")] @@ -619,9 +630,7 @@ impl EncodeOptions { #[cfg(feature = "png-encode")] EncodeOptions::PngGamut(c) => c.common, #[cfg(feature = "jpeg-encode")] - EncodeOptions::JpegJpegEnc(c) => c.common, - #[cfg(feature = "jpeg-encode-jpegli")] - EncodeOptions::JpegJpegli(c) => c.common, + EncodeOptions::Jpeg(c) => c.common, #[cfg(feature = "webp-encode")] EncodeOptions::WebpLibwebp(c) => c.common, #[cfg(feature = "avif-encode")] @@ -674,7 +683,7 @@ mod tests { fn default_for_jpeg_is_jpeg() { let opts = EncodeOptions::default_for(StandardFormat::Jpeg).unwrap(); assert_eq!(opts.format(), OutputFormat::Jpeg); - assert_eq!(opts.codec_id().id, "jpeg/jpeg-encoder"); + assert_eq!(opts.codec_id().id, "jpeg/gamut"); } #[test] diff --git a/crates/rawshift-image/src/formats/mod.rs b/crates/rawshift-image/src/formats/mod.rs index 8b9d5ae..873623e 100644 --- a/crates/rawshift-image/src/formats/mod.rs +++ b/crates/rawshift-image/src/formats/mod.rs @@ -36,8 +36,8 @@ pub use heic::{HeicAuxImage, HeicAuxKind, HeicFile, heic_hw_decode_available}; pub use registry::{available_decoders, available_encoders}; pub use standard::{ DecodeOptions, GifDecodeConfig, HeicDecodeConfig, ImageAvifDecodeConfig, ImageProbe, - JxlDecodeConfig, LibwebpDecodeConfig, ResvgDecodeConfig, StandardFormat, TiffDecodeConfig, - ZuneJpegDecodeConfig, ZunePngDecodeConfig, ZunePpmDecodeConfig, decode_standard_image, + JpegDecodeConfig, JxlDecodeConfig, LibwebpDecodeConfig, ResvgDecodeConfig, StandardFormat, + TiffDecodeConfig, ZunePngDecodeConfig, ZunePpmDecodeConfig, decode_standard_image, decode_standard_image_with, detect_standard_format, probe_standard_image, read_standard_image_metadata, }; diff --git a/crates/rawshift-image/src/formats/registry.rs b/crates/rawshift-image/src/formats/registry.rs index 7810cea..2ea4225 100644 --- a/crates/rawshift-image/src/formats/registry.rs +++ b/crates/rawshift-image/src/formats/registry.rs @@ -34,16 +34,12 @@ pub fn available_encoders() -> Vec { "0.1", CodecDirection::Encode, )); + // Version tracks the gamut-jpeg crate at the pinned gamut commit (git + // dependency). Hand-maintained — bump together with the gamut pin. #[cfg(feature = "jpeg-encode")] encoders.push(CodecInfo::new( - CodecId::new("jpeg/jpeg-encoder"), - "0.7", - CodecDirection::Encode, - )); - #[cfg(feature = "jpeg-encode-jpegli")] - encoders.push(CodecInfo::new( - CodecId::new("jpeg/jpegli"), - "0.11", + CodecId::new("jpeg/gamut"), + "0.1", CodecDirection::Encode, )); #[cfg(feature = "webp-encode")] @@ -83,10 +79,12 @@ pub fn available_encoders() -> Vec { pub fn available_decoders() -> Vec { #[allow(unused_mut)] let mut decoders: Vec = Vec::new(); + // Version tracks the gamut-jpeg crate at the pinned gamut commit (git + // dependency). Hand-maintained — bump together with the gamut pin. #[cfg(feature = "jpeg-decode")] decoders.push(CodecInfo::new( - CodecId::new("jpeg/zune"), - "0.5", + CodecId::new("jpeg/gamut"), + "0.1", CodecDirection::Decode, )); #[cfg(feature = "png-decode")] diff --git a/crates/rawshift-image/src/formats/standard.rs b/crates/rawshift-image/src/formats/standard.rs index 7434505..11718e1 100644 --- a/crates/rawshift-image/src/formats/standard.rs +++ b/crates/rawshift-image/src/formats/standard.rs @@ -363,41 +363,56 @@ fn decode_gif(data: &[u8]) -> RawResult { // ── JPEG ───────────────────────────────────────────────────────────────────── +/// `(v * k) / 255` with correct rounding — the multiplicative K application +/// used for CMYK→RGB (borrowed from stb via zune-jpeg, kept for bit-exact +/// parity with the previous backend). #[cfg(feature = "jpeg-decode")] -fn decode_jpeg(data: &[u8], cfg: &ZuneJpegDecodeConfig) -> RawResult { - let mut opts = DecoderOptions::default() - .jpeg_set_out_colorspace(ColorSpace::RGB) - .set_strict_mode(cfg.strict); - if let Some(w) = cfg.max_width { - opts = opts.set_max_width(w); - } - if let Some(h) = cfg.max_height { - opts = opts.set_max_height(h); - } - let cursor = ZCursor::new(data); - let mut decoder = zune_jpeg::JpegDecoder::new_with_options(cursor, opts); +#[inline] +fn blinn_8x8(v: u8, k: u8) -> u8 { + let t = i32::from(v) * i32::from(k) + 128; + ((t + (t >> 8)) >> 8) as u8 +} + +#[cfg(feature = "jpeg-decode")] +fn decode_jpeg(data: &[u8], _cfg: &JpegDecodeConfig) -> RawResult { + use gamut_core::{Cmyk8, DecodeImage, ImageBuf, Rgb8}; + use gamut_jpeg::JpegDecoder; - let pixels = decoder.decode().map_err(|e| { + let jpeg_err = |e: gamut_core::Error| { RawError::Format(FormatError::ImageDecode { format: "JPEG", - message: format!("{e:?}"), + message: e.to_string(), }) - })?; + }; - let (w, h) = decoder - .dimensions() - .map(|(w, h)| (w as u32, h as u32)) - .ok_or_else(|| { - RawError::Format(FormatError::ImageDecode { - format: "JPEG", - message: "could not read image dimensions after decode".to_string(), + // gamut-jpeg presents four-component (Adobe CMYK/YCCK) streams only as + // `Cmyk8`; everything else — grayscale (replicated), YCbCr, RGB — decodes + // directly as `Rgb8`. The header-only `info` read selects the path. + let info = gamut_jpeg::info(data).map_err(jpeg_err)?; + if info.components == 4 { + let decoded: ImageBuf = JpegDecoder::new().decode_image(data).map_err(jpeg_err)?; + let dims = decoded.dimensions(); + // CMYK→RGB in the Adobe-inverted convention (matching libjpeg and the + // previous zune-jpeg backend): R = C·K/255, G = M·K/255, B = Y·K/255 + // on the stored sample values. + let data_u16: Vec = decoded + .as_samples() + .chunks_exact(4) + .flat_map(|px| { + [ + u8_to_u16(blinn_8x8(px[0], px[3])), + u8_to_u16(blinn_8x8(px[1], px[3])), + u8_to_u16(blinn_8x8(px[2], px[3])), + ] }) - })?; - - // pixels is Vec, RGB interleaved — scale to u16 - let data_u16: Vec = pixels.iter().map(|&v| u8_to_u16(v)).collect(); + .collect(); + return RgbImage::new(dims.width, dims.height, data_u16); + } - RgbImage::new(w, h, data_u16) + let decoded: ImageBuf = JpegDecoder::new().decode_image(data).map_err(jpeg_err)?; + let dims = decoded.dimensions(); + let data_u16: Vec = decoded.as_samples().iter().map(|&v| u8_to_u16(v)).collect(); + RgbImage::new(dims.width, dims.height, data_u16) } // ── PNG ────────────────────────────────────────────────────────────────────── @@ -802,21 +817,6 @@ fn decode_ppm(data: &[u8], _cfg: &ZunePpmDecodeConfig) -> RawResult { // ── Decoder implementation selection ────────────────────────────────────────── -/// Per-implementation configuration for the `zune-jpeg` JPEG decoder. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct ZuneJpegDecodeConfig { - /// Reject images wider than this, in pixels. `None` keeps the decoder's - /// built-in limit. - pub max_width: Option, - /// Reject images taller than this, in pixels. `None` keeps the decoder's - /// built-in limit. - pub max_height: Option, - /// Reject streams that deviate from the JPEG specification instead of - /// attempting recovery. Default: `false`. - pub strict: bool, -} - /// Per-implementation configuration for the `zune-png` PNG decoder. #[derive(Debug, Clone, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -865,6 +865,7 @@ macro_rules! empty_decode_config { }; } +empty_decode_config!(JpegDecodeConfig, "gamut-jpeg"); empty_decode_config!(LibwebpDecodeConfig, "libwebp"); empty_decode_config!(JxlDecodeConfig, "gamut-jxl"); empty_decode_config!(GifDecodeConfig, "gif"); @@ -888,9 +889,10 @@ empty_decode_config!(ZunePpmDecodeConfig, "zune-ppm"); #[non_exhaustive] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum DecodeOptions { - /// JPEG via `zune-jpeg`. + /// JPEG via `gamut-jpeg` (baseline + progressive; grayscale/YCbCr/RGB/ + /// CMYK/YCCK). #[cfg(feature = "jpeg-decode")] - JpegZune(ZuneJpegDecodeConfig), + Jpeg(JpegDecodeConfig), /// PNG via `zune-png`. #[cfg(feature = "png-decode")] PngZune(ZunePngDecodeConfig), @@ -926,7 +928,7 @@ impl DecodeOptions { pub fn format(&self) -> StandardFormat { match self { #[cfg(feature = "jpeg-decode")] - DecodeOptions::JpegZune(_) => StandardFormat::Jpeg, + DecodeOptions::Jpeg(_) => StandardFormat::Jpeg, #[cfg(feature = "png-decode")] DecodeOptions::PngZune(_) => StandardFormat::Png, #[cfg(feature = "webp-decode")] @@ -956,7 +958,7 @@ impl DecodeOptions { pub fn codec_id(&self) -> CodecId { match self { #[cfg(feature = "jpeg-decode")] - DecodeOptions::JpegZune(_) => CodecId::new("jpeg/zune"), + DecodeOptions::Jpeg(_) => CodecId::new("jpeg/gamut"), #[cfg(feature = "png-decode")] DecodeOptions::PngZune(_) => CodecId::new("png/zune"), #[cfg(feature = "webp-decode")] @@ -987,7 +989,7 @@ impl DecodeOptions { pub fn default_for(format: StandardFormat) -> Option { match format { #[cfg(feature = "jpeg-decode")] - StandardFormat::Jpeg => Some(DecodeOptions::JpegZune(ZuneJpegDecodeConfig::default())), + StandardFormat::Jpeg => Some(DecodeOptions::Jpeg(JpegDecodeConfig::default())), #[cfg(feature = "png-decode")] StandardFormat::Png => Some(DecodeOptions::PngZune(ZunePngDecodeConfig::default())), #[cfg(feature = "webp-decode")] @@ -1037,7 +1039,7 @@ pub fn decode_standard_image(data: &[u8], format: StandardFormat) -> RawResult decode_gif(data), #[cfg(feature = "jpeg-decode")] - StandardFormat::Jpeg => decode_jpeg(data, &ZuneJpegDecodeConfig::default()), + StandardFormat::Jpeg => decode_jpeg(data, &JpegDecodeConfig::default()), #[cfg(feature = "png-decode")] StandardFormat::Png => decode_png(data, &ZunePngDecodeConfig::default()), #[cfg(feature = "webp-decode")] @@ -1074,7 +1076,7 @@ pub fn decode_standard_image(data: &[u8], format: StandardFormat) -> RawResult RawResult { let decoded: RawResult = match options { #[cfg(feature = "jpeg-decode")] - DecodeOptions::JpegZune(cfg) => decode_jpeg(data, cfg), + DecodeOptions::Jpeg(cfg) => decode_jpeg(data, cfg), #[cfg(feature = "png-decode")] DecodeOptions::PngZune(cfg) => decode_png(data, cfg), #[cfg(feature = "webp-decode")] @@ -1207,7 +1209,27 @@ fn probe_png(data: &[u8]) -> RawResult<(Dimensions, Option)> { Ok((Dimensions { width, height }, Some(data[24]))) } +/// JPEG: read the frame header (gamut-jpeg's header-only `info` — no entropy +/// decoding). +#[cfg(any(feature = "jpeg-decode", feature = "jpeg-encode"))] +fn probe_jpeg(data: &[u8]) -> RawResult<(Dimensions, Option)> { + let info = gamut_jpeg::info(data).map_err(|e| probe_err("JPEG", e.to_string()))?; + Ok(( + Dimensions { + width: info.width, + height: info.height, + }, + Some(info.precision), + )) +} + /// JPEG: scan marker segments for a Start-Of-Frame (SOFn) marker. +/// +/// Fallback for builds without gamut-jpeg (no `jpeg-decode`/`jpeg-encode` +/// feature): probing works for every raster format regardless of which +/// decoders are compiled in, so a hand-rolled SOF scan is kept for this +/// configuration only. +#[cfg(not(any(feature = "jpeg-decode", feature = "jpeg-encode")))] fn probe_jpeg(data: &[u8]) -> RawResult<(Dimensions, Option)> { let mut i = 2; // skip the SOI marker while i + 1 < data.len() { @@ -1493,7 +1515,7 @@ mod probe_tests { /// # Supported formats /// | Format | Metadata source | /// |--------|----------------| -/// | JPEG | APP1 EXIF segment | +/// | JPEG | APP1 EXIF + APP1 XMP + APP2 ICC segments (requires `jpeg`) | /// | TIFF | IFD0 EXIF tags | /// | WebP | EXIF chunk | /// | AVIF | HEIF/ISOBMFF Exif item | @@ -1516,19 +1538,50 @@ pub fn read_standard_image_metadata( return crate::formats::heic::read_heic_metadata(data); } + // JPEG goes through gamut-jpeg's APP-segment reader (no pixel decoding) so + // that ICC and XMP are extracted alongside EXIF. + #[cfg(any(feature = "jpeg-decode", feature = "jpeg-encode"))] + if format == StandardFormat::Jpeg { + return read_jpeg_metadata(data); + } + let container = match format { - StandardFormat::Jpeg => ExifContainer::Jpeg, StandardFormat::Tiff => ExifContainer::Tiff, StandardFormat::WebP => ExifContainer::WebP, StandardFormat::Avif => ExifContainer::Avif, StandardFormat::Png => ExifContainer::Png, - // Formats without an EXIF extraction path (GIF, SVG, JXL, …) + // Formats without an EXIF extraction path (GIF, SVG, JXL, …), and + // JPEG when neither `jpeg-decode` nor `jpeg-encode` compiled the + // gamut-jpeg metadata reader in. _ => return crate::core::metadata::ImageMetadata::default(), }; ExifParser::parse_from_bytes(data, container) } +/// Extract EXIF / ICC / XMP from a JPEG's APP segments via `gamut-jpeg`. +/// +/// Returns default (empty) metadata when the stream is malformed or carries +/// none of the three payloads. +#[cfg(all( + feature = "exif", + any(feature = "jpeg-decode", feature = "jpeg-encode") +))] +fn read_jpeg_metadata(data: &[u8]) -> crate::core::metadata::ImageMetadata { + use crate::metadata::exif::ExifParser; + + let Ok(meta) = gamut_jpeg::metadata(data) else { + return crate::core::metadata::ImageMetadata::default(); + }; + let mut md = match &meta.exif { + Some(blob) => ExifParser::parse_exif_blob(blob), + None => crate::core::metadata::ImageMetadata::default(), + }; + md.icc_profile = meta.icc; + md.xmp = meta.xmp; + md +} + /// Extract EXIF metadata from a standard image without decoding pixel data. /// /// This is the `exif`-feature-disabled build: the crate carries no EXIF parser, @@ -1683,26 +1736,30 @@ mod tests { // ── JPEG roundtrip ──────────────────────────────────────────────────── + #[cfg(all(feature = "jpeg-decode", feature = "jpeg-encode"))] #[test] fn jpeg_roundtrip_dimensions() { + use gamut_core::{Dimensions as GDimensions, EncodeImage, ImageRef, Rgb8}; + // Encode a 4x4 RGB image to JPEG, then decode it and check dimensions. - const W: u16 = 4; - const H: u16 = 4; + const W: u32 = 4; + const H: u32 = 4; let pixels: Vec = (0..(W as usize * H as usize * 3)) .map(|i| (i * 17 % 256) as u8) .collect(); + let img = ImageRef::::new(&pixels, GDimensions::new(W, H).unwrap()).unwrap(); let mut encoded = Vec::new(); - let encoder = jpeg_encoder::Encoder::new(&mut encoded, 95); - encoder - .encode(&pixels, W, H, jpeg_encoder::ColorType::Rgb) + gamut_jpeg::JpegEncoder::new() + .with_quality(95) + .encode_image(img, &mut encoded) .expect("JPEG encode failed"); let decoded = decode_standard_image(&encoded, StandardFormat::Jpeg).expect("JPEG decode failed"); - assert_eq!(decoded.width(), W as u32); - assert_eq!(decoded.height(), H as u32); + assert_eq!(decoded.width(), W); + assert_eq!(decoded.height(), H); assert_eq!(decoded.data().len(), W as usize * H as usize * 3); } @@ -1783,23 +1840,28 @@ mod tests { // ── detect + decode consistency ─────────────────────────────────────── + #[cfg(all(feature = "jpeg-decode", feature = "jpeg-encode"))] #[test] fn detect_then_decode_jpeg() { - const W: u16 = 2; - const H: u16 = 2; + use gamut_core::{Dimensions as GDimensions, EncodeImage, ImageRef, Rgb8}; + + const W: u32 = 2; + const H: u32 = 2; let pixels = vec![ 100u8, 150u8, 200u8, 50u8, 75u8, 100u8, 200u8, 220u8, 240u8, 10u8, 20u8, 30u8, ]; + let img = ImageRef::::new(&pixels, GDimensions::new(W, H).unwrap()).unwrap(); let mut encoded = Vec::new(); - jpeg_encoder::Encoder::new(&mut encoded, 90) - .encode(&pixels, W, H, jpeg_encoder::ColorType::Rgb) + gamut_jpeg::JpegEncoder::new() + .with_quality(90) + .encode_image(img, &mut encoded) .unwrap(); let fmt = detect_standard_format(&encoded); assert_eq!(fmt, Some(StandardFormat::Jpeg)); let img = decode_standard_image(&encoded, fmt.unwrap()).unwrap(); - assert_eq!(img.width(), W as u32); - assert_eq!(img.height(), H as u32); + assert_eq!(img.width(), W); + assert_eq!(img.height(), H); } // ── PPM detect + decode ─────────────────────────────────────────────── diff --git a/crates/rawshift-image/src/lib.rs b/crates/rawshift-image/src/lib.rs index 2e1b061..67d2261 100644 --- a/crates/rawshift-image/src/lib.rs +++ b/crates/rawshift-image/src/lib.rs @@ -69,9 +69,10 @@ //! for that format). //! 3. **Directions** — `jpeg-decode`, `jpeg-encode`, `arw-decode`, … For //! compressed formats a direction feature aliases the **default** -//! implementation; RAW formats have a single in-repo implementation. +//! implementation; RAW formats (and the gamut-backed JPEG halves) have a +//! single implementation. //! 4. **Implementations** — compressed formats only, named -//! `format-direction-impl` (e.g. `jpeg-decode-zune`). Multiple may be enabled +//! `format-direction-impl` (e.g. `png-decode-zune`). Multiple may be enabled //! at once; the active backend is chosen via [`formats::DecodeOptions`] and //! [`formats::export::EncodeOptions`]. //! 5. **Infrastructure** — `ifd-parser`, `serde`, and the verified hardware diff --git a/crates/rawshift-image/src/metadata/exif.rs b/crates/rawshift-image/src/metadata/exif.rs index 84f30da..235c656 100644 --- a/crates/rawshift-image/src/metadata/exif.rs +++ b/crates/rawshift-image/src/metadata/exif.rs @@ -2,12 +2,14 @@ //! //! Builds and parses EXIF blobs with `gamut-exif` (the upstream home for the //! EXIF model — see the Upstream-First Policy) and converts them to and from -//! [`ImageMetadata`]. Container-level concerns stay on this side for now: -//! JPEG APP1 embedding goes through `img-parts`, AVIF/JXL embedding through -//! the crate's ISOBMFF box splicing ([`crate::metadata::isobmff`]), and the -//! decode-side blob *location* (APP1 segment, `eXIf` chunk, `EXIF` chunk, -//! `Exif` item) is scanned here. All of that container surgery migrates behind -//! the gamut codec boundaries with the per-format codec issues. +//! [`ImageMetadata`]. Container-level concerns stay on this side only for the +//! formats whose codec has not yet migrated to gamut: AVIF embedding goes +//! through the crate's ISOBMFF box splicing ([`crate::metadata::isobmff`]), +//! and the decode-side blob *location* (`eXIf` chunk, `EXIF` chunk, `Exif` +//! item) is scanned here. JPEG APP segments are read and written by +//! `gamut-jpeg` itself (`gamut_jpeg::metadata` / `JpegEncoder::with_exif`); +//! the remaining container surgery migrates behind the gamut codec boundaries +//! with the per-format codec issues. use crate::core::metadata::ImageMetadata; use gamut_exif::{ByteOrder, Exif, ExifTag, ExifWriter, Ifd, Value}; @@ -32,13 +34,6 @@ impl std::fmt::Display for ExifError { impl std::error::Error for ExifError {} -#[cfg(feature = "container-embed")] -impl From for ExifError { - fn from(e: img_parts::Error) -> Self { - ExifError::Container(e.to_string()) - } -} - impl From for ExifError { fn from(e: std::io::Error) -> Self { ExifError::Container(e.to_string()) @@ -224,9 +219,9 @@ impl<'a> ExifBuilder<'a> { /// Build raw TIFF-level EXIF bytes (no APP1 wrapper or `Exif\0\0` prefix). /// - /// These bytes can be passed directly to `img_parts::ImageEXIF::set_exif()` - /// for JPEG and PNG embedding (img_parts handles the format-specific - /// wrapping). For WebP, prepend `b"Exif\0\0"` before passing to the muxer. + /// These bytes are the form the gamut encoders take (`with_exif` on the + /// JPEG/PNG/JXL encoders wraps them format-specifically). For WebP, + /// prepend `b"Exif\0\0"` before passing to the muxer. pub fn build_bytes(&self) -> Result, ExifError> { let exif = self.build(); ExifWriter::new() @@ -235,24 +230,6 @@ impl<'a> ExifBuilder<'a> { .map_err(|e| ExifError::Serialization(e.to_string())) } - /// Append EXIF metadata to existing JPEG data. - /// - /// Uses img-parts for zero-copy segment manipulation. - #[cfg(feature = "container-embed")] - pub fn append_to_jpeg(&self, jpeg_data: Vec) -> Result, ExifError> { - use img_parts::jpeg::Jpeg; - use img_parts::{Bytes, ImageEXIF}; - use std::io::Cursor; - - let tiff_bytes = self.build_bytes()?; - let mut jpeg = Jpeg::from_bytes(Bytes::from(jpeg_data))?; - jpeg.set_exif(Some(Bytes::from(tiff_bytes))); - - let mut output = Cursor::new(Vec::new()); - jpeg.encoder().write_to(&mut output)?; - Ok(output.into_inner()) - } - /// Append EXIF metadata to an in-memory AVIF byte stream. /// /// AVIF uses the HEIF/ISOBMFF container; the EXIF payload is stored as an @@ -281,8 +258,6 @@ impl<'a> ExifBuilder<'a> { /// carries the EXIF TIFF stream. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExifContainer { - /// JPEG — APP1 `Exif\0\0` segment. - Jpeg, /// PNG — `eXIf` chunk. Png, /// TIFF — the whole file is the TIFF stream. @@ -306,7 +281,6 @@ impl ExifParser { /// the container/blob is malformed. pub fn parse_from_bytes(file_data: &[u8], container: ExifContainer) -> ImageMetadata { let blob = match container { - ExifContainer::Jpeg => extract_exif_from_jpeg(file_data), ExifContainer::Png => extract_exif_from_png(file_data), ExifContainer::Tiff => Some(file_data.to_vec()), ExifContainer::WebP => extract_exif_from_webp(file_data), @@ -604,45 +578,8 @@ fn exif_value_to_metadata(value: &Value) -> crate::core::metadata::MetadataValue // // These scanners only *locate* the EXIF payload inside a container; parsing is // gamut-exif's job. They migrate behind the gamut codec boundaries (codec-side -// `MetadataBlock`) with the per-format codec migrations. - -/// Extract the EXIF payload of the first JPEG APP1 `Exif\0\0` segment. -fn extract_exif_from_jpeg(data: &[u8]) -> Option> { - const EXIF_MARKER: &[u8] = b"Exif\x00\x00"; - if data.get(..2) != Some(&[0xFF, 0xD8]) { - return None; - } - let mut pos = 2usize; - loop { - // Tolerate fill bytes between segments. - while *data.get(pos)? == 0xFF && data.get(pos + 1) == Some(&0xFF) { - pos += 1; - } - if *data.get(pos)? != 0xFF { - return None; - } - let marker = *data.get(pos + 1)?; - match marker { - // Standalone markers (no length field). - 0xD8 | 0x01 | 0xD0..=0xD7 => { - pos += 2; - continue; - } - // Start of scan / end of image: no EXIF ahead of the entropy data. - 0xDA | 0xD9 => return None, - _ => {} - } - let len = u16::from_be_bytes([*data.get(pos + 2)?, *data.get(pos + 3)?]) as usize; - if len < 2 { - return None; - } - let payload = data.get(pos + 4..pos + 2 + len)?; - if marker == 0xE1 && payload.starts_with(EXIF_MARKER) { - return Some(payload.to_vec()); - } - pos += 2 + len; - } -} +// `MetadataBlock`) with the per-format codec migrations. (JPEG already did: +// `gamut_jpeg::metadata` locates and strips the APP1/APP2 payloads.) /// Extract the payload of a PNG `eXIf` chunk (a bare TIFF stream). fn extract_exif_from_png(data: &[u8]) -> Option> { @@ -850,30 +787,11 @@ mod tests { ImageMetadata::default() ); assert_eq!( - ExifParser::parse_from_bytes(b"\x00\x01\x02\x03", ExifContainer::Jpeg), + ExifParser::parse_from_bytes(b"\x00\x01\x02\x03", ExifContainer::Png), ImageMetadata::default() ); } - #[test] - fn test_extract_from_jpeg_app1() { - // Wrap the built EXIF blob (marker included) in a minimal JPEG. - let md = sample_metadata(); - let exif = ExifBuilder::new(&md).build(); - let blob = exif.to_bytes().expect("blob with marker"); - - let mut jpeg = Vec::new(); - jpeg.extend_from_slice(&[0xFF, 0xD8]); // SOI - jpeg.extend_from_slice(&[0xFF, 0xE1]); // APP1 - jpeg.extend_from_slice(&((blob.len() + 2) as u16).to_be_bytes()); - jpeg.extend_from_slice(&blob); - jpeg.extend_from_slice(&[0xFF, 0xD9]); // EOI - - let parsed = ExifParser::parse_from_bytes(&jpeg, ExifContainer::Jpeg); - assert_eq!(parsed.camera.make, "SONY"); - assert_eq!(parsed.exif.iso, Some(800)); - } - #[test] fn test_extract_from_png_exif_chunk() { let md = sample_metadata(); diff --git a/crates/rawshift-image/src/metadata/icc.rs b/crates/rawshift-image/src/metadata/icc.rs index 4a07419..2809cc9 100644 --- a/crates/rawshift-image/src/metadata/icc.rs +++ b/crates/rawshift-image/src/metadata/icc.rs @@ -1,10 +1,10 @@ //! ICC profile handling for image export. //! //! The profile bytes themselves are built and validated with `gamut-icc` (the -//! upstream home for ICC parsing/serialization); the container embedding paths -//! (JPEG APP2 via `img-parts`, AVIF box splicing) stay on this side until the -//! per-format codec migrations move them behind the gamut codec boundaries -//! (PNG and JXL already embed through their gamut encoders). +//! upstream home for ICC parsing/serialization); the AVIF box-splicing +//! embedding path stays on this side until the per-format codec migrations +//! move it behind the gamut codec boundaries (JPEG, PNG, and JXL already +//! embed through their gamut encoders). use crate::metadata::isobmff::{find_box, patch_iloc_extents, read_u32_be, write_u32_be}; @@ -29,13 +29,6 @@ impl std::fmt::Display for IccError { impl std::error::Error for IccError {} -#[cfg(feature = "container-embed")] -impl From for IccError { - fn from(e: img_parts::Error) -> Self { - IccError::Container(e.to_string()) - } -} - impl From for IccError { fn from(e: std::io::Error) -> Self { IccError::Container(e.to_string()) @@ -80,23 +73,6 @@ impl IccProfile { gamut_icc::IccProfile::parse(&self.data).is_ok() } - /// Append ICC profile to existing JPEG data. - /// - /// Uses img-parts for segment manipulation. - #[cfg(feature = "container-embed")] - pub fn append_to_jpeg(&self, jpeg_data: Vec) -> Result, IccError> { - use img_parts::jpeg::Jpeg; - use img_parts::{Bytes, ImageICC}; - use std::io::Cursor; - - let mut jpeg = Jpeg::from_bytes(Bytes::from(jpeg_data))?; - jpeg.set_icc_profile(Some(Bytes::from(self.data.clone()))); - - let mut output = Cursor::new(Vec::new()); - jpeg.encoder().write_to(&mut output)?; - Ok(output.into_inner()) - } - /// Embed ICC profile into AVIF (ISOBMFF) data. /// /// Replaces or inserts a `colr rICC` box in `meta/iprp/ipco` and patches diff --git a/crates/rawshift-image/src/metadata/mod.rs b/crates/rawshift-image/src/metadata/mod.rs index 3809769..70c42a5 100644 --- a/crates/rawshift-image/src/metadata/mod.rs +++ b/crates/rawshift-image/src/metadata/mod.rs @@ -10,5 +10,7 @@ pub mod bridge; pub mod exif; pub mod icc; pub(crate) mod isobmff; -#[cfg(feature = "container-embed")] +// XMP box splicing is only needed by the AVIF encode path — JPEG, PNG, and +// JXL embed XMP through their gamut encoders. +#[cfg(feature = "avif-encode")] pub mod xmp; diff --git a/crates/rawshift-image/src/metadata/xmp.rs b/crates/rawshift-image/src/metadata/xmp.rs index 0247cee..531dbf8 100644 --- a/crates/rawshift-image/src/metadata/xmp.rs +++ b/crates/rawshift-image/src/metadata/xmp.rs @@ -1,6 +1,6 @@ //! XMP metadata embedding for image export. //! -//! Provides XMP embedding functions for the JPEG and AVIF containers (PNG and +//! Provides the XMP embedding function for the AVIF container (JPEG, PNG, and //! JXL embed XMP through their gamut encoders instead). Every payload is //! validated with `gamut-xmp` before it is embedded, so a malformed packet is //! rejected instead of being spliced into the output. @@ -25,21 +25,12 @@ impl std::fmt::Display for XmpError { impl std::error::Error for XmpError {} -impl From for XmpError { - fn from(e: img_parts::Error) -> Self { - XmpError::Container(e.to_string()) - } -} - impl From for XmpError { fn from(e: std::io::Error) -> Self { XmpError::Container(e.to_string()) } } -/// Adobe XMP namespace marker used in JPEG APP1 segments. -const XMP_JPEG_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; - /// Validate an XMP payload with `gamut-xmp` before embedding it. /// /// Rejecting malformed packets here keeps garbage out of the output @@ -51,38 +42,6 @@ fn validate_xmp(xmp_bytes: &[u8]) -> Result<(), XmpError> { .map_err(|e| XmpError::Invalid(e.to_string())) } -/// Append XMP metadata to existing JPEG data. -/// -/// XMP is embedded as an APP1 segment (0xE1) with the Adobe XMP namespace prefix -/// `http://ns.adobe.com/xap/1.0/\0` followed by the raw XMP packet bytes. -/// The payload is validated with `gamut-xmp` first. -pub fn append_xmp_to_jpeg(xmp_bytes: &[u8], jpeg_data: Vec) -> Result, XmpError> { - use img_parts::Bytes; - use img_parts::jpeg::{Jpeg, JpegSegment, markers}; - use std::io::Cursor; - - validate_xmp(xmp_bytes)?; - - let mut contents = Vec::with_capacity(XMP_JPEG_NS.len() + xmp_bytes.len()); - contents.extend_from_slice(XMP_JPEG_NS); - contents.extend_from_slice(xmp_bytes); - - let mut jpeg = Jpeg::from_bytes(Bytes::from(jpeg_data))?; - let xmp_segment = JpegSegment::new_with_contents(markers::APP1, Bytes::from(contents)); - - // Insert just before the first segment with entropy (SOS), or at the end. - let pos = jpeg - .segments() - .iter() - .position(|s| s.has_entropy()) - .unwrap_or_else(|| jpeg.segments().len()); - jpeg.segments_mut().insert(pos, xmp_segment); - - let mut output = Cursor::new(Vec::new()); - jpeg.encoder().write_to(&mut output)?; - Ok(output.into_inner()) -} - /// Append XMP metadata to an in-memory AVIF byte stream. /// /// A top-level `xml ` ISOBMFF box containing the XMP packet is appended to the @@ -111,32 +70,6 @@ mod tests { \ "; - #[test] - fn test_append_xmp_to_jpeg_basic() { - // Build a minimal valid JPEG: SOI + APP0 (JFIF) + EOI - let mut jpeg = Vec::new(); - jpeg.extend_from_slice(&[0xFF, 0xD8]); // SOI - // APP0 (JFIF marker) - jpeg.extend_from_slice(&[0xFF, 0xE0]); - let app0_len: u16 = 16; - jpeg.extend_from_slice(&app0_len.to_be_bytes()); - jpeg.extend_from_slice(b"JFIF\0"); - jpeg.extend_from_slice(&[1, 1, 0, 0, 1, 0, 1, 0, 0]); // JFIF header rest - jpeg.extend_from_slice(&[0xFF, 0xD9]); // EOI - - let xmp = VALID_XMP; - let result = append_xmp_to_jpeg(xmp, jpeg).expect("XMP embed should succeed"); - - // Must still be a valid JPEG (starts with SOI) - assert_eq!(&result[0..2], &[0xFF, 0xD8]); - // Must contain the XMP namespace prefix - let has_ns = result.windows(XMP_JPEG_NS.len()).any(|w| w == XMP_JPEG_NS); - assert!(has_ns, "output must contain XMP namespace marker"); - // Must contain the XMP payload - let has_xmp = result.windows(xmp.len()).any(|w| w == xmp); - assert!(has_xmp, "output must contain XMP payload"); - } - #[test] fn test_malformed_xmp_is_rejected() { // Not XML at all → every embed path must refuse to splice it in. diff --git a/crates/rawshift-image/src/prelude.rs b/crates/rawshift-image/src/prelude.rs index acc3a2f..0625707 100644 --- a/crates/rawshift-image/src/prelude.rs +++ b/crates/rawshift-image/src/prelude.rs @@ -45,16 +45,15 @@ pub use crate::error::{ // formats — encode option system pub use crate::formats::export::{ - AvifEncodeConfig, BitDepth, CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, - JpegSubsampling, JpegliEncodeConfig, JxlEncodeConfig, LibwebpEncodeConfig, - MetadataEmbedOptions, OutputFormat, PngCompressionLevel, PngEncodeConfig, PngFilterStrategy, - PngFilterType, WebPMode, + AvifEncodeConfig, BitDepth, CommonEncodeOptions, EncodeOptions, JpegDensity, JpegDensityUnit, + JpegEncodeConfig, JpegSubsampling, JxlEncodeConfig, LibwebpEncodeConfig, MetadataEmbedOptions, + OutputFormat, PngCompressionLevel, PngEncodeConfig, PngFilterStrategy, PngFilterType, WebPMode, }; // formats — decoders, format detection, encode/decode entry points pub use crate::formats::{ DecodeOptions, GifDecodeConfig, HeicDecodeConfig, ImageAvifDecodeConfig, ImageProbe, - JxlDecodeConfig, LibwebpDecodeConfig, ResvgDecodeConfig, StandardFormat, TiffDecodeConfig, - ZuneJpegDecodeConfig, ZunePngDecodeConfig, available_decoders, available_encoders, + JpegDecodeConfig, JxlDecodeConfig, LibwebpDecodeConfig, ResvgDecodeConfig, StandardFormat, + TiffDecodeConfig, ZunePngDecodeConfig, available_decoders, available_encoders, decode_standard_image, decode_standard_image_with, detect_standard_format, encode_rgb_image, encode_rgb_image_to_vec, encode_rgb_image_to_writer, probe_standard_image, read_standard_image_metadata, diff --git a/crates/rawshift-image/tests/export_format_tests.rs b/crates/rawshift-image/tests/export_format_tests.rs index 85f561b..1e29ad8 100644 --- a/crates/rawshift-image/tests/export_format_tests.rs +++ b/crates/rawshift-image/tests/export_format_tests.rs @@ -8,7 +8,7 @@ use rawshift_image::core::RgbImage; use rawshift_image::core::metadata::ImageMetadata; use rawshift_image::formats::export::{ - BitDepth, CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, LibwebpEncodeConfig, + BitDepth, CommonEncodeOptions, EncodeOptions, JpegEncodeConfig, LibwebpEncodeConfig, MetadataEmbedOptions, PngEncodeConfig, WebPMode, }; use rawshift_image::formats::{encode_rgb_image, encode_rgb_image_to_vec}; @@ -87,9 +87,10 @@ mod jpeg_tests { use super::*; fn jpeg(quality: u8, exif: bool, icc: bool) -> EncodeOptions { - EncodeOptions::JpegJpegEnc(JpegEncEncodeConfig { + EncodeOptions::Jpeg(JpegEncodeConfig { quality, common: common(exif, icc, true), + ..JpegEncodeConfig::default() }) } @@ -212,6 +213,48 @@ mod jpeg_tests { low.len() ); } + + /// EXIF, ICC, and XMP written by the gamut-jpeg encoder must all read + /// back through `read_standard_image_metadata` (which extracts them via + /// `gamut_jpeg::metadata`). + #[test] + fn test_jpeg_metadata_roundtrip() { + use rawshift_image::core::metadata::{CameraInfo, ExifInfo}; + use rawshift_image::formats::{StandardFormat, read_standard_image_metadata}; + + let img = synthetic_image(); + let xmp_packet: &[u8] = b"\ + \ + "; + let md = ImageMetadata { + camera: CameraInfo { + make: "TestMake".to_string(), + model: "TestModel".to_string(), + ..Default::default() + }, + exif: ExifInfo { + iso: Some(400), + ..Default::default() + }, + xmp: Some(xmp_packet.to_vec()), + ..Default::default() + }; + + let bytes = encode_rgb_image_to_vec(&img, &md, &jpeg(90, true, true)) + .expect("encode JPEG with metadata"); + + let read_md = read_standard_image_metadata(&bytes, StandardFormat::Jpeg); + assert_eq!(read_md.camera.make, "TestMake", "make round-trip"); + assert_eq!(read_md.camera.model, "TestModel", "model round-trip"); + assert_eq!(read_md.exif.iso, Some(400), "ISO round-trip"); + assert_eq!( + read_md.xmp.as_deref(), + Some(xmp_packet), + "XMP packet round-trip" + ); + let icc = read_md.icc_profile.expect("ICC profile round-trip"); + assert_eq!(&icc[36..40], b"acsp", "ICC payload must be a profile"); + } } // ============================================================================ @@ -768,103 +811,6 @@ mod jxl_tests { } } -// ============================================================================ -// JPEG Export Tests — jpegli backend (opt-in `jpeg-encode-jpegli`) -// ============================================================================ - -#[cfg(feature = "jpeg-encode-jpegli")] -mod jpegli_tests { - use super::*; - use rawshift_image::formats::export::{JpegSubsampling, JpegliEncodeConfig}; - use rawshift_image::formats::{StandardFormat, available_encoders, decode_standard_image}; - - /// `CommonEncodeOptions` at 8-bit depth with no metadata. - fn common_8bit() -> CommonEncodeOptions { - CommonEncodeOptions { - metadata: MetadataEmbedOptions { - embed_exif: false, - embed_icc: false, - embed_xmp: false, - }, - bit_depth: BitDepth::Eight, - } - } - - #[test] - fn jpegli_registers_as_encoder() { - assert!( - available_encoders() - .iter() - .any(|c| c.id.id == "jpeg/jpegli"), - "jpeg/jpegli should be listed when the feature is enabled" - ); - } - - #[test] - fn jpegli_encodes_and_decodes_roundtrip_8bit() { - let img = synthetic_image(); - let opts = EncodeOptions::JpegJpegli(JpegliEncodeConfig { - common: common_8bit(), - ..JpegliEncodeConfig::default() - }); - let bytes = encode_rgb_image_to_vec(&img, &ImageMetadata::default(), &opts) - .expect("encode JPEG via jpegli"); - assert!(!bytes.is_empty()); - assert_eq!(&bytes[..2], &[0xFF, 0xD8], "jpegli output should be a JPEG"); - assert_eq!( - rawshift_image::formats::detect_standard_format(&bytes), - Some(StandardFormat::Jpeg), - "jpegli output should be detected as JPEG" - ); - let decoded = decode_standard_image(&bytes, StandardFormat::Jpeg).expect("decode JPEG"); - assert_eq!((decoded.width(), decoded.height()), (4, 4)); - } - - #[test] - fn jpegli_encodes_from_16bit_input() { - // The default depth is `Sixteen`, so jpegli is fed full-precision input; - // the output is still an 8-bit JPEG that decodes at the right size. - let img = synthetic_image(); - let opts = EncodeOptions::JpegJpegli(JpegliEncodeConfig { - common: common(false, false, false), - ..JpegliEncodeConfig::default() - }); - let bytes = encode_rgb_image_to_vec(&img, &ImageMetadata::default(), &opts) - .expect("encode 16-bit input via jpegli"); - let decoded = decode_standard_image(&bytes, StandardFormat::Jpeg).expect("decode JPEG"); - assert_eq!((decoded.width(), decoded.height()), (4, 4)); - } - - #[test] - fn jpegli_xyb_and_quality_encode() { - let img = synthetic_image(); - let opts = EncodeOptions::JpegJpegli(JpegliEncodeConfig { - common: common_8bit(), - quality: Some(85), - xyb: true, - progressive: false, - subsampling: JpegSubsampling::Yuv444, - ..JpegliEncodeConfig::default() - }); - let bytes = encode_rgb_image_to_vec(&img, &ImageMetadata::default(), &opts) - .expect("encode XYB jpegli"); - assert_eq!(&bytes[..2], &[0xFF, 0xD8]); - } - - #[test] - fn jpegli_embeds_exif_and_icc_when_requested() { - let img = synthetic_image(); - let opts = EncodeOptions::JpegJpegli(JpegliEncodeConfig { - common: common(true, true, false), - ..JpegliEncodeConfig::default() - }); - let data = - encode_rgb_image_to_vec(&img, &ImageMetadata::default(), &opts).expect("encode jpegli"); - assert!(jpeg_has_exif(&data), "jpegli JPEG should contain EXIF"); - assert!(jpeg_has_icc(&data), "jpegli JPEG should contain ICC"); - } -} - // ============================================================================ // EncodeOptions API Tests // ============================================================================ @@ -877,8 +823,6 @@ mod encode_options_tests { fn test_encode_options_constructors() { let _ = EncodeOptions::png(); let _ = EncodeOptions::jpeg(); - #[cfg(feature = "jpeg-encode-jpegli")] - let _ = EncodeOptions::jpeg_jpegli(); let _ = EncodeOptions::webp_lossy(); let _ = EncodeOptions::webp_lossless(); #[cfg(feature = "avif-encode")] @@ -891,7 +835,7 @@ mod encode_options_tests { #[test] fn test_jpeg_config_defaults() { - let cfg = JpegEncEncodeConfig::default(); + let cfg = JpegEncodeConfig::default(); assert_eq!(cfg.quality, 90, "JPEG default quality should be 90"); assert!( cfg.common.metadata.embed_exif, @@ -918,7 +862,7 @@ mod encode_options_tests { #[test] fn test_format_and_codec_id() { assert_eq!(EncodeOptions::png().format(), OutputFormat::Png); - assert_eq!(EncodeOptions::jpeg().codec_id().id, "jpeg/jpeg-encoder"); + assert_eq!(EncodeOptions::jpeg().codec_id().id, "jpeg/gamut"); } } diff --git a/crates/rawshift-image/third_party/jpegli b/crates/rawshift-image/third_party/jpegli deleted file mode 160000 index e232082..0000000 --- a/crates/rawshift-image/third_party/jpegli +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e2320820206c9bb3290720285c94bcc9aeb97f78