From 4cfd317580b8cf59ef5a6e8d1e7629d0de5b4fe0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 5 Aug 2026 02:33:30 -0400 Subject: [PATCH 1/2] chore(deps): add gamut-webp 0.3.1 --- Cargo.lock | 24 ++++++++++++++++++++++++ Cargo.toml | 1 + crates/rawshift-image/Cargo.toml | 1 + 3 files changed, 26 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index fc8b100..dd6baa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -701,6 +701,29 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "gamut-riff" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14bd0da4d6d163461120b8b71bd01195dd85750cfe341d0a12a43cc155ee8627" +dependencies = [ + "gamut-bitstream", + "gamut-core", +] + +[[package]] +name = "gamut-webp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2a3f801c048a65edfb6282bdadd8221faf148a08cc1d88513066fb926f91090" +dependencies = [ + "gamut-codec-abi", + "gamut-color", + "gamut-core", + "gamut-dsp", + "gamut-riff", +] + [[package]] name = "gamut-xmp" version = "1.0.0" @@ -1255,6 +1278,7 @@ dependencies = [ "gamut-jxl 0.4.0", "gamut-metadata", "gamut-png", + "gamut-webp", "gamut-xmp", "gif", "libwebp-sys", diff --git a/Cargo.toml b/Cargo.toml index bd0fe72..5ce298c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ gamut-jpeg = "0.1.0" # `jxl-decode` / `jxl-encode` features select the halves explicitly. gamut-jxl = { version = "0.4.0", default-features = false } gamut-png = "0.1.0" +gamut-webp = "0.3.1" gamut-avif = "1.1.0" gamut-heic = "0.2.2" # Dev-only in rawshift-image: constructs synthetic HEIF containers for the diff --git a/crates/rawshift-image/Cargo.toml b/crates/rawshift-image/Cargo.toml index ebe4bd4..7d47a04 100644 --- a/crates/rawshift-image/Cargo.toml +++ b/crates/rawshift-image/Cargo.toml @@ -44,6 +44,7 @@ gamut-metadata = { workspace = true, optional = true } # guards) and encode (PngEncoder: 8/16-bit RGB, DEFLATE level + filter # strategy + lossless auto-reduce, and eXIf/iCCP/XMP-iTXt chunk embedding). gamut-png = { workspace = true, optional = true } +gamut-webp = { workspace = true, optional = true } # gamut-avif backs both AVIF halves. Encode: AvifEncoder (8-bit RGB, lossless # or lossy AV1 intra at identity 4:4:4 — pure Rust, no C toolchain); 10/12-bit # and alpha encode are pending upstream (visualcommons/gamut#251). Decode: From 4cf60d30c371e4d5a858613f1decd46da953d608 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 5 Aug 2026 02:55:47 -0400 Subject: [PATCH 2/2] feat(webp)!: migrate WebP to gamut-webp --- CHANGELOG.md | 26 +- Cargo.lock | 69 ---- LEGAL | 59 ---- README.md | 4 +- crates/rawshift-image/Cargo.toml | 29 +- crates/rawshift-image/README.md | 21 +- crates/rawshift-image/benches/decode.rs | 27 +- crates/rawshift-image/src/codecs/mod.rs | 2 - crates/rawshift-image/src/codecs/webp.rs | 329 ------------------ crates/rawshift-image/src/error.rs | 4 - crates/rawshift-image/src/formats/encode.rs | 81 +++-- crates/rawshift-image/src/formats/export.rs | 41 +-- crates/rawshift-image/src/formats/mod.rs | 4 +- crates/rawshift-image/src/formats/registry.rs | 8 +- crates/rawshift-image/src/formats/standard.rs | 76 +++- crates/rawshift-image/src/lib.rs | 13 +- crates/rawshift-image/src/metadata/bridge.rs | 8 +- crates/rawshift-image/src/metadata/exif.rs | 62 +--- crates/rawshift-image/src/prelude.rs | 13 +- .../tests/export_format_tests.rs | 82 ++++- .../tests/fixtures/libwebp_metadata.rs | 24 ++ .../tests/standard_decode_fixtures.rs | 26 ++ docs/BENCHMARKS.md | 21 +- docs/V1_DESIGN.md | 14 +- 24 files changed, 356 insertions(+), 687 deletions(-) delete mode 100644 LEGAL delete mode 100644 crates/rawshift-image/src/codecs/webp.rs create mode 100644 crates/rawshift-image/tests/fixtures/libwebp_metadata.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b987709..c0170b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,14 @@ All entries below are **breaking**, grouped by area. CRCs always enforced). Encode: `ZunePngEncodeConfig` → `PngEncodeConfig` (compression level, filter strategy, `auto_reduce`); output bytes change (properly compressed). Codec id `png/gamut` both ways. +- **WebP** → gamut-webp 0.3.1 both directions, replacing the in-repo + `libwebp-sys` FFI wrapper with pure-Rust VP8/VP8L and RIFF handling. + `WebpDecodeConfig` and `WebpEncodeConfig` replace the libwebp-named config + types; encode quality is now `u8` (`0..=100`), and the libwebp-specific + `method`/`near_lossless` knobs are removed. `EncodeError::WebP` is removed; + codec id is `webp/gamut` and registry version is `0.3`. EXIF/XMP/ICC now + flow through gamut-webp and the gamut-metadata bridge, replacing the manual + WebP EXIF scanner. - **JPEG XL** → gamut-jxl. Decode replaces `jxl-oxide` (pure Rust); `decode_jxl_partial` is removed until truncated-stream decode lands upstream (gamut#256); animated and premultiplied-alpha streams are now @@ -113,8 +121,7 @@ All entries below are **breaking**, grouped by area. `DecodeOptions::WebpLibwebp`/`SvgResvg`/`PpmZune` → `WebP`/`Svg`/`Ppm`, and `EncodeOptions::PngGamut`/`WebpLibwebp` → `Png`/`WebP`. Backend names survive only in the config struct names of the - un-migrated/exception backends (`LibwebpDecodeConfig`, - `LibwebpEncodeConfig`, `TiffDecodeConfig`, `GifDecodeConfig`, + un-migrated/exception backends (`TiffDecodeConfig`, `GifDecodeConfig`, `ResvgDecodeConfig`, `ZunePpmDecodeConfig`). The never-wired "planned backend" config structs (`LibjpegTurboEncodeConfig`, `MozjpegEncodeConfig`, `SvtAv1EncodeConfig`) are deleted — alternative encoder backends contradict @@ -129,9 +136,9 @@ All entries below are **breaking**, grouped by area. `container-embed`, `tiff-parser` (replaced by `ifd-parser` over gamut-ifd), `heic-vendored`, and every `*-vendored` linking flag. Retained tier-4 aliases: `gif-decode-gif`/`svg-decode-resvg`/`ppm-decode-zune` (permanent - exceptions) and `tiff-decode-tiff`/`webp-decode-libwebp`/ - `webp-encode-libwebp` (blocked migrations: gamut#299/#300 via rawshift#22, - gamut#302 via rawshift#24). + exceptions) and `tiff-decode-tiff` (blocked migration: gamut#299/#300 via + rawshift#22). The migrated `webp-decode-libwebp` and + `webp-encode-libwebp` aliases are removed. - `--all-features` is no longer a valid build invocation by design: the hardware backend pins (`hw-videotoolbox`/`hw-vaapi`/`hw-mediacodec`) are mutually exclusive verified feature flags. Use `--features full`. @@ -183,9 +190,9 @@ hung off them: `image` (with its dav1d subtree leaving the lockfile), and the direct `jpegxl-src` dependency (it remains only as gamut-jxl-sys's own pinned build-dependency). `zune-core`/`zune-ppm` stay — PPM is a permanent - exception; `libwebp-sys` and `tiff` stay pending their blocked upstream - migrations (rawshift#24, rawshift#22); `gif` and `resvg` are permanent - exceptions. + exception; `tiff` stays pending its blocked upstream migration + (rawshift#22); `gif` and `resvg` are permanent exceptions. `libwebp-sys` + and the in-repo unsafe WebP wrapper are removed in favor of gamut-webp. - *(deps, metadata/containers)* `little_exif`, `img-parts`, `binrw` — with the whole in-repo binrw TIFF layer: the `tiff/` module and its public `TiffParser`/`TiffWriter`/`TiffValue`/`TiffTag` API. @@ -193,7 +200,8 @@ hung off them: in-repo libjxl bindgen glue, and the `bindgen`/`cc`/`cmake`/`pkg-config` build-dependencies. - *(image)* `decode_jxl_partial` (until gamut#256); the hand-rolled JPEG - APP1 and PNG eXIf scanners; `ExifContainer::Jpeg`/`Png`/`Avif` read paths; + APP1, PNG eXIf, and WebP RIFF EXIF scanners; + `ExifContainer::Jpeg`/`Png`/`WebP`/`Avif` read paths; the never-implemented "planned backend" encode config structs. ### Fixed diff --git a/Cargo.lock b/Cargo.lock index dd6baa1..485ea7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,8 +167,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -745,18 +743,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - [[package]] name = "gif" version = "0.13.3" @@ -767,12 +753,6 @@ dependencies = [ "weezl", ] -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "half" version = "2.7.1" @@ -850,16 +830,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom", - "libc", -] - [[package]] name = "jpegxl-src" version = "0.12.0" @@ -961,17 +931,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libwebp-sys" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375ca3fbd6d89769361c5d505c9da676eb4128ee471b9fd763144d377a2d30e6" -dependencies = [ - "cc", - "glob", - "pkg-config", -] - [[package]] name = "lock_api" version = "0.4.14" @@ -1124,12 +1083,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "plotters" version = "0.3.7" @@ -1226,12 +1179,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "rawshift" version = "0.1.1" @@ -1281,7 +1228,6 @@ dependencies = [ "gamut-webp", "gamut-xmp", "gif", - "libwebp-sys", "rawshift-core", "rawshift-hwdec", "rayon", @@ -1881,15 +1827,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -1975,12 +1912,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" - [[package]] name = "xmlwriter" version = "0.1.0" diff --git a/LEGAL b/LEGAL deleted file mode 100644 index 02a17b5..0000000 --- a/LEGAL +++ /dev/null @@ -1,59 +0,0 @@ -Third-Party License Notices -============================ - -libwebp — WebP codec library ------------------------------ - -Copyright (c) 2010, Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in - the documentation and/or other materials provided with the - distribution. - - * Neither the name of Google nor the names of its contributors may - be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Additional IP Rights Grant (Patents) ------------------------------------- - -"These implementations" means the copyrightable works that implement the WebP -codec distributed by Google as part of the libwebp package. - -Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge, -irrevocable (except as stated in this section) patent license to make, have -made, use, offer to sell, sell, import, transfer, and otherwise run, modify and -propagate the contents of these implementations of the WebP codec, where such -license applies only to those patent claims, both currently owned by Google and -acquired in the future, licensable by Google that are necessarily infringed by -these implementations of the WebP codec. This grant does not include claims -that would be infringed only as a consequence of further modification of these -implementations. If you or your agent or exclusive licensee institute or order -or agree to the institution of patent litigation or any other patent -enforcement activity against any entity (including a cross-claim or -counterclaim in a lawsuit) alleging that any of these implementations of WebP -or any code incorporated within any of these implementations of WebP -constitutes direct or contributory patent infringement, or inducement of -patent infringement, then any patent rights granted to you under this License -for these implementations of WebP shall terminate as of the date such -litigation is filed. diff --git a/README.md b/README.md index da21a69..aaa4aaf 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,8 @@ them here would duplicate state that drifts. See each crate's README: - [`rawshift-image` feature flags](./crates/rawshift-image/README.md#feature-flags) — a tiered per-format system (bundles → formats → directions → - infrastructure; gamut is the backend, plus six retained implementation - aliases for the permanent exceptions and blocked migrations). + infrastructure; gamut is the backend, plus four retained implementation + aliases for the permanent exceptions and blocked TIFF migration). - [`rawshift-video` feature flags](./crates/rawshift-video/README.md#feature-flags) — mirrors the image tiers; currently gates no code (video is unimplemented). diff --git a/crates/rawshift-image/Cargo.toml b/crates/rawshift-image/Cargo.toml index 7d47a04..4d7e70b 100644 --- a/crates/rawshift-image/Cargo.toml +++ b/crates/rawshift-image/Cargo.toml @@ -82,7 +82,6 @@ rawshift-hwdec = { workspace = true, optional = true } # linked by gamut-jxl-sys; needs cmake + a C++ toolchain). Which half is # compiled is selected by the `jxl-decode` / `jxl-encode` features. gamut-jxl = { workspace = true, optional = true } -libwebp-sys = { version = "0.14", optional = true } rayon = { workspace = true } resvg = { version = "0.44", optional = true } serde = { workspace = true, optional = true } @@ -118,15 +117,15 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } [features] # Feature flags — the consolidated post-migration tree. gamut is the backend: # every gamut-backed direction feature pulls its `dep:gamut-*` directly, so -# the tier-4 implementation layer has collapsed to the six retained aliases -# documented in the tier-4 section below (permanent exceptions and blocked -# migrations only). See the "Feature Flags" section of README.md and the +# the tier-4 implementation layer has collapsed to the four retained aliases +# documented in the tier-4 section below (permanent exceptions and one blocked +# migration). See the "Feature Flags" section of README.md and the # feature table in docs/V1_DESIGN.md. # # tier 1 bundles full, experimental, raw-stabilizing, … # tier 2 format jpeg, png, dng, … (decode + encode) # tier 3 direction jpeg-decode, dng-encode, … (pulls the backend) -# tier 4 implementation six retained aliases only (see section comment) +# tier 4 implementation four retained aliases only (see section comment) # tier 5 infrastructure ifd-parser, serde, exif, hw/hw-*, … # # `jxl-encode` is deliberately absent from `default`: it wraps the reference @@ -163,8 +162,8 @@ raf = ["raf-decode"] # ── Tier 3: direction features ──────────────────────────────────────────────── # gamut-backed direction features pull their `dep:gamut-*` directly — gamut is # the backend, so there is no implementation choice and no tier-4 layer. Only -# the six retained tier-4 aliases (webp/gif/tiff/svg/ppm — permanent -# exceptions or blocked migrations, see the tier-4 section) still route a +# the four retained tier-4 aliases (gif/tiff/svg/ppm — permanent exceptions or +# a blocked migration, see the tier-4 section) still route a # direction feature through an implementation feature. RAW direction features # reference infra/deps directly: RAW formats have a single in-repo # implementation and no tier-4 layer. @@ -186,8 +185,10 @@ png-decode = ["dep:gamut-png", "exif"] # Encode: `exif` supplies the ExifBuilder used to serialise the eXIf chunk # payload. png-encode = ["dep:gamut-png", "exif"] -webp-decode = ["webp-decode-libwebp"] -webp-encode = ["webp-encode-libwebp"] +# WebP is backed directly by gamut-webp (pure Rust VP8/VP8L plus RIFF +# EXIF/XMP/ICC metadata in both directions). +webp-decode = ["dep:gamut-webp", "exif"] +webp-encode = ["dep:gamut-webp", "exif"] # JXL is backed directly by gamut-jxl (no tier-4 layer): like PNG and # the RAW direction features, each half has a single gamut-backed # implementation, so the format-direction feature itself pulls the dependency. @@ -238,9 +239,9 @@ dng-encode = ["dep:gamut-dng"] nef-decode = ["ifd-parser"] raf-decode = [] -# ── Tier 4: implementation features (six retained aliases only) ─────────────── +# ── Tier 4: implementation features (four retained aliases only) ────────────── # The gamut migrations collapsed this tier for every gamut-backed format — the -# direction features above pull their `dep:gamut-*` directly. The six flags +# direction features above pull their `dep:gamut-*` directly. The four flags # below are the deliberate remainder; each names its gate: # # gif-decode-gif permanent exception (`gif`) — AGENTS.md upstream policy @@ -250,16 +251,10 @@ raf-decode = [] # `zune-runtime` # tiff-decode-tiff blocked migration to gamut-tiff — gamut#299/#300, # tracked by rawshift#22 (`blocked-upstream`) -# webp-decode-libwebp blocked migration to gamut-webp — gamut#302, -# tracked by rawshift#24 (`blocked-upstream`) -# webp-encode-libwebp blocked migration to gamut-webp — gamut#302, -# tracked by rawshift#24 (`blocked-upstream`) # # Each also enables its tier-3 direction feature, so it can be selected # standalone. When a blocked migration unblocks, its alias dissolves into the # direction feature exactly like the gamut-backed formats above. -webp-decode-libwebp = ["webp-decode", "dep:libwebp-sys", "exif"] -webp-encode-libwebp = ["webp-encode", "dep:libwebp-sys", "exif"] gif-decode-gif = ["gif-decode", "dep:gif"] tiff-decode-tiff = ["tiff-decode", "dep:tiff", "exif"] svg-decode-resvg = ["svg-decode", "dep:resvg"] diff --git a/crates/rawshift-image/README.md b/crates/rawshift-image/README.md index 8eaef78..a161186 100644 --- a/crates/rawshift-image/README.md +++ b/crates/rawshift-image/README.md @@ -23,7 +23,7 @@ or an explicit hardware-decode backend pin (`hw-*`). | Fujifilm RAF | Custom RAF parser (Incomplete) | N/A | No test fixtures. | | JPEG | [gamut-jpeg](https://github.com/visualcommons/gamut) (Stable) | [gamut-jpeg](https://github.com/visualcommons/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 | [gamut-png](https://github.com/visualcommons/gamut) (Stable) | [gamut-png](https://github.com/visualcommons/gamut) (Stable) | Pure Rust. Decode: every colour type/bit depth incl. Adam7, eXIf/iCCP/XMP extraction, resource guards. Encode: 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. gamut-webp migration blocked upstream ([gamut#302](https://github.com/visualcommons/gamut/issues/302), tracked by [rawshift#24](https://github.com/visualcommons/rawshift/issues/24)). | +| WebP | [gamut-webp](https://github.com/visualcommons/gamut) (Stable) | [gamut-webp](https://github.com/visualcommons/gamut) (Stable) | Pure Rust VP8/VP8L; EXIF/XMP/ICC metadata in both directions. | | GIF | [gif](https://github.com/image-rs/image-gif) (Stable) | Not planned | Permanent exception to the gamut migration (AGENTS.md). | | TIFF | [tiff](https://github.com/image-rs/image-tiff) (Stable) | Not planned | gamut-tiff migration blocked upstream ([gamut#299](https://github.com/visualcommons/gamut/issues/299)/[#300](https://github.com/visualcommons/gamut/issues/300), tracked by [rawshift#22](https://github.com/visualcommons/rawshift/issues/22)). | | JXL | [gamut-jxl](https://github.com/visualcommons/gamut) (Stable) | [gamut-jxl](https://github.com/visualcommons/gamut) (Stable) | Decode is pure Rust (jxl-rs); encode wraps the reference libjxl, cmake-built and statically linked by gamut-jxl-sys. | @@ -46,9 +46,8 @@ under [Feature Flags](#feature-flags). Cargo features are organised in tiers, from high-level bundles down to infrastructure flags. gamut is the backend: every gamut-backed direction feature pulls its `gamut-*` crate directly, so the tier-4 implementation -layer has collapsed to six retained aliases — the permanent exceptions -(GIF/SVG/PPM) and the blocked migrations (TIFF/WebP) listed under tier 4 -below. +layer has collapsed to four retained aliases — the permanent exceptions +(GIF/SVG/PPM) and the blocked TIFF migration listed under tier 4 below. 1. **Bundle features** — coarse, ready-made groupings. - `default` — `jpeg`, `png`, `webp`, `jxl-decode`, `gif-decode`, `tiff-decode`, `ppm-decode`. @@ -63,9 +62,9 @@ below. - `arw`, `cr2`, `cr3`, `crw`, `nef`, `raf` — RAW, decode-only 3. **Direction features** — one per format per direction. - gamut-backed formats: `jpeg-decode`, `jpeg-encode`, `png-decode`, - `png-encode`, `jxl-decode`, `jxl-encode`, `avif-decode`, `avif-encode`, + `png-encode`, `webp-decode`, `webp-encode`, `jxl-decode`, `jxl-encode`, `avif-decode`, `avif-encode`, `heic-decode` — each has a single gamut-backed implementation - (`gamut-jpeg` / `gamut-png` / `gamut-jxl` / `gamut-avif` / `gamut-heic`) + (`gamut-jpeg` / `gamut-png` / `gamut-webp` / `gamut-jxl` / `gamut-avif` / `gamut-heic`) and pulls it directly, with no tier-4 layer below it. (`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 @@ -74,13 +73,13 @@ below. [gamut#251](https://github.com/visualcommons/gamut/issues/251). `avif-decode` is container/metadata pure Rust; its pixel decode needs a hardware AV1 backend — see the `hw` flags under tier 5.) - - Exception/blocked formats: `webp-decode`, `webp-encode`, `gif-decode`, - `tiff-decode`, `svg-decode`, `ppm-decode` — each is an alias for its + - Exception/blocked formats: `gif-decode`, `tiff-decode`, `svg-decode`, + `ppm-decode` — each is an alias for its retained tier-4 implementation feature (see tier 4). - RAW formats: `arw-decode`, `cr2-decode`, `cr3-decode`, `crw-decode`, `dng-decode`, `dng-encode`, `nef-decode`, `raf-decode` — RAW formats have a single in-repo implementation, so there is no tier-4 layer below them. -4. **Implementation features** — six retained aliases, named +4. **Implementation features** — four retained aliases, named `format-direction-impl`. The gamut migrations collapsed this tier for every gamut-backed format; these remain, each for a named reason: - `gif-decode-gif`, `svg-decode-resvg`, `ppm-decode-zune` — permanent @@ -88,10 +87,6 @@ below. - `tiff-decode-tiff` — gamut-tiff migration blocked upstream ([gamut#299](https://github.com/visualcommons/gamut/issues/299)/[#300](https://github.com/visualcommons/gamut/issues/300), tracked by [rawshift#22](https://github.com/visualcommons/rawshift/issues/22)). - - `webp-decode-libwebp`, `webp-encode-libwebp` — gamut-webp migration - blocked upstream - ([gamut#302](https://github.com/visualcommons/gamut/issues/302), tracked by - [rawshift#24](https://github.com/visualcommons/rawshift/issues/24)). 5. **Infrastructure / linking features** — cross-cutting, not tied to one format. - `ifd-parser` — the gamut-ifd TIFF/IFD structure engine used by the TIFF-based RAW decoders and format detection. diff --git a/crates/rawshift-image/benches/decode.rs b/crates/rawshift-image/benches/decode.rs index 6fe57d7..aa8b48e 100644 --- a/crates/rawshift-image/benches/decode.rs +++ b/crates/rawshift-image/benches/decode.rs @@ -2,7 +2,7 @@ //! //! Covers three layers: //! - RAW data-structure primitives (creation, pixel access), -//! - the gamut-backed standard codecs (JPEG/PNG encode + decode round-trips +//! - the gamut-backed standard codecs (JPEG/PNG/WebP encode + decode round-trips //! on a synthetic image — the going-forward per-format regression baseline //! after the gamut migration), //! - hardware HEIC/AVIF still decode (`hw` feature). The hardware benches @@ -62,6 +62,7 @@ fn bench_pixel_access(c: &mut Criterion) { #[cfg(any( all(feature = "jpeg-decode", feature = "jpeg-encode"), all(feature = "png-decode", feature = "png-encode"), + all(feature = "webp-decode", feature = "webp-encode"), ))] fn synthetic_rgb(width: u32, height: u32) -> rawshift_image::core::RgbImage { let mut data = Vec::with_capacity((width * height * 3) as usize); @@ -122,6 +123,29 @@ fn bench_png_codec(c: &mut Criterion) { #[cfg(not(all(feature = "png-decode", feature = "png-encode")))] fn bench_png_codec(_c: &mut Criterion) {} +#[cfg(all(feature = "webp-decode", feature = "webp-encode"))] +fn bench_webp_codec(c: &mut Criterion) { + use rawshift_image::core::metadata::ImageMetadata; + use rawshift_image::formats::export::EncodeOptions; + use rawshift_image::formats::{StandardFormat, decode_standard_image, encode_rgb_image_to_vec}; + + let image = synthetic_rgb(512, 512); + let metadata = ImageMetadata::default(); + let opts = EncodeOptions::webp_lossy(); + + c.bench_function("webp_encode_512x512", |b| { + b.iter(|| encode_rgb_image_to_vec(&image, &metadata, &opts).expect("encode WebP")); + }); + + let bytes = encode_rgb_image_to_vec(&image, &metadata, &opts).expect("encode WebP"); + c.bench_function("webp_decode_512x512", |b| { + b.iter(|| decode_standard_image(&bytes, StandardFormat::WebP).expect("decode WebP")); + }); +} + +#[cfg(not(all(feature = "webp-decode", feature = "webp-encode")))] +fn bench_webp_codec(_c: &mut Criterion) {} + // ── Hardware HEIC/AVIF decode (`hw` feature) ───────────────────────────────── /// Local fixture generation for the hardware benches: a synthetic Y4M pushed @@ -256,6 +280,7 @@ criterion_group!( bench_pixel_access, bench_jpeg_codec, bench_png_codec, + bench_webp_codec, bench_heic_hw_decode, bench_avif_hw_decode ); diff --git a/crates/rawshift-image/src/codecs/mod.rs b/crates/rawshift-image/src/codecs/mod.rs index ad197ec..17aa4b2 100644 --- a/crates/rawshift-image/src/codecs/mod.rs +++ b/crates/rawshift-image/src/codecs/mod.rs @@ -6,5 +6,3 @@ pub(crate) mod bit_pump; // uses gamut-dng's internal (and public) lossless-JPEG implementation. #[cfg(any(feature = "arw-decode", feature = "cr2-decode", feature = "nef-decode"))] pub(crate) mod ljpeg; -#[cfg(any(feature = "webp-decode", feature = "webp-encode"))] -pub(crate) mod webp; diff --git a/crates/rawshift-image/src/codecs/webp.rs b/crates/rawshift-image/src/codecs/webp.rs deleted file mode 100644 index 8de7ab8..0000000 --- a/crates/rawshift-image/src/codecs/webp.rs +++ /dev/null @@ -1,329 +0,0 @@ -//! Safe wrappers around libwebp FFI for WebP encoding, decoding, and muxing. - -use std::ffi::c_int; -use std::os::raw::c_void; -use std::ptr; -use std::slice; - -use libwebp_sys::*; - -/// Decode a WebP bitstream into RGB pixels. -/// -/// Returns `(width, height, rgb_pixels)`. Alpha is always stripped. -pub fn decode_webp_rgb(data: &[u8]) -> Result<(u32, u32, Vec), String> { - let mut width: c_int = 0; - let mut height: c_int = 0; - - // Validate and get dimensions - let ok = unsafe { WebPGetInfo(data.as_ptr(), data.len(), &mut width, &mut height) }; - if ok == 0 { - return Err("WebPGetInfo failed: invalid WebP data".into()); - } - if width <= 0 || height <= 0 { - return Err(format!("Invalid dimensions: {width}x{height}")); - } - - let w = width as u32; - let h = height as u32; - - // Decode to RGB (alpha stripped by libwebp) - let ptr = unsafe { WebPDecodeRGB(data.as_ptr(), data.len(), &mut width, &mut height) }; - if ptr.is_null() { - return Err("WebPDecodeRGB failed".into()); - } - - let len = (w as usize) * (h as usize) * 3; - let pixels = unsafe { slice::from_raw_parts(ptr, len) }.to_vec(); - unsafe { WebPFree(ptr as *mut c_void) }; - - Ok((w, h, pixels)) -} - -/// Build a `WebPConfig` from our high-level options. -pub fn build_webp_config( - lossless: bool, - quality: f32, - method: u32, - near_lossless: u32, -) -> Result { - let mut config = WebPConfig::new_with_preset(WebPPreset::WEBP_PRESET_DEFAULT, quality) - .map_err(|()| "WebPConfigInit failed")?; - - config.lossless = if lossless { 1 } else { 0 }; - config.quality = quality; - config.method = method as c_int; - if lossless { - config.near_lossless = near_lossless as c_int; - } - - let valid = unsafe { WebPValidateConfig(&config) }; - if valid == 0 { - return Err("WebPValidateConfig failed: invalid config".into()); - } - - Ok(config) -} - -/// Encode RGB pixel data to a WebP bitstream. -/// -/// `pixels` must be `width * height * 3` bytes of packed RGB. -pub fn encode_webp_rgb( - pixels: &[u8], - width: u32, - height: u32, - config: &WebPConfig, -) -> Result, String> { - let expected_len = (width as usize) * (height as usize) * 3; - if pixels.len() != expected_len { - return Err(format!( - "pixel buffer length mismatch: expected {expected_len}, got {}", - pixels.len() - )); - } - - unsafe { - let mut picture: WebPPicture = std::mem::zeroed(); - if WebPPictureInitInternal(&mut picture, WEBP_ENCODER_ABI_VERSION as c_int) == 0 { - return Err("WebPPictureInit failed".into()); - } - - picture.width = width as c_int; - picture.height = height as c_int; - picture.use_argb = if config.lossless != 0 { 1 } else { 0 }; - - // Set up memory writer - let mut writer: WebPMemoryWriter = std::mem::zeroed(); - WebPMemoryWriterInit(&mut writer); - picture.writer = Some(WebPMemoryWrite); - picture.custom_ptr = &mut writer as *mut WebPMemoryWriter as *mut c_void; - - // Import RGB data - let stride = (width * 3) as c_int; - if WebPPictureImportRGB(&mut picture, pixels.as_ptr(), stride) == 0 { - WebPPictureFree(&mut picture); - WebPMemoryWriterClear(&mut writer); - return Err("WebPPictureImportRGB failed".into()); - } - - // Encode - let ok = WebPEncode(config, &mut picture); - if ok == 0 { - let err_code = picture.error_code; - WebPPictureFree(&mut picture); - WebPMemoryWriterClear(&mut writer); - return Err(format!("WebPEncode failed: error code {:?}", err_code)); - } - - // Copy output - let output = slice::from_raw_parts(writer.mem, writer.size).to_vec(); - - WebPPictureFree(&mut picture); - WebPMemoryWriterClear(&mut writer); - - Ok(output) - } -} - -/// Mux a WebP bitstream with optional EXIF, ICC, and XMP metadata chunks. -/// -/// Returns the assembled WebP file bytes including all metadata. -/// If no metadata is provided, returns the original bitstream unchanged. -pub fn mux_webp( - bitstream: &[u8], - exif: Option<&[u8]>, - icc: Option<&[u8]>, - xmp: Option<&[u8]>, -) -> Result, String> { - if exif.is_none() && icc.is_none() && xmp.is_none() { - return Ok(bitstream.to_vec()); - } - - unsafe { - let mux = WebPMuxNew(); - if mux.is_null() { - return Err("WebPMuxNew failed".into()); - } - - // Set the image bitstream - let image_data = WebPData { - bytes: bitstream.as_ptr(), - size: bitstream.len(), - }; - let rc = WebPMuxSetImage(mux, &image_data, 1); - if rc != WebPMuxError::WEBP_MUX_OK { - WebPMuxDelete(mux); - return Err(format!("WebPMuxSetImage failed: {:?}", rc)); - } - - // Set EXIF chunk - if let Some(data) = exif { - let chunk_data = WebPData { - bytes: data.as_ptr(), - size: data.len(), - }; - let rc = WebPMuxSetChunk(mux, b"EXIF".as_ptr() as *const i8, &chunk_data, 1); - if rc != WebPMuxError::WEBP_MUX_OK { - WebPMuxDelete(mux); - return Err(format!("WebPMuxSetChunk(EXIF) failed: {:?}", rc)); - } - } - - // Set ICC profile chunk - if let Some(data) = icc { - let chunk_data = WebPData { - bytes: data.as_ptr(), - size: data.len(), - }; - let rc = WebPMuxSetChunk(mux, b"ICCP".as_ptr() as *const i8, &chunk_data, 1); - if rc != WebPMuxError::WEBP_MUX_OK { - WebPMuxDelete(mux); - return Err(format!("WebPMuxSetChunk(ICCP) failed: {:?}", rc)); - } - } - - // Set XMP chunk - if let Some(data) = xmp { - let chunk_data = WebPData { - bytes: data.as_ptr(), - size: data.len(), - }; - let rc = WebPMuxSetChunk(mux, b"XMP ".as_ptr() as *const i8, &chunk_data, 1); - if rc != WebPMuxError::WEBP_MUX_OK { - WebPMuxDelete(mux); - return Err(format!("WebPMuxSetChunk(XMP) failed: {:?}", rc)); - } - } - - // Assemble - let mut output_data: WebPData = WebPData { - bytes: ptr::null(), - size: 0, - }; - let rc = WebPMuxAssemble(mux, &mut output_data); - if rc != WebPMuxError::WEBP_MUX_OK { - WebPMuxDelete(mux); - return Err(format!("WebPMuxAssemble failed: {:?}", rc)); - } - - let result = slice::from_raw_parts(output_data.bytes, output_data.size).to_vec(); - - WebPDataClear(&mut output_data); - WebPMuxDelete(mux); - - Ok(result) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_build_config_lossy() { - let config = build_webp_config(false, 75.0, 4, 100).expect("config should be valid"); - assert_eq!(config.lossless, 0); - assert!((config.quality - 75.0).abs() < f32::EPSILON); - } - - #[test] - fn test_build_config_lossless() { - let config = build_webp_config(true, 75.0, 4, 80).expect("config should be valid"); - assert_eq!(config.lossless, 1); - assert_eq!(config.near_lossless, 80); - } - - #[test] - fn test_lossless_roundtrip() { - // 2x2 red/green/blue/white image - let pixels: Vec = vec![ - 255, 0, 0, 0, 255, 0, // row 0 - 0, 0, 255, 255, 255, 255, // row 1 - ]; - let config = build_webp_config(true, 75.0, 4, 100).unwrap(); - let encoded = encode_webp_rgb(&pixels, 2, 2, &config).expect("encode"); - assert!(!encoded.is_empty()); - - let (w, h, decoded) = decode_webp_rgb(&encoded).expect("decode"); - assert_eq!(w, 2); - assert_eq!(h, 2); - assert_eq!(decoded, pixels, "lossless round-trip should be exact"); - } - - #[test] - fn test_lossy_encode_decode() { - let pixels: Vec = vec![128; 4 * 4 * 3]; - let config = build_webp_config(false, 50.0, 4, 100).unwrap(); - let encoded = encode_webp_rgb(&pixels, 4, 4, &config).expect("encode"); - assert!(!encoded.is_empty()); - - let (w, h, decoded) = decode_webp_rgb(&encoded).expect("decode"); - assert_eq!(w, 4); - assert_eq!(h, 4); - assert_eq!(decoded.len(), 4 * 4 * 3); - } - - #[test] - fn test_mux_with_exif() { - // Encode a minimal image first - let pixels: Vec = vec![100; 2 * 2 * 3]; - let config = build_webp_config(true, 75.0, 0, 100).unwrap(); - let bitstream = encode_webp_rgb(&pixels, 2, 2, &config).unwrap(); - - let fake_exif = b"Exif\x00\x00test_exif_data"; - let muxed = mux_webp(&bitstream, Some(fake_exif), None, None).expect("mux"); - - // Should be a valid RIFF/WEBP container - assert_eq!(&muxed[0..4], b"RIFF"); - assert_eq!(&muxed[8..12], b"WEBP"); - // Should contain EXIF fourcc - let has_exif = muxed.windows(4).any(|w| w == b"EXIF"); - assert!(has_exif, "muxed output should contain EXIF chunk"); - } - - #[test] - fn test_mux_with_icc() { - let pixels: Vec = vec![100; 2 * 2 * 3]; - let config = build_webp_config(true, 75.0, 0, 100).unwrap(); - let bitstream = encode_webp_rgb(&pixels, 2, 2, &config).unwrap(); - - let fake_icc = b"fake_icc_profile_data"; - let muxed = mux_webp(&bitstream, None, Some(fake_icc), None).expect("mux"); - - let has_iccp = muxed.windows(4).any(|w| w == b"ICCP"); - assert!(has_iccp, "muxed output should contain ICCP chunk"); - } - - #[test] - fn test_mux_with_xmp() { - let pixels: Vec = vec![100; 2 * 2 * 3]; - let config = build_webp_config(true, 75.0, 0, 100).unwrap(); - let bitstream = encode_webp_rgb(&pixels, 2, 2, &config).unwrap(); - - let fake_xmp = b"test"; - let muxed = mux_webp(&bitstream, None, None, Some(fake_xmp)).expect("mux"); - - let has_xmp = muxed.windows(4).any(|w| w == b"XMP "); - assert!(has_xmp, "muxed output should contain XMP chunk"); - } - - #[test] - fn test_mux_no_metadata_returns_original() { - let data = b"some_data"; - let result = mux_webp(data, None, None, None).unwrap(); - assert_eq!(result, data); - } - - #[test] - fn test_decode_invalid_data() { - let result = decode_webp_rgb(b"not a webp"); - assert!(result.is_err()); - } - - #[test] - fn test_encode_buffer_mismatch() { - let pixels = vec![0u8; 10]; // wrong size for any image - let config = build_webp_config(false, 75.0, 4, 100).unwrap(); - let result = encode_webp_rgb(&pixels, 2, 2, &config); - assert!(result.is_err()); - } -} diff --git a/crates/rawshift-image/src/error.rs b/crates/rawshift-image/src/error.rs index 11606b1..def1cc2 100644 --- a/crates/rawshift-image/src/error.rs +++ b/crates/rawshift-image/src/error.rs @@ -224,10 +224,6 @@ pub enum EncodeError { /// The bit depth that was requested but is not supported. requested: BitDepth, }, - - /// WebP encoding error. - #[error("WebP error: {0}")] - WebP(String), } /// Result type alias using RawError. diff --git a/crates/rawshift-image/src/formats/encode.rs b/crates/rawshift-image/src/formats/encode.rs index cb0db60..8ed3e1e 100644 --- a/crates/rawshift-image/src/formats/encode.rs +++ b/crates/rawshift-image/src/formats/encode.rs @@ -292,52 +292,67 @@ fn encode_jpeg( fn encode_webp( image: &RgbImage, metadata: &ImageMetadata, - cfg: &super::export::LibwebpEncodeConfig, + cfg: &super::export::WebpEncodeConfig, ) -> RawResult> { - use crate::codecs::webp::{build_webp_config, encode_webp_rgb, mux_webp}; use crate::metadata::exif::ExifBuilder; use crate::metadata::icc::IccProfile; + use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; + use gamut_webp::WebpEncoder; check_8bit_backend(cfg.common.bit_depth, "WebP")?; - let lossless = cfg.mode == WebPMode::Lossless; - let config = build_webp_config(lossless, cfg.quality, cfg.method, cfg.near_lossless) - .map_err(|e| RawError::Encode(EncodeError::WebP(e)))?; - let data_8bit = pack_rgb8(image); - let encoded = encode_webp_rgb(&data_8bit, image.width(), image.height(), &config) - .map_err(|e| RawError::Encode(EncodeError::WebP(e)))?; - let m = &cfg.common.metadata; - let exif_bytes = if m.embed_exif { - match ExifBuilder::new(metadata).build_bytes() { - Ok(bytes) => Some(bytes), - Err(e) => { - tracing::warn!("Failed to build EXIF for WebP: {e}"); - None - } - } - } else { - None - }; - let icc_bytes = if m.embed_icc { - Some(IccProfile::srgb().as_bytes().to_vec()) + let mut model = crate::metadata::bridge::to_gamut(metadata); + model.exif = m.embed_exif.then(|| ExifBuilder::new(metadata).build()); + if !m.embed_xmp { + model.xmp = None; + } else if metadata.xmp.is_some() && model.xmp.is_none() { + tracing::warn!("Skipping malformed XMP metadata while encoding WebP"); + } + model.icc = if m.embed_icc { + Some( + gamut_icc::IccProfile::parse(IccProfile::srgb().as_bytes()).map_err(|e| { + RawError::Encode(EncodeError::Encoding { + format: "WebP", + message: format!("WebP ICC encoding error: {e}"), + }) + })?, + ) } else { None }; - let xmp_bytes = if m.embed_xmp { - metadata.xmp.as_deref() - } else { - None + + let blocks = model.encode().map_err(|e| { + RawError::Encode(EncodeError::Encoding { + format: "WebP", + message: format!("WebP metadata encoding error: {e}"), + }) + })?; + + let mut encoder = match cfg.mode { + WebPMode::Lossless => WebpEncoder::lossless(), + WebPMode::Lossy => WebpEncoder::lossy(cfg.quality), }; + if let Some(exif) = blocks.exif.as_deref() { + encoder = encoder.with_exif(exif); + } + if let Some(xmp) = blocks.xmp.as_deref() { + encoder = encoder.with_xmp(xmp); + } + if let Some(icc) = blocks.icc.as_deref() { + encoder = encoder.with_icc_profile(icc); + } - mux_webp( - &encoded, - exif_bytes.as_deref(), - icc_bytes.as_deref(), - xmp_bytes, - ) - .map_err(|e| RawError::Encode(EncodeError::WebP(e))) + let encoding_error = |e: gamut_core::Error| { + RawError::Encode(EncodeError::Encoding { + format: "WebP", + message: format!("WebP encoding error: {e}"), + }) + }; + let dims = Dimensions::new(image.width(), image.height()).map_err(encoding_error)?; + let img = ImageRef::::new(&data_8bit, dims).map_err(encoding_error)?; + encoder.encode_to_vec(img).map_err(encoding_error) } // ── AVIF ────────────────────────────────────────────────────────────────────── diff --git a/crates/rawshift-image/src/formats/export.rs b/crates/rawshift-image/src/formats/export.rs index 5b2ae31..288b2e2 100644 --- a/crates/rawshift-image/src/formats/export.rs +++ b/crates/rawshift-image/src/formats/export.rs @@ -4,9 +4,8 @@ //! it is a *format-keyed* enum — each variant names one output format and //! carries the configuration of the backend that encodes it. There is no //! backend-selection axis: gamut is the encoder for every migrated format, -//! and the remaining non-gamut backends (libwebp, pending the gamut-webp -//! migration) are named honestly by their configuration struct -//! ([`LibwebpEncodeConfig`]) rather than by extra enum variants. +//! and permanent exceptions are named by their configuration struct rather +//! than by extra enum variants. //! //! `EncodeOptions` is `#[non_exhaustive]` so formats whose encoders are //! pending upstream (e.g. TIFF encode via gamut-tiff) can be added without a @@ -279,33 +278,26 @@ impl Default for JpegEncodeConfig { } } -/// Configuration for the `libwebp` WebP encoder. -#[derive(Debug, Clone, PartialEq)] +/// Configuration for the pure-Rust `gamut-webp` WebP encoder. +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct LibwebpEncodeConfig { +pub struct WebpEncodeConfig { /// Encoder-agnostic options. WebP output is always 8-bit. pub common: CommonEncodeOptions, /// Lossy or lossless mode. pub mode: WebPMode, - /// Quality, `0.0..=100.0`. For lossy this is image quality; for lossless it - /// is the compression effort. Higher quality is larger (lossy). Default: `75.0`. - pub quality: f32, - /// Compression method, `0` (fast) to `6` (slowest, best). Default: `4`. - pub method: u32, - /// Near-lossless preprocessing, `0..=100` (`100` = off). Lossless mode only. - /// Default: `100`. - pub near_lossless: u32, + /// Lossy quality, clamped to `0..=100` by gamut-webp; ignored in lossless + /// mode. Higher is better quality and generally larger. Default: `75`. + pub quality: u8, } -impl LibwebpEncodeConfig { +impl WebpEncodeConfig { /// Lossy encoding with sensible defaults. pub fn lossy() -> Self { Self { common: CommonEncodeOptions::default(), mode: WebPMode::Lossy, - quality: 75.0, - method: 4, - near_lossless: 100, + quality: 75, } } @@ -318,7 +310,7 @@ impl LibwebpEncodeConfig { } } -impl Default for LibwebpEncodeConfig { +impl Default for WebpEncodeConfig { fn default() -> Self { Self::lossy() } @@ -432,10 +424,9 @@ pub enum EncodeOptions { /// JPEG via `gamut-jpeg` (requires `jpeg-encode`). #[cfg(feature = "jpeg-encode")] Jpeg(JpegEncodeConfig), - /// WebP via `libwebp` (requires `webp-encode`; the gamut-webp migration is - /// blocked upstream — gamut#302, tracked by rawshift#24). + /// WebP via `gamut-webp` (requires `webp-encode`). #[cfg(feature = "webp-encode")] - WebP(LibwebpEncodeConfig), + WebP(WebpEncodeConfig), /// AVIF via `gamut-avif` (requires `avif-encode`). #[cfg(feature = "avif-encode")] Avif(AvifEncodeConfig), @@ -471,13 +462,13 @@ impl EncodeOptions { /// Lossy WebP with default configuration. #[cfg(feature = "webp-encode")] pub fn webp_lossy() -> Self { - Self::WebP(LibwebpEncodeConfig::lossy()) + Self::WebP(WebpEncodeConfig::lossy()) } /// Lossless WebP with default configuration. #[cfg(feature = "webp-encode")] pub fn webp_lossless() -> Self { - Self::WebP(LibwebpEncodeConfig::lossless()) + Self::WebP(WebpEncodeConfig::lossless()) } /// AVIF with default configuration (lossless). @@ -528,7 +519,7 @@ impl EncodeOptions { #[cfg(feature = "jpeg-encode")] EncodeOptions::Jpeg(_) => CodecId::new("jpeg/gamut"), #[cfg(feature = "webp-encode")] - EncodeOptions::WebP(_) => CodecId::new("webp/libwebp"), + EncodeOptions::WebP(_) => CodecId::new("webp/gamut"), #[cfg(feature = "avif-encode")] EncodeOptions::Avif(_) => CodecId::new("avif/gamut"), #[cfg(feature = "jxl-encode")] diff --git a/crates/rawshift-image/src/formats/mod.rs b/crates/rawshift-image/src/formats/mod.rs index 072ca4c..9b8cc8d 100644 --- a/crates/rawshift-image/src/formats/mod.rs +++ b/crates/rawshift-image/src/formats/mod.rs @@ -47,8 +47,8 @@ pub use heic::{HeicAuxImage, HeicAuxKind, HeicFile, heic_hw_decode_available}; pub use registry::{available_decoders, available_encoders}; pub use standard::{ AvifDecodeConfig, DecodeOptions, GifDecodeConfig, HeicDecodeConfig, ImageProbe, - JpegDecodeConfig, JxlDecodeConfig, LibwebpDecodeConfig, PngDecodeConfig, ResvgDecodeConfig, - StandardFormat, TiffDecodeConfig, ZunePpmDecodeConfig, decode_standard_image, + JpegDecodeConfig, JxlDecodeConfig, PngDecodeConfig, ResvgDecodeConfig, StandardFormat, + TiffDecodeConfig, WebpDecodeConfig, 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 8588cd9..064fcf2 100644 --- a/crates/rawshift-image/src/formats/registry.rs +++ b/crates/rawshift-image/src/formats/registry.rs @@ -44,8 +44,8 @@ pub fn available_encoders() -> Vec { )); #[cfg(feature = "webp-encode")] encoders.push(CodecInfo::new( - CodecId::new("webp/libwebp"), - "0.14", + CodecId::new("webp/gamut"), + "0.3", CodecDirection::Encode, )); // Version tracks the gamut-avif crate. Hand-maintained — update together @@ -97,8 +97,8 @@ pub fn available_decoders() -> Vec { )); #[cfg(feature = "webp-decode")] decoders.push(CodecInfo::new( - CodecId::new("webp/libwebp"), - "0.14", + CodecId::new("webp/gamut"), + "0.3", CodecDirection::Decode, )); // Version tracks the gamut-jxl crate (pure-Rust jxl-rs decode). diff --git a/crates/rawshift-image/src/formats/standard.rs b/crates/rawshift-image/src/formats/standard.rs index a252e96..8ab6b8b 100644 --- a/crates/rawshift-image/src/formats/standard.rs +++ b/crates/rawshift-image/src/formats/standard.rs @@ -513,16 +513,22 @@ fn decode_png(data: &[u8], cfg: &PngDecodeConfig) -> RawResult { #[cfg(feature = "webp-decode")] fn decode_webp(data: &[u8]) -> RawResult { - let (w, h, rgb) = crate::codecs::webp::decode_webp_rgb(data).map_err(|e| { + use gamut_core::{DecodeImage, ImageBuf, Rgb8}; + use gamut_webp::WebpDecoder; + + let decoded: ImageBuf = WebpDecoder::new().decode_image(data).map_err(|e| { RawError::Format(FormatError::ImageDecode { format: "WebP", - message: e, + message: e.to_string(), }) })?; - - let data_u16: Vec = rgb.iter().map(|&v| u8_to_u16(v)).collect(); - - RgbImage::new(w, h, data_u16) + let dims = decoded.dimensions(); + let data_u16 = decoded + .as_samples() + .iter() + .map(|&value| u8_to_u16(value)) + .collect(); + RgbImage::new(dims.width, dims.height, data_u16) } // ── JXL ────────────────────────────────────────────────────────────────────── @@ -868,7 +874,7 @@ macro_rules! empty_decode_config { } empty_decode_config!(JpegDecodeConfig, "gamut-jpeg"); -empty_decode_config!(LibwebpDecodeConfig, "libwebp"); +empty_decode_config!(WebpDecodeConfig, "gamut-webp"); empty_decode_config!(JxlDecodeConfig, "gamut-jxl"); empty_decode_config!(GifDecodeConfig, "gif"); empty_decode_config!(TiffDecodeConfig, "tiff"); @@ -881,10 +887,9 @@ empty_decode_config!(ZunePpmDecodeConfig, "zune-ppm"); /// /// The enum is *format-keyed*: each variant names one format, and there is no /// backend-selection axis — gamut is the decoder for every migrated format. -/// Where a non-gamut backend remains (libwebp and the `tiff` crate pending -/// blocked upstream migrations; `gif`/`resvg`/`zune-ppm` as permanent -/// exceptions) the configuration struct names it honestly (e.g. -/// [`LibwebpDecodeConfig`]), but the variant stays format-named. +/// Where a non-gamut backend remains (the `tiff` crate pending an upstream +/// migration; `gif`/`resvg`/`zune-ppm` as permanent exceptions), the +/// configuration struct names it honestly, but the variant stays format-named. /// /// Use [`DecodeOptions::default_for`] to obtain the default configuration for /// a format. RAW formats are intentionally absent — they are decoded through @@ -901,9 +906,9 @@ pub enum DecodeOptions { /// interlace; alpha/transparency is dropped, palettes expand to RGB). #[cfg(feature = "png-decode")] Png(PngDecodeConfig), - /// WebP via `libwebp`. + /// WebP via the pure-Rust `gamut-webp` decoder. #[cfg(feature = "webp-decode")] - WebP(LibwebpDecodeConfig), + WebP(WebpDecodeConfig), /// JPEG XL via `gamut-jxl` (the pure-Rust jxl-rs decoder). #[cfg(feature = "jxl-decode")] Jxl(JxlDecodeConfig), @@ -968,7 +973,7 @@ impl DecodeOptions { #[cfg(feature = "png-decode")] DecodeOptions::Png(_) => CodecId::new("png/gamut"), #[cfg(feature = "webp-decode")] - DecodeOptions::WebP(_) => CodecId::new("webp/libwebp"), + DecodeOptions::WebP(_) => CodecId::new("webp/gamut"), #[cfg(feature = "jxl-decode")] DecodeOptions::Jxl(_) => CodecId::new("jxl/gamut"), #[cfg(feature = "gif-decode")] @@ -999,7 +1004,7 @@ impl DecodeOptions { #[cfg(feature = "png-decode")] StandardFormat::Png => Some(DecodeOptions::Png(PngDecodeConfig::default())), #[cfg(feature = "webp-decode")] - StandardFormat::WebP => Some(DecodeOptions::WebP(LibwebpDecodeConfig::default())), + StandardFormat::WebP => Some(DecodeOptions::WebP(WebpDecodeConfig::default())), #[cfg(feature = "jxl-decode")] StandardFormat::Jxl => Some(DecodeOptions::Jxl(JxlDecodeConfig::default())), #[cfg(feature = "gif-decode")] @@ -1507,7 +1512,8 @@ mod probe_tests { } } -/// Extract EXIF metadata from a standard image without decoding pixel data. +/// Extract EXIF, ICC, and XMP metadata from a standard image without decoding +/// pixel data. /// /// Reads embedded EXIF from image file bytes and maps the tags to the unified /// [`ImageMetadata`](crate::core::metadata::ImageMetadata) type. Returns a @@ -1520,7 +1526,7 @@ mod probe_tests { /// |--------|----------------| /// | JPEG | APP1 EXIF + APP1 XMP + APP2 ICC segments (requires `jpeg`) | /// | TIFF | IFD0 EXIF tags | -/// | WebP | EXIF chunk | +/// | WebP | gamut-webp EXIF + ICC + XMP chunks (requires `webp-decode` or `webp-encode`) | /// | AVIF | gamut-avif Exif + ICC + XMP items (requires `avif-decode`) | /// | PNG | eXIf + iCCP + XMP iTXt chunks (requires `png`) | /// | HEIC | HEIF Exif + ICC + XMP items (requires the `heic` feature) | @@ -1562,9 +1568,15 @@ pub fn read_standard_image_metadata( return read_png_metadata(data); } + // WebP metadata chunks are located by gamut-webp and parsed through the + // unified gamut-metadata model before being projected into ImageMetadata. + #[cfg(any(feature = "webp-decode", feature = "webp-encode"))] + if format == StandardFormat::WebP { + return read_webp_metadata(data); + } + let container = match format { StandardFormat::Tiff => ExifContainer::Tiff, - StandardFormat::WebP => ExifContainer::WebP, // Formats without an EXIF extraction path (GIF, SVG, JXL, …), plus // JPEG/PNG/AVIF when no feature compiled the gamut metadata reader in. _ => return crate::core::metadata::ImageMetadata::default(), @@ -1573,6 +1585,34 @@ pub fn read_standard_image_metadata( ExifParser::parse_from_bytes(data, container) } +/// Extract EXIF / ICC / XMP from WebP RIFF chunks via `gamut-webp` and +/// `gamut-metadata`. +#[cfg(all( + feature = "exif", + any(feature = "webp-decode", feature = "webp-encode") +))] +fn read_webp_metadata(data: &[u8]) -> crate::core::metadata::ImageMetadata { + use gamut_metadata::{Metadata, MetadataBlock}; + + let Ok(meta) = gamut_webp::metadata(data) else { + return crate::core::metadata::ImageMetadata::default(); + }; + let mut blocks = Vec::with_capacity(3); + if let Some(exif) = meta.exif.as_deref() { + blocks.push(MetadataBlock::Exif(exif)); + } + if let Some(xmp) = meta.xmp.as_deref() { + blocks.push(MetadataBlock::Xmp(xmp)); + } + if let Some(icc) = meta.icc.as_deref() { + blocks.push(MetadataBlock::Icc(icc)); + } + let Ok(model) = Metadata::from_blocks(&blocks) else { + return crate::core::metadata::ImageMetadata::default(); + }; + crate::metadata::bridge::from_gamut(&model) +} + /// Extract EXIF / ICC / XMP from a JPEG's APP segments via `gamut-jpeg`. /// /// Returns default (empty) metadata when the stream is malformed or carries diff --git a/crates/rawshift-image/src/lib.rs b/crates/rawshift-image/src/lib.rs index 0e53e4e..4513d8f 100644 --- a/crates/rawshift-image/src/lib.rs +++ b/crates/rawshift-image/src/lib.rs @@ -15,15 +15,15 @@ //! ground: image primitives (`ImageBuf`, the sealed pixel vocabulary), colour //! (CICP code points, ICC), container/IFD parsing, the metadata stack //! (EXIF/ICC/XMP), and the codecs for every migrated format — JPEG, PNG, -//! JPEG XL, AVIF, HEIC, and DNG. rawshift adds what gamut deliberately does +//! WebP, JPEG XL, AVIF, HEIC, and DNG. rawshift adds what gamut deliberately does //! not model: the camera/sensor domain (CFA containers, demosaicing, the RAW //! colour pipeline), vendor tag catalogues, and the high-level API. //! //! Hardware still-frame decode of HEVC (HEIC) and AV1 (AVIF) is provided by //! the `rawshift-hwdec` crate through the `hw`/`hw-*` features. //! -//! The non-gamut backends that remain are either blocked upstream migrations -//! (`libwebp` for WebP, the `tiff` crate for TIFF) or permanent exceptions +//! The non-gamut backends that remain are either a blocked upstream migration +//! (the `tiff` crate for TIFF) or permanent exceptions //! (`gif`, `resvg`, `zune-ppm`); see the workspace upstream-first policy. //! //! [gamut]: https://github.com/visualcommons/gamut @@ -91,11 +91,10 @@ //! 3. **Directions** — `jpeg-decode`, `jpeg-encode`, `arw-decode`, … //! gamut-backed direction features pull their `gamut-*` dependency //! directly — gamut is the backend, there is no implementation choice. -//! 4. **Implementation aliases** — the six retained flags naming the +//! 4. **Implementation aliases** — the four retained flags naming the //! non-gamut backends: `gif-decode-gif` / `svg-decode-resvg` / -//! `ppm-decode-zune` (permanent exceptions) and `tiff-decode-tiff` / -//! `webp-decode-libwebp` / `webp-encode-libwebp` (blocked upstream -//! migrations). +//! `ppm-decode-zune` (permanent exceptions) and `tiff-decode-tiff` (blocked +//! upstream migration). //! 5. **Infrastructure** — `ifd-parser`, `exif`, `serde`, and the verified //! hardware decode flags `hw` / `hw-videotoolbox` / `hw-vaapi` / //! `hw-mediacodec` (see `docs/SUPPORT.md`). diff --git a/crates/rawshift-image/src/metadata/bridge.rs b/crates/rawshift-image/src/metadata/bridge.rs index 50c0b7f..c2c8a20 100644 --- a/crates/rawshift-image/src/metadata/bridge.rs +++ b/crates/rawshift-image/src/metadata/bridge.rs @@ -26,7 +26,9 @@ //! carriers back as blobs. use crate::core::metadata::ImageMetadata; -use crate::metadata::exif::{ExifBuilder, ExifParser}; +#[cfg(feature = "webp-encode")] +use crate::metadata::exif::ExifBuilder; +use crate::metadata::exif::ExifParser; use gamut_exif::ExifWriter; use gamut_metadata::Metadata; @@ -35,7 +37,7 @@ use gamut_metadata::Metadata; /// EXIF is rebuilt from the typed fields with [`ExifBuilder`]; the stored XMP /// and ICC blobs are parsed into their typed gamut forms (and skipped when /// malformed). See the module docs for what the projection drops. -#[allow(dead_code)] // consumed by the codec metadata slices (#24) +#[cfg(feature = "webp-encode")] pub fn to_gamut(md: &ImageMetadata) -> Metadata { let exif_model = ExifBuilder::new(md).build(); let exif = (!exif_model.image().fields().is_empty() @@ -61,7 +63,7 @@ pub fn to_gamut(md: &ImageMetadata) -> Metadata { /// parser uses (typed fields plus the generic `extra` mirror, and the /// re-serialized blob in `exif_raw`); XMP and ICC are serialized back into /// their blob fields. -#[allow(dead_code)] // consumed by the codec metadata slices (#24) +#[cfg(any(feature = "webp-decode", feature = "webp-encode"))] pub fn from_gamut(metadata: &Metadata) -> ImageMetadata { let mut md = match &metadata.exif { Some(exif) => ExifParser::parse_metadata(exif), diff --git a/crates/rawshift-image/src/metadata/exif.rs b/crates/rawshift-image/src/metadata/exif.rs index 4cfcf53..423ac70 100644 --- a/crates/rawshift-image/src/metadata/exif.rs +++ b/crates/rawshift-image/src/metadata/exif.rs @@ -49,6 +49,7 @@ pub struct ExifBuilder<'a> { impl<'a> ExifBuilder<'a> { /// Create a new ExifBuilder from ImageMetadata. + #[cfg_attr(not(any_standard_encode), allow(dead_code))] pub fn new(metadata: &'a ImageMetadata) -> Self { Self { metadata } } @@ -260,8 +261,6 @@ impl<'a> ExifBuilder<'a> { pub enum ExifContainer { /// TIFF — the whole file is the TIFF stream. Tiff, - /// WebP — RIFF `EXIF` chunk. - WebP, } /// Parses EXIF metadata from image file bytes into an [`ImageMetadata`]. @@ -278,7 +277,6 @@ impl ExifParser { pub fn parse_from_bytes(file_data: &[u8], container: ExifContainer) -> ImageMetadata { let blob = match container { ExifContainer::Tiff => Some(file_data.to_vec()), - ExifContainer::WebP => extract_exif_from_webp(file_data), }; match blob { Some(blob) => Self::parse_exif_blob(&blob), @@ -568,37 +566,6 @@ fn exif_value_to_metadata(value: &Value) -> crate::core::metadata::MetadataValue } } -// ── Container-side EXIF blob location ───────────────────────────────────────── -// -// 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. (JPEG and PNG already -// did: `gamut_jpeg::metadata` locates and strips the APP1/APP2 payloads, and -// `gamut_png::PngDecoder::decode` surfaces the eXIf/iCCP/XMP chunk payloads.) - -/// Extract the payload of a WebP RIFF `EXIF` chunk. -fn extract_exif_from_webp(data: &[u8]) -> Option> { - if data.get(..4) != Some(b"RIFF") || data.get(8..12) != Some(b"WEBP") { - return None; - } - let riff_len = u32::from_le_bytes(data.get(4..8)?.try_into().unwrap()) as usize; - let end = (8 + riff_len).min(data.len()); - let mut pos = 12usize; - while let Some(header) = data.get(pos..pos + 8) { - if pos + 8 > end { - return None; - } - let chunk_type = &header[..4]; - let len = u32::from_le_bytes(header[4..8].try_into().unwrap()) as usize; - let payload = data.get(pos + 8..pos + 8 + len)?; - if chunk_type == b"EXIF" { - return Some(payload.to_vec()); - } - pos += 8 + len + (len & 1); // chunks are padded to even sizes - } - None -} - // (The AVIF `Exif`-item read path lives in `formats::avif` on gamut-avif's // item surface — the box-scanning reader that used to live here was replaced // in #33. The write path — `insert_item` splicing — remains in @@ -752,32 +719,5 @@ mod tests { ExifParser::parse_exif_blob(b"not a tiff stream"), ImageMetadata::default() ); - assert_eq!( - ExifParser::parse_from_bytes(b"\x00\x01\x02\x03", ExifContainer::WebP), - ImageMetadata::default() - ); - } - - #[test] - fn test_extract_from_webp_exif_chunk() { - let md = sample_metadata(); - let tiff = ExifBuilder::new(&md).build_bytes().expect("bare tiff"); - - let mut chunks = Vec::new(); - chunks.extend_from_slice(b"EXIF"); - chunks.extend_from_slice(&(tiff.len() as u32).to_le_bytes()); - chunks.extend_from_slice(&tiff); - if tiff.len() % 2 == 1 { - chunks.push(0); - } - - let mut webp = Vec::new(); - webp.extend_from_slice(b"RIFF"); - webp.extend_from_slice(&((4 + chunks.len()) as u32).to_le_bytes()); - webp.extend_from_slice(b"WEBP"); - webp.extend_from_slice(&chunks); - - let parsed = ExifParser::parse_from_bytes(&webp, ExifContainer::WebP); - assert_eq!(parsed.exif.exposure_time, Some(URational::new(1, 250))); } } diff --git a/crates/rawshift-image/src/prelude.rs b/crates/rawshift-image/src/prelude.rs index aa3e62b..7de57c6 100644 --- a/crates/rawshift-image/src/prelude.rs +++ b/crates/rawshift-image/src/prelude.rs @@ -47,16 +47,17 @@ pub use crate::error::{ // formats — encode option system pub use crate::formats::export::{ AvifEncodeConfig, BitDepth, CommonEncodeOptions, EncodeOptions, JpegDensity, JpegDensityUnit, - JpegEncodeConfig, JpegSubsampling, JxlEncodeConfig, LibwebpEncodeConfig, MetadataEmbedOptions, - OutputFormat, PngCompressionLevel, PngEncodeConfig, PngFilterStrategy, PngFilterType, WebPMode, + JpegEncodeConfig, JpegSubsampling, JxlEncodeConfig, MetadataEmbedOptions, OutputFormat, + PngCompressionLevel, PngEncodeConfig, PngFilterStrategy, PngFilterType, WebPMode, + WebpEncodeConfig, }; // formats — decoders, format detection, encode/decode entry points pub use crate::formats::{ AvifDecodeConfig, DecodeOptions, GifDecodeConfig, HeicDecodeConfig, ImageProbe, - JpegDecodeConfig, JxlDecodeConfig, LibwebpDecodeConfig, PngDecodeConfig, ResvgDecodeConfig, - StandardFormat, TiffDecodeConfig, ZunePpmDecodeConfig, 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, + JpegDecodeConfig, JxlDecodeConfig, PngDecodeConfig, ResvgDecodeConfig, StandardFormat, + TiffDecodeConfig, WebpDecodeConfig, ZunePpmDecodeConfig, 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 bb5934f..45749c1 100644 --- a/crates/rawshift-image/tests/export_format_tests.rs +++ b/crates/rawshift-image/tests/export_format_tests.rs @@ -8,8 +8,8 @@ use rawshift_image::core::RgbImage; use rawshift_image::core::metadata::ImageMetadata; use rawshift_image::formats::export::{ - BitDepth, CommonEncodeOptions, EncodeOptions, JpegEncodeConfig, LibwebpEncodeConfig, - MetadataEmbedOptions, PngEncodeConfig, WebPMode, + BitDepth, CommonEncodeOptions, EncodeOptions, JpegEncodeConfig, MetadataEmbedOptions, + PngEncodeConfig, WebPMode, WebpEncodeConfig, }; use rawshift_image::formats::{encode_rgb_image, encode_rgb_image_to_vec}; use std::fs; @@ -264,10 +264,10 @@ mod jpeg_tests { mod webp_tests { use super::*; - fn webp(exif: bool, icc: bool, xmp: bool) -> LibwebpEncodeConfig { - LibwebpEncodeConfig { + fn webp(exif: bool, icc: bool, xmp: bool) -> WebpEncodeConfig { + WebpEncodeConfig { common: common(exif, icc, xmp), - ..LibwebpEncodeConfig::lossy() + ..WebpEncodeConfig::lossy() } } @@ -342,8 +342,10 @@ mod webp_tests { let img = synthetic_image(); let path = temp_path("export_xmp.webp"); + let mut xmp = gamut_xmp::XmpMeta::new(); + xmp.set_text(gamut_xmp::WellKnownNs::Xmp.uri(), "CreatorTool", "rawshift"); let meta = ImageMetadata { - xmp: Some(b"test".to_vec()), + xmp: Some(xmp.to_packet()), ..Default::default() }; @@ -356,6 +358,42 @@ mod webp_tests { fs::remove_file(&path).ok(); } + #[test] + fn test_webp_metadata_round_trip_through_gamut_bridge() { + use rawshift_image::core::metadata::{CameraInfo, ExifInfo}; + use rawshift_image::formats::{StandardFormat, read_standard_image_metadata}; + + let mut xmp = gamut_xmp::XmpMeta::new(); + xmp.set_text(gamut_xmp::WellKnownNs::Xmp.uri(), "CreatorTool", "rawshift"); + let metadata = ImageMetadata { + camera: CameraInfo { + make: "Visual Commons".into(), + model: "WebP bridge".into(), + ..Default::default() + }, + exif: ExifInfo { + iso: Some(320), + ..Default::default() + }, + xmp: Some(xmp.to_packet()), + ..Default::default() + }; + let encoded = encode_rgb_image_to_vec( + &synthetic_image(), + &metadata, + &EncodeOptions::WebP(webp(true, true, true)), + ) + .expect("encode WebP metadata"); + + let decoded = read_standard_image_metadata(&encoded, StandardFormat::WebP); + assert_eq!(decoded.camera.make, metadata.camera.make); + assert_eq!(decoded.camera.model, metadata.camera.model); + assert_eq!(decoded.exif.iso, Some(320)); + assert!(decoded.exif_raw.is_some()); + assert!(decoded.icc_profile.is_some()); + assert!(decoded.xmp.is_some()); + } + #[test] fn test_webp_export_default_options() { let img = synthetic_image(); @@ -384,9 +422,9 @@ mod webp_tests { let img = RgbImage::new(64, 64, data).expect("valid RGB buffer"); let mut low = webp(false, false, false); - low.quality = 10.0; + low.quality = 10; let mut high = webp(false, false, false); - high.quality = 95.0; + high.quality = 95; let low = encode_rgb_image_to_vec(&img, &ImageMetadata::default(), &EncodeOptions::WebP(low)) @@ -875,23 +913,22 @@ mod encode_options_tests { #[test] fn test_webp_config_defaults() { - let cfg = LibwebpEncodeConfig::default(); + let cfg = WebpEncodeConfig::default(); assert_eq!(cfg.mode, WebPMode::Lossy, "WebP default mode is Lossy"); - assert!((cfg.quality - 75.0).abs() < f32::EPSILON); - assert_eq!(cfg.method, 4); - assert_eq!(cfg.near_lossless, 100); + assert_eq!(cfg.quality, 75); } #[test] fn test_webp_named_constructors() { - assert_eq!(LibwebpEncodeConfig::lossy().mode, WebPMode::Lossy); - assert_eq!(LibwebpEncodeConfig::lossless().mode, WebPMode::Lossless); + assert_eq!(WebpEncodeConfig::lossy().mode, WebPMode::Lossy); + assert_eq!(WebpEncodeConfig::lossless().mode, WebPMode::Lossless); } #[test] fn test_format_and_codec_id() { assert_eq!(EncodeOptions::png().format(), OutputFormat::Png); assert_eq!(EncodeOptions::jpeg().codec_id().id, "jpeg/gamut"); + assert_eq!(EncodeOptions::webp_lossy().codec_id().id, "webp/gamut"); } } @@ -974,9 +1011,26 @@ mod in_memory_tests { let encoders = available_encoders(); assert!(!encoders.is_empty(), "default build has encoders"); assert!(encoders.iter().all(|c| c.id.id.contains('/'))); + let webp_encoder = encoders + .iter() + .find(|codec| codec.id.id == "webp/gamut") + .expect("gamut WebP encoder"); + assert_eq!(webp_encoder.version, "0.3"); let decoders = available_decoders(); assert!(!decoders.is_empty(), "default build has decoders"); + let webp_decoder = decoders + .iter() + .find(|codec| codec.id.id == "webp/gamut") + .expect("gamut WebP decoder"); + assert_eq!(webp_decoder.version, "0.3"); + assert_eq!( + rawshift_image::formats::DecodeOptions::default_for(StandardFormat::WebP) + .expect("default WebP decoder") + .codec_id() + .id, + "webp/gamut" + ); } #[test] diff --git a/crates/rawshift-image/tests/fixtures/libwebp_metadata.rs b/crates/rawshift-image/tests/fixtures/libwebp_metadata.rs new file mode 100644 index 0000000..2ea0984 --- /dev/null +++ b/crates/rawshift-image/tests/fixtures/libwebp_metadata.rs @@ -0,0 +1,24 @@ +//! WebP interoperability fixture produced before the gamut-webp migration. +//! +//! The bytes were encoded from a 1×1 RGB image by rawshift at commit +//! `e00a0bf`, using its `libwebp-sys 0.14.2` codec/mux wrapper in lossless +//! mode. The container carries EXIF, sRGB ICC, and XMP chunks. Keeping the +//! fixture as hex makes its external-backend provenance reviewable without +//! adding an opaque binary to the repository. + +const LIBWEBP_METADATA_WEBP_HEX: &str = "524946469002000057454250565038580a0000002c00000000000000000049434350c8010000000001c800000000021000006d6e74725247422058595a2000000000000000000000000061637370000000000000000000000000000000000000000000000000000000000000f6d6000100000000d32d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000964657363000000f00000005f6370727400000150000000167774707400000168000000147258595a0000017c000000146758595a00000190000000146258595a000001a40000001472545243000001b80000000e67545243000001b80000000e62545243000001b80000000e64657363000000000000000573524742000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000074657874000000005075626c696320446f6d61696e00000058595a20000000000000f6d6000100000000d32d58595a200000000000006fa2000038f50000039058595a2000000000000062990000b7860000185258595a2000000000000024a000000f840000b6cf637572760000000000000001023800005650384c110000002f000000000750ab4a54b3ff8188e87f0000455849460e00000049492a0008000000000000000000584d5020720000003c783a786d706d65746120786d6c6e733a783d2761646f62653a6e733a6d6574612f273e3c7264663a52444620786d6c6e733a7264663d27687474703a2f2f7777772e77332e6f72672f313939392f30322f32322d7264662d73796e7461782d6e7323272f3e3c2f783a786d706d6574613e"; + +pub fn libwebp_metadata_webp() -> Vec { + LIBWEBP_METADATA_WEBP_HEX + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let digit = |byte: u8| match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + _ => panic!("invalid fixture hex"), + }; + (digit(pair[0]) << 4) | digit(pair[1]) + }) + .collect() +} diff --git a/crates/rawshift-image/tests/standard_decode_fixtures.rs b/crates/rawshift-image/tests/standard_decode_fixtures.rs index 13c084d..192150e 100644 --- a/crates/rawshift-image/tests/standard_decode_fixtures.rs +++ b/crates/rawshift-image/tests/standard_decode_fixtures.rs @@ -13,6 +13,9 @@ use rawshift_image::formats::{ use serde::Deserialize; use std::path::PathBuf; +#[path = "fixtures/libwebp_metadata.rs"] +mod libwebp_metadata; + /// Ground truth for a standard format fixture. #[derive(Debug, Deserialize)] #[allow(dead_code)] @@ -48,6 +51,29 @@ fn test_data_path(rel: &str) -> PathBuf { .join(rel) } +#[cfg(feature = "webp-decode")] +#[test] +fn decode_and_read_metadata_from_libwebp_fixture() { + let data = libwebp_metadata::libwebp_metadata_webp(); + let image = decode_standard_image(&data, StandardFormat::WebP) + .expect("gamut-webp must decode a libwebp-produced file"); + assert_eq!((image.width(), image.height()), (1, 1)); + assert_eq!(image.data(), &[0x1212, 0x5656, 0x9a9a]); + + let chunks = gamut_webp::metadata(&data).expect("fixture RIFF metadata"); + assert!(chunks.exif.is_some()); + assert!(chunks.icc.is_some()); + assert!(chunks.xmp.is_some()); + + let metadata = read_standard_image_metadata(&data, StandardFormat::WebP); + assert!(metadata.exif_raw.is_some(), "typed EXIF bridge"); + assert!(metadata.icc_profile.is_some(), "typed ICC bridge"); + assert!( + metadata.xmp.is_none(), + "the fixture's empty RDF graph is normalized out by gamut-metadata" + ); +} + fn test_fixture_path(rel: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../..") diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 90673ca..6c65dc6 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -20,7 +20,7 @@ is missing or no hardware decoder is usable at runtime. Pre-migration baselines were **not captured** before the gamut migration epic (#38) started, so no repo-wide before/after comparison exists. Per-codec before/after numbers were recorded in the individual migration PRs where they -were measured (PNG: #47, JPEG: #52). The table below is the **post-migration +were measured (PNG: #47, JPEG: #52, WebP: #24). The table below is the **post-migration baseline** — the reference point for future regressions — captured on 2026-07-18 at commit `217c596` (post `chore(features,ci)`), `--quick` mode. @@ -42,12 +42,31 @@ re-measure with a full `cargo bench` run before acting on small deltas. | jpeg_decode_512x512 (gamut-jpeg) | 6.04 ms | | png_encode_512x512 (gamut-png) | 23.0 ms | | png_decode_512x512 (gamut-png) | 4.72 ms | +| webp_encode_512x512 (gamut-webp) | 9.97 ms² | +| webp_decode_512x512 (gamut-webp) | 4.88 ms² | | heic_hw_decode_primary_512x512 (gamut-heic + VAAPI HEVC) | 9.20 ms | | avif_hw_decode_primary_512x512 (gamut-avif + VAAPI AV1) | 9.48 ms | ¹ Lazy zero-page allocation artifact at this size; not a real throughput number. +² Added from the full (non-`--quick`) WebP migration run on 2026-08-05. + +### WebP migration comparison (2026-08-05) + +Full Criterion runs on the same Ryzen 7 7800X3D host, using the synthetic +512×512 gradient and each backend's default lossy quality (75): + +| Benchmark | libwebp 0.14.2 | gamut-webp 0.3.1 | +|---|---:|---:| +| webp_encode_512x512 | 7.40 ms | 9.97 ms | +| webp_decode_512x512 | 766 µs | 4.88 ms | + +The decode comparison includes each encoder's own output, so it measures the +end-to-end default round trip rather than holding the compressed stream +constant. These values establish the gamut-webp baseline; performance work +belongs upstream under the repository's upstream-first policy. + ### demosaic | Benchmark | Time (mid estimate) | diff --git a/docs/V1_DESIGN.md b/docs/V1_DESIGN.md index 59e0635..7d6076e 100644 --- a/docs/V1_DESIGN.md +++ b/docs/V1_DESIGN.md @@ -78,7 +78,7 @@ pub struct RgbImage { buf: ImageBuf, color: ColorDescription, Options are **format-keyed** (the backend-selection axis is gone — gamut is the backend). Every variant is format-named; where a non-gamut backend remains (blocked migrations, permanent exceptions) the *config struct* names -it honestly (`LibwebpDecodeConfig`, `TiffDecodeConfig`, `GifDecodeConfig`, +it honestly (`TiffDecodeConfig`, `GifDecodeConfig`, `ResvgDecodeConfig`, `ZunePpmDecodeConfig`). Config structs are rawshift-owned mirrors of committed gamut knobs; see the tables below. @@ -96,7 +96,7 @@ rawshift-owned mirrors of committed gamut knobs; see the tables below. | --- | --- | | `Png` | `common`, `compression` (Default), `filter` (MinSumAbs), `auto_reduce` (false) | | `Jpeg` | `common`, `quality: u8` (90), `subsampling` (4:2:0), `progressive` (false), `restart_interval: u16` (0), `density` (1:1 aspect ratio) | -| `WebP` | `common`, `mode` (Lossy), `quality: f32` (75.0), `method: u32` (4), `near_lossless: u32` (100 = off) | +| `WebP` | `common`, `mode` (Lossy), `quality: u8` (75, lossy only) | | `Avif` | `common`, `lossless: bool` (true), `quality: u8` (80, lossy only); 10/12-bit output pending gamut#251 | | `Jxl` | `common`, `lossless: bool` (true), `distance: f32` (1.0), `effort: u8` (7), `use_container` (false), `coded_bit_depth` (None) | | `Dng` | `software`, `embed_exif` (true), `embed_gps` (own config shape; LinearRaw 16-bit uncompressed) | @@ -113,7 +113,7 @@ BitDepth (Sixteen where supported) }`. | --- | --- | --- | --- | | JPEG | ✅ | ✅ | gamut JPEG (upstream #28) | | PNG | ✅ | ✅ | gamut-png (decoder upstream) | -| WebP | ✅ | ✅ | libwebp (`libwebp-sys`) — gamut-webp migration blocked upstream (gamut#302, tracked by rawshift#24) | +| WebP | ✅ | ✅ | gamut-webp (pure Rust VP8/VP8L) | | JXL | ✅ (pure Rust) | ✅ (libjxl via gamut-jxl-sys) | gamut-jxl | | TIFF | ✅ | — | `tiff` crate — gamut-tiff migration (incl. new encode) blocked upstream (gamut#299/#300, tracked by rawshift#22) | | AVIF | ✅ hardware | ✅ (Rgb8 lossless now; 10/12-bit upstream) | gamut-avif container + rawshift-hwdec AV1 | @@ -148,10 +148,9 @@ tier-1 target expecting success. Deleted feature axes: the per-implementation flags of every gamut-backed format, `container-embed`, `tiff-parser` (replaced by `ifd-parser` over gamut-ifd), `heic-vendored`, every `*-vendored` linking flag. Retained (delivered reality, post-#34 audit): -six implementation aliases — `gif-decode-gif` / `svg-decode-resvg` / +four implementation aliases — `gif-decode-gif` / `svg-decode-resvg` / `ppm-decode-zune` (permanent exceptions per AGENTS.md) and -`tiff-decode-tiff` / `webp-decode-libwebp` / `webp-encode-libwebp` (blocked -migrations: gamut#299/#300 via rawshift#22, gamut#302 via rawshift#24) — +`tiff-decode-tiff` (blocked migration: gamut#299/#300 via rawshift#22) — plus the `zune-runtime` and `exif` infrastructure flags they and the gamut metadata stack hang off. @@ -200,8 +199,7 @@ ravif, avif-serialize, libaom-sys, image, libheif-rs, little_exif, img-parts, binrw. `build.rs` shrinks to cfg aliases + feature/target verification. -Still present, pending blocked upstream migrations (post-#34 audit): -`libwebp-sys` (gamut-webp — gamut#302 via rawshift#24) and `tiff` +Still present, pending a blocked upstream migration (post-#34 audit): `tiff` (gamut-tiff — gamut#299/#300 via rawshift#22). Permanent exceptions that stay: `gif`, `resvg`, `zune-ppm` (+ its `zune-core` runtime).