From 6c6ecf3c3ebf9bc76ab1e74829c437ae67facff3 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Sat, 18 Jul 2026 17:07:03 -0400 Subject: [PATCH] feat(avif)!: migrate AVIF encode to gamut-avif; delete libaom and ravif MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AVIF encoding moves to gamut_avif::AvifEncoder (Rgb8; lossless default plus lossy quality 0-100 — gamut ships lossy at this pin, so it is exposed rather than artificially hidden). One AvifEncodeConfig replaces RavifEncodeConfig/ LibaomEncodeConfig/AvifRateControl; EncodeOptions::AvifRavif/AvifLibaom collapse to Avif; codec id avif/gamut. AVIF decode is untouched (#33). Per the no-interim policy, the entire libaom stack is deleted in this change even though gamut does not yet cover its 10/12-bit capability: codecs/avif_libaom.rs, the aom build.rs slice (jpegli is now the only build.rs consumer), deps ravif/libaom-sys/avif-serialize, and the avif-encode-libaom(-vendored) features — 26 crates leave the lockfile (rav1e, nasm-rs and friends). Ten/Twelve-bit requests now return EncodeError::UnsupportedBitDepth citing justin13888/gamut#251; Eight and Sixteen (16->8, as with ravif) remain accepted. Documented in the config rustdoc and README. gamut-avif cannot attach EXIF/XMP/ICC on encode yet (deferred upstream), so the encode path keeps rawshift's existing ISOBMFF item splicing on the gamut-produced container; avif_exif_round_trip passes unchanged. New tests cover lossless+lossy encode and a decode-back round trip through the independent dav1d-backed decoder. CI drops nasm (nothing needs it once rav1e is gone) and the stale rav1e/libaom comments; dav1d/cmake/clang stay for decode and the remaining vendored builds. BREAKING CHANGE: AVIF encode config/variant names changed; 10/12-bit AVIF encode is temporarily unavailable pending justin13888/gamut#251. --- .github/workflows/ci.yml | 11 +- Cargo.lock | 433 ++--------------- Cargo.toml | 1 + TEST_FIXTURES.md | 1 - crates/rawshift-core/src/codec.rs | 2 +- crates/rawshift-image/Cargo.toml | 60 +-- crates/rawshift-image/README.md | 26 +- crates/rawshift-image/build.rs | 91 ---- .../examples/generate_test_fixtures.rs | 27 -- .../rawshift-image/src/codecs/avif_libaom.rs | 454 ------------------ crates/rawshift-image/src/codecs/mod.rs | 2 - crates/rawshift-image/src/formats/encode.rs | 143 ++---- crates/rawshift-image/src/formats/export.rs | 126 ++--- crates/rawshift-image/src/formats/registry.rs | 14 +- crates/rawshift-image/src/formats/standard.rs | 7 +- crates/rawshift-image/src/prelude.rs | 6 +- .../tests/export_format_tests.rs | 153 +++--- 17 files changed, 232 insertions(+), 1325 deletions(-) delete mode 100644 crates/rawshift-image/src/codecs/avif_libaom.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f40838..d4acc0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,16 +67,13 @@ jobs: with: toolchain: "1.92.0" - uses: Swatinem/rust-cache@v2 - # `rawshift-image/full` enables `avif` (links system dav1d) and `heic`. + # `rawshift-image/full` enables `avif` (decode links system dav1d via the + # `image` crate; encode is pure-Rust gamut-avif) and `heic`. # Ubuntu 24.04's libheif-dev is 1.17.x, but libheif-sys requires >= 1.21, # so we add `heic-vendored` to build libheif 1.21.2 from source instead. - # `nasm` is required to build `rav1e` (pulled in by the `image` crate's - # AVIF encoder) with its `asm` feature enabled. # `jxl-encode` builds libjxl hermetically through gamut-jxl-sys (cmake + # clang for its bindings). `jpeg-encode-jpegli-vendored` builds the # vendored google/jpegli submodule the same way (cmake + clang). - # `avif-encode-libaom-vendored` builds libaom from source via cmake (through - # `libaom-sys`); it reuses the same `nasm` + `cmake` + `clang`/`libclang`. - 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 @@ -89,14 +86,14 @@ jobs: - name: Install system libraries run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libdav1d-dev nasm cmake clang libclang-dev + sudo apt-get install -y --no-install-recommends libdav1d-dev cmake clang libclang-dev - name: Fetch test fixtures run: bash scripts/fetch_test_fixtures.sh env: 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/heic-vendored,rawshift-image/jpeg-encode-jpegli-vendored,rawshift-image/avif-encode-libaom-vendored + - run: cargo test --workspace --features rawshift-image/full,rawshift-image/heic-vendored,rawshift-image/jpeg-encode-jpegli-vendored image-only: name: Build (image only) diff --git a/Cargo.lock b/Cargo.lock index 31a5d6d..f206f10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,24 +29,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "anes" version = "0.1.6" @@ -103,29 +85,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "array-init" version = "2.1.0" @@ -144,15 +103,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -172,49 +122,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" -dependencies = [ - "arrayvec", -] - [[package]] name = "base64" version = "0.22.1" @@ -230,7 +137,7 @@ dependencies = [ "bitflags 2.11.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools", "log", "prettyplease", "proc-macro2", @@ -262,15 +169,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -280,12 +178,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - [[package]] name = "bumpalo" version = "3.20.2" @@ -346,7 +238,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom 7.1.3", + "nom", ] [[package]] @@ -464,15 +356,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "core_maths" version = "0.1.1" @@ -503,7 +386,7 @@ dependencies = [ "clap", "criterion-plot", "is-terminal", - "itertools 0.10.5", + "itertools", "num-traits", "once_cell", "oorandom", @@ -524,7 +407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools 0.10.5", + "itertools", ] [[package]] @@ -623,26 +506,6 @@ dependencies = [ "syn", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -767,6 +630,29 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "795cbfc56d419a7ce47ccbb7504dd9a5b7c484c083c356e797de08bd988d9629" +[[package]] +name = "gamut-av1" +version = "0.4.0" +source = "git+https://github.com/justin13888/gamut?rev=dde9f640ab02ec9c3437c3f1181164f6e7c60151#dde9f640ab02ec9c3437c3f1181164f6e7c60151" +dependencies = [ + "gamut-bitstream", + "gamut-color", + "gamut-core", + "gamut-dsp", +] + +[[package]] +name = "gamut-avif" +version = "1.0.0" +source = "git+https://github.com/justin13888/gamut?rev=dde9f640ab02ec9c3437c3f1181164f6e7c60151#dde9f640ab02ec9c3437c3f1181164f6e7c60151" +dependencies = [ + "gamut-av1", + "gamut-codec-abi", + "gamut-color", + "gamut-core", + "gamut-isobmff", +] + [[package]] name = "gamut-bitstream" version = "0.2.2" @@ -813,6 +699,11 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "gamut-dsp" +version = "2.0.0" +source = "git+https://github.com/justin13888/gamut?rev=dde9f640ab02ec9c3437c3f1181164f6e7c60151#dde9f640ab02ec9c3437c3f1181164f6e7c60151" + [[package]] name = "gamut-exif" version = "1.0.0" @@ -851,6 +742,14 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gamut-isobmff" +version = "2.0.0" +source = "git+https://github.com/justin13888/gamut?rev=dde9f640ab02ec9c3437c3f1181164f6e7c60151#dde9f640ab02ec9c3437c3f1181164f6e7c60151" +dependencies = [ + "gamut-core", +] + [[package]] name = "gamut-jxl" version = "0.3.0" @@ -1021,12 +920,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - [[package]] name = "indenter" version = "0.3.4" @@ -1043,17 +936,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "is-terminal" version = "0.4.17" @@ -1080,15 +962,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" @@ -1190,31 +1063,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "libaom-sys" -version = "0.17.2+libaom.3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c4fce8aaf0c2d8534529b0698ed98ec1da8d35319588d20b0d79ce6446e4194" -dependencies = [ - "cmake", -] - [[package]] name = "libc" version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" -[[package]] -name = "libfuzzer-sys" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" -dependencies = [ - "arbitrary", - "cc", -] - [[package]] name = "libheif-rs" version = "2.7.0" @@ -1284,15 +1138,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - [[package]] name = "matchers" version = "0.2.0" @@ -1302,16 +1147,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - [[package]] name = "md-5" version = "0.10.6" @@ -1388,22 +1223,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "nasm-rs" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" -dependencies = [ - "jobserver", - "log", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "nom" version = "7.1.3" @@ -1414,21 +1233,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1529,18 +1333,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pico-args" version = "0.5.0" @@ -1600,15 +1392,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1650,25 +1433,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn", -] - [[package]] name = "pxfm" version = "0.1.28" @@ -1705,87 +1469,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cc", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "nasm-rs", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand", - "rand_chacha", - "simd_helpers", - "thiserror", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - [[package]] name = "rawshift" version = "0.1.1" @@ -1807,13 +1490,13 @@ dependencies = [ name = "rawshift-image" version = "0.1.1" dependencies = [ - "avif-serialize", "bindgen", "cc", "clap", "cmake", "criterion", "eyre", + "gamut-avif", "gamut-core", "gamut-dng", "gamut-exif", @@ -1827,11 +1510,9 @@ dependencies = [ "image", "img-parts", "jpeg-encoder", - "libaom-sys", "libheif-rs", "libwebp-sys", "pkg-config", - "ravif", "rawshift-core", "rayon", "resvg", @@ -2070,15 +1751,6 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - [[package]] name = "simplecss" version = "0.2.2" @@ -2119,12 +1791,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "static_assertions" version = "1.1.0" @@ -2498,17 +2164,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" @@ -2661,12 +2316,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "zerocopy" version = "0.8.40" diff --git a/Cargo.toml b/Cargo.toml index 6a85972..d2f38c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ gamut-icc = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02e # `jxl-decode` / `jxl-encode` features select the halves explicitly. gamut-jxl = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151", default-features = false } gamut-png = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151" } +gamut-avif = { git = "https://github.com/justin13888/gamut", rev = "dde9f640ab02ec9c3437c3f1181164f6e7c60151" } # Shared infrastructure dependencies (used across multiple workspace crates). thiserror = "2.0" diff --git a/TEST_FIXTURES.md b/TEST_FIXTURES.md index 76d7783..4d3df11 100644 --- a/TEST_FIXTURES.md +++ b/TEST_FIXTURES.md @@ -60,7 +60,6 @@ test_data/ │ ├── webp/test_8x8.webp │ ├── svg/test_8x8.svg │ ├── avif/test_8x8.avif # (with avif-encode feature) -│ ├── avif_libaom/test_8x8.avif # (with avif-encode-libaom feature; 10-bit) │ └── jxl/test_8x8.jxl # (with jxl-encode feature) └── .device-versions/ # Per-device version stamps (written by fetch script) ├── sony-ilce-6700 # contains "1" diff --git a/crates/rawshift-core/src/codec.rs b/crates/rawshift-core/src/codec.rs index 227bfed..18eeb57 100644 --- a/crates/rawshift-core/src/codec.rs +++ b/crates/rawshift-core/src/codec.rs @@ -7,7 +7,7 @@ use std::fmt; /// A stable identifier for one codec implementation, in `"{format}/{impl}"` form. /// -/// For example `"jpeg/mozjpeg"` or `"avif/ravif"`. The string is stable across +/// For example `"jpeg/mozjpeg"` or `"avif/gamut"`. The string is stable across /// releases, so callers may use it as part of a cache key. /// /// Only [`Serialize`](serde::Serialize) is derived: the `&'static str` is diff --git a/crates/rawshift-image/Cargo.toml b/crates/rawshift-image/Cargo.toml index 82ce678..ac0bbe1 100644 --- a/crates/rawshift-image/Cargo.toml +++ b/crates/rawshift-image/Cargo.toml @@ -13,12 +13,11 @@ categories = ["multimedia::images", "encoding", "parser-implementations"] readme = "README.md" [package.metadata.docs.rs] -# `heic-vendored`, the `jpeg-encode-jpegli*`, and `avif-encode-libaom*` -# 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`.) +# `heic-vendored` and 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`.) features = ["full"] rustdoc-args = ["--cfg", "docsrs"] @@ -46,6 +45,11 @@ gamut-metadata = { workspace = true, optional = true } # strategy + lossless auto-reduce, and eXIf/iCCP/XMP-iTXt chunk embedding). # PNG decode stays on zune-png until the gamut decoder migration (#32). gamut-png = { workspace = true, optional = true } +# gamut-avif backs AVIF 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 (justin13888/gamut#251). AVIF decode stays on +# the `image` crate until #33. +gamut-avif = { workspace = true, optional = true } gamut-xmp = { workspace = true, optional = true } # gamut-jxl backs JPEG XL decode (a pure-Rust jxl-rs wrapper — no C toolchain) # and encode (the reference libjxl, vendored, cmake-built, and statically @@ -58,15 +62,6 @@ 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 } -ravif = { version = "0.13", optional = true } -# AVIF (ISO-BMFF) container muxer for the libaom backend — wraps libaom's raw -# AV1 bitstream. BSD-3-Clause (already in the tree via `ravif`), no GPL. -avif-serialize = { version = "0.8", optional = true } -# libaom (BSD-2-Clause): cmake-builds the bundled libaom source and links it -# statically for the `avif-encode-libaom-vendored` feature. Encoder only. We -# generate our own bindgen bindings (see build.rs); this crate's `links = "aom"` -# exposes the built headers to our build script via `DEP_AOM_INCLUDE`. -libaom-sys = { version = "0.17", default-features = false, features = ["av1_encoder"], optional = true } resvg = { version = "0.44", optional = true } serde = { workspace = true, optional = true } thiserror = { workspace = true } @@ -79,11 +74,11 @@ gif = { version = "0.13", optional = true } tiff = { version = "0.11", optional = true } [build-dependencies] -# Compiled only when a `jpeg-encode-jpegli*` or `avif-encode-libaom*` feature -# is enabled (see [features]). All permissive (no GPL): bindgen (BSD-3) -# generates the C-API bindings; pkg-config resolves a system libjpegli/libaom; -# cc (MIT/Apache) compiles the jpegli setjmp shim; cmake (MIT/Apache) builds -# the vendored google/jpegli submodule. +# 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 } @@ -116,8 +111,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # 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`, which have a single -# implementation) reference `dep:*`. +# `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 ─────────────────────────────────────────────────── @@ -172,7 +167,14 @@ jxl-encode = ["dep:gamut-jxl", "gamut-jxl/encode", "exif"] gif-decode = ["gif-decode-gif"] tiff-decode = ["tiff-decode-tiff"] avif-decode = ["avif-decode-image"] -avif-encode = ["avif-encode-ravif"] +# AVIF encode is backed directly by gamut-avif (no tier-4 layer): like +# `png-encode` and the RAW direction features, it has a single gamut-backed +# 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"] heic-decode = ["heic-decode-libheif"] svg-decode = ["svg-decode-resvg"] ppm-decode = ["ppm-decode-zune"] @@ -212,13 +214,6 @@ webp-encode-libwebp = ["webp-encode", "dep:libwebp-sys", "exif", "container-emb gif-decode-gif = ["gif-decode", "dep:gif"] tiff-decode-tiff = ["tiff-decode", "dep:tiff", "exif"] avif-decode-image = ["avif-decode", "dep:image", "exif"] -avif-encode-ravif = ["avif-encode", "dep:ravif", "exif", "container-embed"] -# Alternative AVIF encoder via libaom (the AV1 reference encoder): 8/10/12-bit, -# 4:4:4, better intra coding than rav1e. Enabling it keeps `avif-encode-ravif` -# compiled in too (tier-3 aliases the default impl); the active backend is chosen -# via `EncodeOptions`. Links system libaom by default — see `avif-encode-libaom-vendored`. -# We bind libaom with our own bindgen and mux the AV1 bitstream with `avif-serialize`. -avif-encode-libaom = ["avif-encode", "exif", "container-embed", "dep:avif-serialize", "dep:bindgen", "dep:pkg-config"] heic-decode-libheif = ["heic-decode", "dep:libheif-rs", "exif"] svg-decode-resvg = ["svg-decode", "dep:resvg"] ppm-decode-zune = ["ppm-decode", "dep:zune-ppm", "zune-runtime"] @@ -250,11 +245,6 @@ heic-vendored = ["heic-decode", "libheif-rs/embedded-libheif"] # 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"] -# libaom linking mode. `avif-encode-libaom` links the system libaom (resolved by -# pkg-config); `avif-encode-libaom-vendored` builds libaom from source via cmake -# (through `libaom-sys`) and links it statically (needs a C/C++ toolchain, cmake, -# nasm, and `libclang` for bindgen). -avif-encode-libaom-vendored = ["avif-encode-libaom", "dep:libaom-sys"] [[bench]] name = "decode" diff --git a/crates/rawshift-image/README.md b/crates/rawshift-image/README.md index e7a547f..c8fab10 100644 --- a/crates/rawshift-image/README.md +++ b/crates/rawshift-image/README.md @@ -27,7 +27,7 @@ the `tiff-parser` API, or `heic-vendored` linking. | GIF | [gif](https://github.com/image-rs/image-gif) (Stable) | Not planned | | | TIFF | [tiff](https://github.com/image-rs/image-tiff) (Stable) | Not planned | | | JXL | [gamut-jxl](https://github.com/justin13888/gamut) (Stable) | [gamut-jxl](https://github.com/justin13888/gamut) (Stable) | Decode is pure Rust (jxl-rs); encode wraps the reference libjxl, cmake-built and statically linked by gamut-jxl-sys. | -| AVIF | [image/avif-native](https://github.com/image-rs/image) (Functional) | [ravif](https://github.com/kornelski/cavif-rs/tree/main/ravif) (Functional, default) · [libaom](https://aomedia.googlesource.com/aom/) (8/10/12-bit, 4:4:4, opt-in) | libaom via `avif-encode-libaom` (system) / `avif-encode-libaom-vendored` (from source). | +| AVIF | [image/avif-native](https://github.com/image-rs/image) (Functional) | [gamut-avif](https://github.com/justin13888/gamut) (Functional) | Encode via gamut (pure Rust; 8-bit RGB, lossless/lossy AV1 intra, 4:4:4). 10/12-bit encode temporarily unavailable, pending [gamut#251](https://github.com/justin13888/gamut/issues/251). | | HEIC | [libheif](https://github.com/strukturag/libheif) (Functional) | Not planned | Requires `heic` feature; `heic-vendored` builds libheif from source. | | SVG | [resvg/tiny-skia](https://github.com/linebender/resvg) (Functional) | Not planned | | | PPM | [zune-ppm](https://github.com/etemesi254/zune-image/tree/dev/crates/zune-ppm) (Functional) | Not planned | Netpbm family: P5, P6, P7, PFM. | @@ -46,7 +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`) pull in an external crate. +`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,11 +67,14 @@ below it; only tier-4 features (plus RAW tier-3 features and the gamut-backed `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`, and `jxl-encode` each have a single gamut-backed - implementation (`gamut-png` / `gamut-jxl`) 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.) + `jxl-decode`, `jxl-encode`, and `avif-encode` each have a single + gamut-backed implementation (`gamut-png` / `gamut-jxl` / `gamut-avif`) 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 + RGB, lossless or lossy AV1 intra; 10/12-bit AVIF encode is temporarily + unavailable, pending + [gamut#251](https://github.com/justin13888/gamut/issues/251).) - 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. @@ -83,7 +87,7 @@ below it; only tier-4 features (plus RAW tier-3 features and the gamut-backed - `png-decode-zune` - `webp-decode-libwebp`, `webp-encode-libwebp` - `gif-decode-gif`, `tiff-decode-tiff` - - `avif-decode-image`, `avif-encode-ravif`, `avif-encode-libaom` + - `avif-decode-image` - `heic-decode-libheif`, `svg-decode-resvg` - `ppm-decode-zune` 5. **Infrastructure / linking features** — cross-cutting, not tied to one format. @@ -101,12 +105,6 @@ below it; only tier-4 features (plus RAW tier-3 features and the gamut-backed 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`. - - `avif-encode-libaom` — alternative AVIF encoder via libaom (8/10/12-bit, - 4:4:4); links the system libaom (`aom`, resolved by pkg-config, ≥ 3.2). The - AV1 bitstream is bound with our own bindgen and muxed with `avif-serialize`. - - `avif-encode-libaom-vendored` — build libaom from source via cmake (through - `libaom-sys`) and link it statically, instead of linking the system libaom. - Requires a C/C++ toolchain, cmake, nasm, and `libclang` (for bindgen). The `zune-runtime` / `exif` / `container-embed` features are pulled in automatically by the format implementations that need them — they exist so diff --git a/crates/rawshift-image/build.rs b/crates/rawshift-image/build.rs index 5a93a49..07b90bd 100644 --- a/crates/rawshift-image/build.rs +++ b/crates/rawshift-image/build.rs @@ -64,13 +64,6 @@ fn main() { #[cfg(feature = "jpeg-encode-jpegli")] jpegli::generate(); - // libaom AVIF encoder backend: link libaom and generate its C-API bindings. - // Only compiled when an `avif-encode-libaom*` feature is on. The bindgen / - // pkg-config build deps are shared with the jpegli backend; the vendored - // build of libaom itself comes from the BSD-2 `libaom-sys` crate. - #[cfg(feature = "avif-encode-libaom")] - aom::generate(); - // TODO: Generate large static tables from constant files } @@ -225,87 +218,3 @@ mod jpegli { } } } - -/// Resolve libaom, ensure its link directives are emitted, and run `bindgen` over -/// its C encoder API into `$OUT_DIR/aom_bindings.rs` (included by -/// `src/codecs/avif_libaom.rs`). -/// -/// libaom only emits a raw AV1 bitstream; the `avif-serialize` crate muxes that -/// into the AVIF container. We bind libaom ourselves (no GPL wrapper crates). -#[cfg(feature = "avif-encode-libaom")] -mod aom { - use std::env; - use std::path::PathBuf; - - pub fn generate() { - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by cargo")); - let include_dirs = link_and_include_dirs(); - - // `aomcx.h` is the AV1 encoder control interface; it pulls in `aom.h`, - // `aom_encoder.h`, and `aom_image.h`. Allowlists keep the output scoped. - let mut builder = bindgen::Builder::default() - // This crate is edition 2024, where `extern` blocks must be - // `unsafe extern` — make bindgen emit edition-2024-correct code. - .rust_edition(bindgen::RustEdition::Edition2024) - .header_contents( - "rawshift_aom_wrapper.h", - "#include \n#include \n#include \n", - ) - .allowlist_function("aom_codec_.*") - .allowlist_function("aom_img_.*") - .allowlist_type("aom_.*") - .allowlist_var("AOM_.*") - .allowlist_var("AV1E_.*") - .allowlist_var("AOME_.*") - // C enums as integer type-aliases + consts: simplest to compare against - // and to pass to the variadic `aom_codec_control`. `prepend_enum_name(false)` - // keeps the original C names (`AOM_CODEC_OK`, `AV1E_SET_CQ_LEVEL`). - .default_enum_style(bindgen::EnumVariation::Consts) - .prepend_enum_name(false) - .layout_tests(false) - .generate_comments(false) - .merge_extern_blocks(true); - for dir in &include_dirs { - builder = builder.clang_arg(format!("-I{}", dir.display())); - } - - let bindings = builder - .generate() - .expect("bindgen failed to generate libaom bindings"); - bindings - .write_to_file(out_dir.join("aom_bindings.rs")) - .expect("failed to write aom_bindings.rs"); - - println!("cargo:rerun-if-changed=build.rs"); - } - - /// Return the header include dirs for bindgen. The vendored build is performed - /// by `libaom-sys` (BSD-2), which cmake-builds the bundled source, links it - /// statically, and exposes the installed headers via `DEP_AOM_INCLUDE`; - /// otherwise a system libaom is resolved via pkg-config. - #[cfg(feature = "avif-encode-libaom-vendored")] - fn link_and_include_dirs() -> Vec { - // `libaom-sys`'s build script (it declares `links = "aom"`) already emitted - // the `cargo:rustc-link-{search,lib}` directives and a `cargo:include` that - // cargo forwards to us as `DEP_AOM_INCLUDE`. The headers live under - // `/aom/`, so bindgen's `` resolves with `-I`. - let include = env::var("DEP_AOM_INCLUDE") - .expect("DEP_AOM_INCLUDE is set by the libaom-sys build script"); - vec![PathBuf::from(include)] - } - - #[cfg(not(feature = "avif-encode-libaom-vendored"))] - fn link_and_include_dirs() -> Vec { - pkg_config::Config::new() - .probe("aom") - .unwrap_or_else(|e| { - panic!( - "could not find system `aom` via pkg-config ({e}); install \ - libaom development files (e.g. `libaom-dev` / `libaom-devel`) \ - or enable the `avif-encode-libaom-vendored` feature to build it \ - from source" - ) - }) - .include_paths - } -} diff --git a/crates/rawshift-image/examples/generate_test_fixtures.rs b/crates/rawshift-image/examples/generate_test_fixtures.rs index fbd3675..77fb9c9 100644 --- a/crates/rawshift-image/examples/generate_test_fixtures.rs +++ b/crates/rawshift-image/examples/generate_test_fixtures.rs @@ -367,31 +367,6 @@ fn generate_avif(data_dir: &Path, fixture_dir: &Path) { println!(" Generated {}", name); } -#[cfg(feature = "avif-encode-libaom")] -fn generate_avif_libaom(data_dir: &Path, fixture_dir: &Path) { - let dir = data_dir.join("avif_libaom"); - let fdir = fixture_dir.join("avif_libaom"); - fs::create_dir_all(&dir).unwrap(); - fs::create_dir_all(&fdir).unwrap(); - - let (w, h, pixels_u8) = reference_pixels_u8(); - let pixels_u16 = pixels_u8_to_u16(&pixels_u8); - let img = RgbImage::new(w, h, pixels_u16).expect("valid RGB buffer"); - - // Default libaom config: 10-bit, 4:4:4. - let name = "test_8x8.avif"; - encode_rgb_image( - &img, - &reference_metadata(), - &dir.join(name), - &EncodeOptions::avif_libaom(), - ) - .expect("libaom AVIF encode"); - - write_expected_json(&fdir, name, w, h, "AVIF", true); - println!(" Generated {}", name); -} - #[cfg(feature = "jxl-encode")] fn generate_jxl(data_dir: &Path, fixture_dir: &Path) { let dir = data_dir.join("jxl"); @@ -434,8 +409,6 @@ fn main() { generate_svg(&data_dir, &fixture_dir); #[cfg(feature = "avif-encode")] generate_avif(&data_dir, &fixture_dir); - #[cfg(feature = "avif-encode-libaom")] - generate_avif_libaom(&data_dir, &fixture_dir); #[cfg(feature = "jxl-encode")] generate_jxl(&data_dir, &fixture_dir); diff --git a/crates/rawshift-image/src/codecs/avif_libaom.rs b/crates/rawshift-image/src/codecs/avif_libaom.rs deleted file mode 100644 index d2166fa..0000000 --- a/crates/rawshift-image/src/codecs/avif_libaom.rs +++ /dev/null @@ -1,454 +0,0 @@ -//! Safe wrapper around libaom's C AV1 encoder, muxed into AVIF via `avif-serialize`. -//! -//! All `unsafe`/FFI interaction with libaom is confined to this module (the -//! `src/codecs` safety boundary — see `PRINCIPLES.md`). Callers receive a plain -//! `Vec` (a complete `.avif` file) and `Result<_, String>`; -//! `src/formats/encode.rs` maps the `String` into -//! [`EncodeError::Encoding`](crate::error::EncodeError::Encoding). -//! -//! libaom only produces a raw AV1 bitstream (OBUs); the BSD-3 `avif-serialize` -//! crate wraps it in the AVIF (ISO-BMFF) container. Pixels are encoded as -//! **full-range BT.709 YUV 4:4:4** (no chroma subsampling — what `avif-serialize` -//! expects by default, and what AV1 handles best) at 8/10/12 bits. The signalled -//! CICP matches both the RGB→YUV conversion here and the container's `colr` box. -//! -//! The raw bindings are generated by `build.rs` (bindgen over libaom's BSD-2 -//! headers) into `$OUT_DIR/aom_bindings.rs` and `include!`d below — we vendor no -//! GPL bindings. - -use std::ffi::CStr; -use std::os::raw::{c_int, c_uint}; -use std::ptr; - -/// Generated libaom C-API 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"), "/aom_bindings.rs")); -} - -/// Fully-resolved libaom encoder settings. -/// -/// This is the plain-data contract between [`crate::formats::encode`] and the FFI -/// layer: every field is already the integer libaom expects. -#[derive(Debug, Clone)] -pub struct AvifLibaomParams { - /// Constant-quantiser level, `0..=63` (`AOME_SET_CQ_LEVEL`). Lower is better - /// quality and larger files. - pub cq_level: u32, - /// `rc_min_quantizer`, `0..=63`. - pub min_quantizer: u32, - /// `rc_max_quantizer`, `0..=63`. - pub max_quantizer: u32, - /// Speed/quality trade-off, `0` (slowest, best) ..= `8` (fastest) - /// (`AOME_SET_CPUUSED`). - pub cpu_used: u32, - /// `true` selects constant-quality (`AOM_Q`); `false` selects - /// constrained-quality (`AOM_CQ`). - pub constant_quality: bool, -} - -/// Owns an `aom_codec_ctx_t`; destroys it on drop, but only once successfully -/// initialised (libaom must not be destroyed if `enc_init` failed). -struct Codec { - ctx: ffi::aom_codec_ctx_t, - initialized: bool, -} -impl Drop for Codec { - fn drop(&mut self) { - if self.initialized { - unsafe { ffi::aom_codec_destroy(&mut self.ctx) }; - } - } -} - -/// Owns an `aom_image_t`; frees its plane buffers on drop, but only once -/// `aom_img_alloc` has succeeded. -struct Image { - img: ffi::aom_image_t, - allocated: bool, -} -impl Drop for Image { - fn drop(&mut self) { - if self.allocated { - unsafe { ffi::aom_img_free(&mut self.img) }; - } - } -} - -/// Encode interleaved 16-bit RGB to a complete AVIF file. -/// -/// `rgb16` is `width * height * 3` samples, each normalised to `[0, 65535]` (the -/// crate's display-referred sRGB convention). `depth` is the output bit depth: 8, -/// 10, or 12. EXIF/ICC/XMP are appended by the caller. -pub fn encode( - rgb16: &[u16], - width: u32, - height: u32, - depth: u8, - params: &AvifLibaomParams, -) -> Result, String> { - if !matches!(depth, 8 | 10 | 12) { - return Err(format!("unsupported AV1 bit depth: {depth}")); - } - if width == 0 || height == 0 { - return Err("image dimensions must be non-zero".into()); - } - let npx = (width as usize) * (height as usize); - let expected = npx * 3; - if rgb16.len() != expected { - return Err(format!( - "pixel buffer length mismatch: expected {expected}, got {}", - rgb16.len() - )); - } - - // RGB → full-range BT.709 YUV 4:4:4 at the target depth. - let (y_plane, u_plane, v_plane) = rgb_to_yuv444_bt709(rgb16, npx, depth); - - let obu = encode_av1(&y_plane, &u_plane, &v_plane, width, height, depth, params)?; - - // Mux the AV1 bitstream into an AVIF container. The CICP set here must match - // the conversion above (and the AV1E_SET_* controls in `encode_av1`). - use avif_serialize::constants::{ColorPrimaries, MatrixCoefficients, TransferCharacteristics}; - let mut out = Vec::new(); - avif_serialize::Aviffy::new() - .set_color_primaries(ColorPrimaries::Bt709) - .set_transfer_characteristics(TransferCharacteristics::Srgb) - .set_matrix_coefficients(MatrixCoefficients::Bt709) - .set_full_color_range(true) - .write(&mut out, &obu, None, width, height, depth) - .map_err(|e| format!("AVIF muxing failed: {e}"))?; - Ok(out) -} - -/// Full-range BT.709 RGB→YUV 4:4:4. Returns the Y, U, V planes (one sample per -/// pixel each), with values in `[0, 2^depth - 1]` stored in `u16`. -fn rgb_to_yuv444_bt709(rgb16: &[u16], npx: usize, depth: u8) -> (Vec, Vec, Vec) { - let shift = 16 - u32::from(depth); - let max = ((1u32 << depth) - 1) as f32; - let half = (1u32 << (depth - 1)) as f32; - - let mut yb = vec![0u16; npx]; - let mut ub = vec![0u16; npx]; - let mut vb = vec![0u16; npx]; - - let q = |x: f32| x.round().clamp(0.0, max) as u16; - for i in 0..npx { - let r = f32::from(rgb16[i * 3] >> shift); - let g = f32::from(rgb16[i * 3 + 1] >> shift); - let b = f32::from(rgb16[i * 3 + 2] >> shift); - let y = 0.2126 * r + 0.7152 * g + 0.0722 * b; - yb[i] = q(y); - ub[i] = q((b - y) / 1.8556 + half); - vb[i] = q((r - y) / 1.5748 + half); - } - (yb, ub, vb) -} - -/// Encode the YUV 4:4:4 planes to a single AV1 keyframe and return its bitstream. -fn encode_av1( - y: &[u16], - u: &[u16], - v: &[u16], - width: u32, - height: u32, - depth: u8, - params: &AvifLibaomParams, -) -> Result, String> { - unsafe { - let iface = ffi::aom_codec_av1_cx(); - if iface.is_null() { - return Err("aom_codec_av1_cx returned null".into()); - } - - // Default config tuned for a single still image (all-intra), then override - // the fields we care about. - let mut cfg: ffi::aom_codec_enc_cfg_t = std::mem::zeroed(); - let e = ffi::aom_codec_enc_config_default(iface, &mut cfg, ffi::AOM_USAGE_ALL_INTRA); - if e != ffi::AOM_CODEC_OK { - return Err(format!( - "aom_codec_enc_config_default failed: {}", - err_str(e) - )); - } - cfg.g_w = width; - cfg.g_h = height; - // 4:4:4 requires AV1 seq profile 1 (8/10-bit) or 2 (12-bit). - cfg.g_profile = if depth >= 12 { 2 } else { 1 }; - cfg.g_bit_depth = match depth { - 8 => ffi::AOM_BITS_8, - 10 => ffi::AOM_BITS_10, - _ => ffi::AOM_BITS_12, - }; - cfg.g_input_bit_depth = u32::from(depth); - cfg.g_pass = ffi::AOM_RC_ONE_PASS; - cfg.g_lag_in_frames = 0; - cfg.monochrome = 0; - cfg.rc_end_usage = if params.constant_quality { - ffi::AOM_Q - } else { - ffi::AOM_CQ - }; - cfg.rc_min_quantizer = params.min_quantizer; - cfg.rc_max_quantizer = params.max_quantizer; - if let Ok(n) = std::thread::available_parallelism() { - cfg.g_threads = n.get().min(16) as c_uint; - } - - let flags: ffi::aom_codec_flags_t = if depth > 8 { - ffi::AOM_CODEC_USE_HIGHBITDEPTH as ffi::aom_codec_flags_t - } else { - 0 - }; - let mut codec = Codec { - ctx: std::mem::zeroed(), - initialized: false, - }; - let e = ffi::aom_codec_enc_init_ver( - &mut codec.ctx, - iface, - &cfg, - flags, - ffi::AOM_ENCODER_ABI_VERSION as c_int, - ); - if e != ffi::AOM_CODEC_OK { - return Err(format!("aom_codec_enc_init_ver failed: {}", err_str(e))); - } - codec.initialized = true; - - // Speed, quality, and CICP (must match the muxer's `colr` box). - control( - &mut codec.ctx, - ffi::AOME_SET_CPUUSED, - params.cpu_used as c_int, - )?; - control( - &mut codec.ctx, - ffi::AOME_SET_CQ_LEVEL, - params.cq_level as c_int, - )?; - control( - &mut codec.ctx, - ffi::AV1E_SET_COLOR_PRIMARIES, - ffi::AOM_CICP_CP_BT_709 as c_int, - )?; - control( - &mut codec.ctx, - ffi::AV1E_SET_TRANSFER_CHARACTERISTICS, - ffi::AOM_CICP_TC_SRGB as c_int, - )?; - control( - &mut codec.ctx, - ffi::AV1E_SET_MATRIX_COEFFICIENTS, - ffi::AOM_CICP_MC_BT_709 as c_int, - )?; - control( - &mut codec.ctx, - ffi::AV1E_SET_COLOR_RANGE, - ffi::AOM_CR_FULL_RANGE as c_int, - )?; - - // Allocate the input image (4:4:4, high-bit-depth container for 10/12) and - // copy the planes in. - let fmt = if depth > 8 { - ffi::AOM_IMG_FMT_I44416 - } else { - ffi::AOM_IMG_FMT_I444 - }; - let mut image = Image { - img: std::mem::zeroed(), - allocated: false, - }; - if ffi::aom_img_alloc(&mut image.img, fmt, width, height, 1).is_null() { - return Err("aom_img_alloc failed".into()); - } - image.allocated = true; - image.img.bit_depth = u32::from(depth); - fill_plane( - &mut image.img, - ffi::AOM_PLANE_Y as usize, - y, - width, - height, - depth, - ); - fill_plane( - &mut image.img, - ffi::AOM_PLANE_U as usize, - u, - width, - height, - depth, - ); - fill_plane( - &mut image.img, - ffi::AOM_PLANE_V as usize, - v, - width, - height, - depth, - ); - - // Encode the single frame, then flush (img = null) so all packets are emitted. - let e = ffi::aom_codec_encode( - &mut codec.ctx, - &image.img, - 0, - 1, - ffi::AOM_EFLAG_FORCE_KF as ffi::aom_enc_frame_flags_t, - ); - if e != ffi::AOM_CODEC_OK { - return Err(format!("aom_codec_encode failed: {}", err_str(e))); - } - let e = ffi::aom_codec_encode(&mut codec.ctx, ptr::null(), 0, 1, 0); - if e != ffi::AOM_CODEC_OK { - return Err(format!("aom_codec_encode(flush) failed: {}", err_str(e))); - } - - // Drain compressed-frame packets into a single OBU buffer. - let mut out = Vec::new(); - let mut iter: ffi::aom_codec_iter_t = ptr::null(); - loop { - let pkt = ffi::aom_codec_get_cx_data(&mut codec.ctx, &mut iter); - if pkt.is_null() { - break; - } - if (*pkt).kind == ffi::AOM_CODEC_CX_FRAME_PKT { - let frame = &(*pkt).data.frame; - let bytes = std::slice::from_raw_parts(frame.buf.cast::(), frame.sz); - out.extend_from_slice(bytes); - } - } - if out.is_empty() { - return Err("libaom produced no output".into()); - } - Ok(out) - } -} - -/// Issue one `aom_codec_control` and convert a non-OK status to an error string. -unsafe fn control(ctx: *mut ffi::aom_codec_ctx_t, id: c_int, val: c_int) -> Result<(), String> { - let e = unsafe { ffi::aom_codec_control(ctx, id, val) }; - if e != ffi::AOM_CODEC_OK { - return Err(format!("aom_codec_control(id={id}) failed: {}", unsafe { - err_str(e) - })); - } - Ok(()) -} - -/// Copy a planar YUV channel into a libaom-allocated plane, honouring its byte -/// stride and (for 10/12-bit) the 16-bit sample container. -unsafe fn fill_plane( - img: &mut ffi::aom_image_t, - plane: usize, - src: &[u16], - width: u32, - height: u32, - depth: u8, -) { - let stride = img.stride[plane] as usize; // bytes between rows - let base = img.planes[plane]; - let w = width as usize; - for row in 0..height as usize { - let dst = unsafe { base.add(row * stride) }; - let srow = &src[row * w..row * w + w]; - if depth == 8 { - for (x, &s) in srow.iter().enumerate() { - unsafe { *dst.add(x) = s as u8 }; - } - } else { - let dst16 = dst.cast::(); - for (x, &s) in srow.iter().enumerate() { - unsafe { *dst16.add(x) = s }; - } - } - } -} - -/// Human-readable string for a libaom error code. -unsafe fn err_str(err: ffi::aom_codec_err_t) -> String { - let p = unsafe { ffi::aom_codec_err_to_string(err) }; - if p.is_null() { - return format!("error {err}"); - } - unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned() -} - -#[cfg(test)] -mod tests { - use super::*; - - /// `w`×`h` RGB16 gradient. - fn gradient(w: u32, h: u32) -> Vec { - let mut px = Vec::with_capacity((w * h * 3) as usize); - for y in 0..h { - for x in 0..w { - px.push((u32::from(x) * 65535 / u32::from(w.max(2) - 1)) as u16); - px.push((u32::from(y) * 65535 / u32::from(h.max(2) - 1)) as u16); - px.push(32768); - } - } - px - } - - fn params() -> AvifLibaomParams { - AvifLibaomParams { - cq_level: 30, - min_quantizer: 0, - max_quantizer: 63, - cpu_used: 6, - constant_quality: true, - } - } - - /// The bytes are a valid ISO-BMFF AVIF file (`ftyp` box, `avif`/`avis` brand). - fn is_avif(data: &[u8]) -> bool { - data.len() > 12 && &data[4..8] == b"ftyp" && { - let brand = &data[8..12]; - brand == b"avif" || brand == b"avis" || brand == b"mif1" - } - } - - #[test] - #[cfg_attr(miri, ignore)] // FFI into libaom cannot run under Miri. - fn encodes_8bit_avif() { - let data = encode(&gradient(16, 12), 16, 12, 8, ¶ms()).expect("encode 8-bit"); - assert!(is_avif(&data), "expected a valid AVIF container"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn encodes_10bit_avif() { - let data = encode(&gradient(16, 12), 16, 12, 10, ¶ms()).expect("encode 10-bit"); - assert!(is_avif(&data)); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn encodes_12bit_avif() { - let data = encode(&gradient(16, 12), 16, 12, 12, ¶ms()).expect("encode 12-bit"); - assert!(is_avif(&data)); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn rejects_wrong_buffer_length() { - assert!(encode(&[0u16; 10], 16, 12, 8, ¶ms()).is_err()); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn rejects_bad_depth() { - assert!(encode(&gradient(4, 4), 4, 4, 16, ¶ms()).is_err()); - } -} diff --git a/crates/rawshift-image/src/codecs/mod.rs b/crates/rawshift-image/src/codecs/mod.rs index 05c0c07..a0a253b 100644 --- a/crates/rawshift-image/src/codecs/mod.rs +++ b/crates/rawshift-image/src/codecs/mod.rs @@ -1,7 +1,5 @@ #[cfg(feature = "arw-decode")] pub(crate) mod arw; -#[cfg(feature = "avif-encode-libaom")] -pub(crate) mod avif_libaom; #[cfg(any(feature = "arw-decode", feature = "cr2-decode", feature = "nef-decode"))] pub(crate) mod bit_pump; #[cfg(feature = "heic-decode")] diff --git a/crates/rawshift-image/src/formats/encode.rs b/crates/rawshift-image/src/formats/encode.rs index c640a85..5d8a536 100644 --- a/crates/rawshift-image/src/formats/encode.rs +++ b/crates/rawshift-image/src/formats/encode.rs @@ -44,9 +44,7 @@ pub fn encode_rgb_image_to_vec( #[cfg(feature = "webp-encode")] EncodeOptions::WebpLibwebp(cfg) => encode_webp(image, metadata, cfg), #[cfg(feature = "avif-encode")] - EncodeOptions::AvifRavif(cfg) => encode_avif(image, metadata, cfg), - #[cfg(feature = "avif-encode-libaom")] - EncodeOptions::AvifLibaom(cfg) => encode_avif_libaom(image, metadata, cfg), + EncodeOptions::Avif(cfg) => encode_avif(image, metadata, cfg), #[cfg(feature = "jxl-encode")] EncodeOptions::Jxl(cfg) => encode_jxl(image, metadata, cfg), #[cfg(feature = "dng-encode")] @@ -407,121 +405,56 @@ fn encode_webp( fn encode_avif( image: &RgbImage, metadata: &ImageMetadata, - cfg: &super::export::RavifEncodeConfig, + cfg: &super::export::AvifEncodeConfig, ) -> RawResult> { use crate::metadata::exif::ExifBuilder; use crate::metadata::icc::IccProfile; use crate::metadata::xmp::append_xmp_to_avif; - use ravif::{Encoder, Img, RGBA8}; - - check_8bit_backend(cfg.common.bit_depth, "AVIF")?; - - let rgba_data: Vec = image - .data() - .chunks_exact(3) - .map(|rgb| { - RGBA8::new( - (rgb[0] >> 8) as u8, - (rgb[1] >> 8) as u8, - (rgb[2] >> 8) as u8, - 255, - ) - }) - .collect(); - - let img = Img::new( - rgba_data.as_slice(), - image.width() as usize, - image.height() as usize, - ); - - let encoder = Encoder::new() - .with_quality(cfg.quality as f32) - .with_speed(cfg.speed); - - // Encode failures are domain errors, never panics — this runs on a worker - // pool and a failed target must be reported, not crash the process. - let result = encoder.encode_rgba(img).map_err(|e| { - RawError::Encode(EncodeError::Encoding { - format: "AVIF", - message: format!("{e:?}"), - }) - })?; - let mut avif_bytes = result.avif_file; - - let m = &cfg.common.metadata; - if m.embed_icc { - match IccProfile::srgb().append_to_avif(avif_bytes.clone()) { - Ok(data) => avif_bytes = data, - Err(e) => tracing::warn!("Failed to embed ICC in AVIF: {e}"), - } - } - if m.embed_exif { - match ExifBuilder::new(metadata).append_to_avif(avif_bytes.clone()) { - Ok(data) => avif_bytes = data, - Err(e) => tracing::warn!("Failed to embed EXIF in AVIF: {e}"), - } - } - if m.embed_xmp - && let Some(xmp_data) = &metadata.xmp - { - match append_xmp_to_avif(xmp_data, avif_bytes.clone()) { - Ok(data) => avif_bytes = data, - Err(e) => tracing::warn!("Failed to embed XMP in AVIF: {e}"), - } - } - - Ok(avif_bytes) -} - -// ── AVIF (libaom reference encoder) ───────────────────────────────────────────── - -#[cfg(feature = "avif-encode-libaom")] -fn encode_avif_libaom( - image: &RgbImage, - metadata: &ImageMetadata, - cfg: &super::export::LibaomEncodeConfig, -) -> RawResult> { - use super::export::AvifRateControl; - use crate::codecs::avif_libaom::{self, AvifLibaomParams}; - use crate::metadata::exif::ExifBuilder; - use crate::metadata::icc::IccProfile; - use crate::metadata::xmp::append_xmp_to_avif; - - // libaom is HDR-capable: 8/10/12-bit map straight to AV1 depths. AV1 cannot - // represent 16-bit, so that (and anything deeper) is reported rather than - // silently degraded. - let depth: u8 = match cfg.common.bit_depth { - BitDepth::Eight => 8, - BitDepth::Ten => 10, - BitDepth::Twelve => 12, + use gamut_avif::AvifEncoder; + use gamut_core::{Dimensions, EncodeImage, ImageRef, Rgb8}; + + // gamut-avif takes 8-bit RGB: `Eight` and `Sixteen` are accepted + // (`Sixteen` is down-converted, as with every 8-bit-only backend). + // 10/12-bit AVIF output is temporarily unavailable — it is pending + // high-bit-depth support in gamut-avif (justin13888/gamut#251) — so those + // requests are reported rather than silently degraded. + match cfg.common.bit_depth { + BitDepth::Eight | BitDepth::Sixteen => {} other => { return Err(RawError::Encode(EncodeError::UnsupportedBitDepth { - format: "AVIF", + format: "AVIF (10/12-bit output pending justin13888/gamut#251)", requested: other, })); } + } + + let encoding_error = |e: gamut_core::Error| { + RawError::Encode(EncodeError::Encoding { + format: "AVIF", + message: format!("AVIF encoding error: {e}"), + }) }; - let params = AvifLibaomParams { - cq_level: u32::from(cfg.cq_level), - min_quantizer: u32::from(cfg.min_quantizer), - max_quantizer: u32::from(cfg.max_quantizer), - cpu_used: u32::from(cfg.cpu_used), - constant_quality: cfg.rate_control == AvifRateControl::ConstantQuality, + let dims = Dimensions::new(image.width(), image.height()).map_err(encoding_error)?; + let samples = pack_rgb8(image); + let img = ImageRef::::new(&samples, dims).map_err(encoding_error)?; + + let encoder = if cfg.lossless { + AvifEncoder::lossless() + } else { + AvifEncoder::lossy(cfg.quality) }; - let mut avif_bytes = - avif_libaom::encode(image.data(), image.width(), image.height(), depth, ¶ms).map_err( - |e| { - RawError::Encode(EncodeError::Encoding { - format: "AVIF", - message: e, - }) - }, - )?; - - // Metadata embedding mirrors the `ravif` `encode_avif` path exactly. + // Encode failures are domain errors, never panics — this runs on a worker + // pool and a failed target must be reported, not crash the process. + let mut avif_bytes = Vec::new(); + encoder + .encode_image(img, &mut avif_bytes) + .map_err(encoding_error)?; + + // gamut-avif does not emit metadata items yet (deferred upstream), so + // EXIF / ICC / XMP are spliced into the encoded container as ISOBMFF + // items by rawshift's own muxer (`metadata::isobmff::insert_item`). let m = &cfg.common.metadata; if m.embed_icc { match IccProfile::srgb().append_to_avif(avif_bytes.clone()) { diff --git a/crates/rawshift-image/src/formats/export.rs b/crates/rawshift-image/src/formats/export.rs index 3b8e36e..6907586 100644 --- a/crates/rawshift-image/src/formats/export.rs +++ b/crates/rawshift-image/src/formats/export.rs @@ -10,9 +10,8 @@ //! (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`]) and libaom -//! ([`LibaomEncodeConfig`]) backends are wired up behind the -//! `jpeg-encode-jpegli` and `avif-encode-libaom` features respectively. +//! the implementations. The jpegli ([`JpegliEncodeConfig`]) backend is wired +//! up behind the `jpeg-encode-jpegli` feature. #[cfg(feature = "dng-encode")] use crate::formats::dng_export::DngEncodeConfig; @@ -125,17 +124,6 @@ pub enum JpegSubsampling { Yuv444, } -/// Rate-control strategy for AV1-based AVIF encoders. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum AvifRateControl { - /// Constant quality / constant quantizer — quality knob drives file size. - #[default] - ConstantQuality, - /// Constrained quality — quality target with a bitrate ceiling. - Constrained, -} - /// DEFLATE compression level for the `gamut-png` PNG encoder /// (maps to `gamut_deflate::Level`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -273,68 +261,43 @@ impl Default for LibwebpEncodeConfig { } } -/// Configuration for the `ravif` (rav1e) AVIF encoder. +/// Configuration for the `gamut-avif` AVIF encoder (pure Rust — the AV1 intra +/// codestream from gamut-av1 wrapped in a gamut-isobmff container). +/// +/// Exposes exactly gamut-avif's encoder options: lossless (the default) or +/// lossy AV1 intra coding at identity-matrix 4:4:4, with a `0..=100` quality +/// factor in lossy mode. The encoder takes 8-bit RGB input, so `common.bit_depth` +/// honours `BitDepth::Eight` and `BitDepth::Sixteen` (16-bit samples are +/// down-converted to 8-bit, as with every 8-bit-only backend); `Ten` and +/// `Twelve` return +/// [`EncodeError::UnsupportedBitDepth`](crate::error::EncodeError::UnsupportedBitDepth) +/// — high-bit-depth AVIF encode is **temporarily unavailable** pending +/// 10/12-bit support in gamut-avif +/// ([justin13888/gamut#251](https://github.com/justin13888/gamut/issues/251)). +/// +/// EXIF / ICC / XMP metadata is spliced into the encoded container as ISOBMFF +/// items by rawshift (gamut-avif does not emit metadata items yet). #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct RavifEncodeConfig { - /// Encoder-agnostic options. This backend currently produces 8-bit AVIF. +pub struct AvifEncodeConfig { + /// Encoder-agnostic options (metadata embedding, bit depth). pub common: CommonEncodeOptions, - /// Quality, `0..=100`. Higher is better quality and larger files (monotonic). - /// Default: `80`. + /// Lossless encoding (decoded output bit-exact to the 8-bit input). When + /// set, `quality` is ignored. Default: `true`, matching gamut-avif's + /// default mode. + pub lossless: bool, + /// Quality for lossy encoding, `0..=100` (values above `100` are clamped). + /// Higher is better quality and larger files (monotonic). Used only when + /// `lossless` is `false`. Default: `80`. pub quality: u8, - /// Encoding speed, `1` (slowest, best) to `10` (fastest). Default: `6`. - pub speed: u8, } -impl Default for RavifEncodeConfig { +impl Default for AvifEncodeConfig { fn default() -> Self { Self { common: CommonEncodeOptions::default(), + lossless: true, quality: 80, - speed: 6, - } - } -} - -/// Configuration for the **libaom** AVIF encoder (the AV1 reference encoder). -/// -/// Produces full-range BT.709 YUV 4:4:4 AVIF. Requires the `avif-encode-libaom` -/// feature. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct LibaomEncodeConfig { - /// Encoder-agnostic options. libaom honours `BitDepth::Eight`, `Ten`, and - /// `Twelve`; a `Sixteen` request returns - /// [`EncodeError::UnsupportedBitDepth`](crate::error::EncodeError::UnsupportedBitDepth) - /// (AV1 tops out at 12-bit). Defaults to 10-bit. - pub common: CommonEncodeOptions, - /// Constant-quantiser level, `0..=63`. Lower is better quality and larger - /// files (monotonic). Used under [`AvifRateControl::ConstantQuality`]. - pub cq_level: u8, - /// Minimum quantizer, `0..=63`. - pub min_quantizer: u8, - /// Maximum quantizer, `0..=63`. - pub max_quantizer: u8, - /// Speed/quality trade-off, `0` (slowest, best) to `8` (fastest). - pub cpu_used: u8, - /// Rate-control strategy. - pub rate_control: AvifRateControl, -} - -impl Default for LibaomEncodeConfig { - fn default() -> Self { - Self { - // libaom is the HDR-capable backend; default to 10-bit rather than the - // global 16-bit default it cannot honour. - common: CommonEncodeOptions { - bit_depth: BitDepth::Ten, - ..CommonEncodeOptions::default() - }, - cq_level: 30, - min_quantizer: 0, - max_quantizer: 63, - cpu_used: 6, - rate_control: AvifRateControl::ConstantQuality, } } } @@ -533,13 +496,9 @@ pub enum EncodeOptions { /// WebP via `libwebp` (requires `webp-encode`). #[cfg(feature = "webp-encode")] WebpLibwebp(LibwebpEncodeConfig), - /// AVIF via `ravif` / rav1e (requires `avif-encode`). + /// AVIF via `gamut-avif` (requires `avif-encode`). #[cfg(feature = "avif-encode")] - AvifRavif(RavifEncodeConfig), - /// AVIF via `libaom`, the AV1 reference encoder: 8/10/12-bit, 4:4:4 - /// (requires `avif-encode-libaom`). - #[cfg(feature = "avif-encode-libaom")] - AvifLibaom(LibaomEncodeConfig), + Avif(AvifEncodeConfig), /// JPEG XL via `gamut-jxl`, wrapping the reference libjxl encoder /// (requires `jxl-encode`). #[cfg(feature = "jxl-encode")] @@ -587,17 +546,10 @@ impl EncodeOptions { Self::WebpLibwebp(LibwebpEncodeConfig::lossless()) } - /// AVIF with default configuration. + /// AVIF with default configuration (lossless). #[cfg(feature = "avif-encode")] pub fn avif() -> Self { - Self::AvifRavif(RavifEncodeConfig::default()) - } - - /// AVIF via the libaom reference encoder, with default configuration - /// (10-bit, 4:4:4). - #[cfg(feature = "avif-encode-libaom")] - pub fn avif_libaom() -> Self { - Self::AvifLibaom(LibaomEncodeConfig::default()) + Self::Avif(AvifEncodeConfig::default()) } /// JPEG XL with default configuration (lossless). @@ -624,9 +576,7 @@ impl EncodeOptions { #[cfg(feature = "webp-encode")] EncodeOptions::WebpLibwebp(_) => OutputFormat::WebP, #[cfg(feature = "avif-encode")] - EncodeOptions::AvifRavif(_) => OutputFormat::Avif, - #[cfg(feature = "avif-encode-libaom")] - EncodeOptions::AvifLibaom(_) => OutputFormat::Avif, + EncodeOptions::Avif(_) => OutputFormat::Avif, #[cfg(feature = "jxl-encode")] EncodeOptions::Jxl(_) => OutputFormat::Jxl, #[cfg(feature = "dng-encode")] @@ -650,9 +600,7 @@ impl EncodeOptions { #[cfg(feature = "webp-encode")] EncodeOptions::WebpLibwebp(_) => CodecId::new("webp/libwebp"), #[cfg(feature = "avif-encode")] - EncodeOptions::AvifRavif(_) => CodecId::new("avif/ravif"), - #[cfg(feature = "avif-encode-libaom")] - EncodeOptions::AvifLibaom(_) => CodecId::new("avif/libaom"), + EncodeOptions::Avif(_) => CodecId::new("avif/gamut"), #[cfg(feature = "jxl-encode")] EncodeOptions::Jxl(_) => CodecId::new("jxl/gamut"), #[cfg(feature = "dng-encode")] @@ -677,9 +625,7 @@ impl EncodeOptions { #[cfg(feature = "webp-encode")] EncodeOptions::WebpLibwebp(c) => c.common, #[cfg(feature = "avif-encode")] - EncodeOptions::AvifRavif(c) => c.common, - #[cfg(feature = "avif-encode-libaom")] - EncodeOptions::AvifLibaom(c) => c.common, + EncodeOptions::Avif(c) => c.common, #[cfg(feature = "jxl-encode")] EncodeOptions::Jxl(c) => c.common, #[cfg(feature = "dng-encode")] diff --git a/crates/rawshift-image/src/formats/registry.rs b/crates/rawshift-image/src/formats/registry.rs index 4d4444b..0a6eeef 100644 --- a/crates/rawshift-image/src/formats/registry.rs +++ b/crates/rawshift-image/src/formats/registry.rs @@ -52,18 +52,12 @@ pub fn available_encoders() -> Vec { "0.14", CodecDirection::Encode, )); + // Version tracks the gamut-avif crate at the pinned gamut commit (git + // dependency). Hand-maintained — bump together with the gamut pin. #[cfg(feature = "avif-encode")] encoders.push(CodecInfo::new( - CodecId::new("avif/ravif"), - "0.13", - CodecDirection::Encode, - )); - // Version tracks the libaom bundled by `libaom-sys` (vendored); a system - // libaom may differ. Hand-maintained — bump with the `libaom-sys` dependency. - #[cfg(feature = "avif-encode-libaom")] - encoders.push(CodecInfo::new( - CodecId::new("avif/libaom"), - "3.11", + CodecId::new("avif/gamut"), + "1.0", CodecDirection::Encode, )); // Version tracks the gamut-jxl crate at the pinned gamut commit (git diff --git a/crates/rawshift-image/src/formats/standard.rs b/crates/rawshift-image/src/formats/standard.rs index 6f9d614..b2f26df 100644 --- a/crates/rawshift-image/src/formats/standard.rs +++ b/crates/rawshift-image/src/formats/standard.rs @@ -2430,7 +2430,7 @@ mod tests { use crate::core::metadata::*; use crate::formats::encode_rgb_image; use crate::formats::export::{ - CommonEncodeOptions, EncodeOptions, MetadataEmbedOptions, RavifEncodeConfig, + AvifEncodeConfig, CommonEncodeOptions, EncodeOptions, MetadataEmbedOptions, }; // Build a 2×2 synthetic image (solid red). @@ -2457,9 +2457,7 @@ mod tests { }; let tmp = std::env::temp_dir().join("rawshift_avif_exif_test.avif"); - let opts = EncodeOptions::AvifRavif(RavifEncodeConfig { - quality: 60, - speed: 10, + let opts = EncodeOptions::Avif(AvifEncodeConfig { common: CommonEncodeOptions { metadata: MetadataEmbedOptions { embed_icc: false, @@ -2467,6 +2465,7 @@ mod tests { }, ..Default::default() }, + ..AvifEncodeConfig::default() }); encode_rgb_image(&rgb, &md, &tmp, &opts).expect("encode AVIF"); diff --git a/crates/rawshift-image/src/prelude.rs b/crates/rawshift-image/src/prelude.rs index 7ad5244..8851e66 100644 --- a/crates/rawshift-image/src/prelude.rs +++ b/crates/rawshift-image/src/prelude.rs @@ -45,10 +45,10 @@ pub use crate::error::{ // formats — encode option system pub use crate::formats::export::{ - AvifRateControl, BitDepth, CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, - JpegSubsampling, JpegliEncodeConfig, JxlEncodeConfig, LibaomEncodeConfig, LibwebpEncodeConfig, + AvifEncodeConfig, BitDepth, CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, + JpegSubsampling, JpegliEncodeConfig, JxlEncodeConfig, LibwebpEncodeConfig, MetadataEmbedOptions, OutputFormat, PngCompressionLevel, PngEncodeConfig, PngFilterStrategy, - PngFilterType, RavifEncodeConfig, WebPMode, + PngFilterType, WebPMode, }; // formats — decoders, format detection, encode/decode entry points pub use crate::formats::{ diff --git a/crates/rawshift-image/tests/export_format_tests.rs b/crates/rawshift-image/tests/export_format_tests.rs index 92af318..85f561b 100644 --- a/crates/rawshift-image/tests/export_format_tests.rs +++ b/crates/rawshift-image/tests/export_format_tests.rs @@ -502,15 +502,76 @@ fn jxl_is_container(data: &[u8]) -> bool { #[cfg(feature = "avif-encode")] mod avif_tests { use super::*; - use rawshift_image::formats::export::RavifEncodeConfig; + use rawshift_image::formats::export::AvifEncodeConfig; fn avif(exif: bool, icc: bool) -> EncodeOptions { - EncodeOptions::AvifRavif(RavifEncodeConfig { + EncodeOptions::Avif(AvifEncodeConfig { common: common(exif, icc, true), - ..RavifEncodeConfig::default() + ..AvifEncodeConfig::default() }) } + /// The bytes are a valid ISO-BMFF AVIF file (`ftyp` box, AVIF brand). + fn is_avif(data: &[u8]) -> bool { + data.len() > 12 + && &data[4..8] == b"ftyp" + && matches!(&data[8..12], b"avif" | b"avis" | b"mif1") + } + + #[test] + fn encodes_lossless_and_lossy() { + let img = synthetic_image(); + for lossless in [true, false] { + let opts = EncodeOptions::Avif(AvifEncodeConfig { + common: common(false, false, false), + lossless, + quality: 60, + }); + let data = encode_rgb_image_to_vec(&img, &ImageMetadata::default(), &opts) + .unwrap_or_else(|e| panic!("AVIF encode (lossless={lossless}) failed: {e}")); + assert!( + is_avif(&data), + "lossless={lossless} output must be a valid AVIF" + ); + } + } + + /// 10/12-bit AVIF output is temporarily unavailable — gamut-avif is 8-bit + /// only until justin13888/gamut#251 lands — and must be reported, not + /// silently degraded. + #[test] + fn rejects_10_and_12_bit() { + let img = synthetic_image(); + for depth in [BitDepth::Ten, BitDepth::Twelve] { + let opts = EncodeOptions::Avif(AvifEncodeConfig { + common: CommonEncodeOptions { + bit_depth: depth, + ..common(false, false, false) + }, + ..AvifEncodeConfig::default() + }); + let err = encode_rgb_image_to_vec(&img, &ImageMetadata::default(), &opts) + .expect_err("10/12-bit AVIF encode must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("justin13888/gamut#251"), + "error must reference the upstream issue, got: {msg}" + ); + } + } + + /// Strongest proof the AV1 bitstream + container are valid: decode it back. + #[cfg(feature = "avif-decode")] + #[test] + fn round_trips_through_decoder() { + use rawshift_image::formats::{StandardFormat, decode_standard_image}; + let img = synthetic_image(); + let data = encode_rgb_image_to_vec(&img, &ImageMetadata::default(), &avif(false, false)) + .expect("AVIF encode"); + let decoded = decode_standard_image(&data, StandardFormat::Avif).expect("decode AVIF"); + assert_eq!((decoded.width(), decoded.height()), (4, 4)); + } + #[test] fn test_avif_export_with_icc_enabled() { let img = synthetic_image(); @@ -547,92 +608,6 @@ mod avif_tests { } } -// ============================================================================ -// AVIF (libaom) Export Tests -// ============================================================================ - -#[cfg(feature = "avif-encode-libaom")] -mod avif_libaom_tests { - use super::*; - use rawshift_image::formats::export::LibaomEncodeConfig; - - fn libaom(bit_depth: BitDepth, exif: bool, icc: bool) -> EncodeOptions { - EncodeOptions::AvifLibaom(LibaomEncodeConfig { - common: CommonEncodeOptions { - bit_depth, - ..common(exif, icc, true) - }, - ..LibaomEncodeConfig::default() - }) - } - - /// The bytes are a valid ISO-BMFF AVIF file (`ftyp` box, AVIF brand). - fn is_avif(data: &[u8]) -> bool { - data.len() > 12 - && &data[4..8] == b"ftyp" - && matches!(&data[8..12], b"avif" | b"avis" | b"mif1") - } - - #[test] - fn encodes_8_10_12_bit() { - let img = synthetic_image(); - for depth in [BitDepth::Eight, BitDepth::Ten, BitDepth::Twelve] { - let data = encode_rgb_image_to_vec( - &img, - &ImageMetadata::default(), - &libaom(depth, false, false), - ) - .unwrap_or_else(|e| panic!("libaom encode at {depth:?} failed: {e}")); - assert!(is_avif(&data), "{depth:?} output must be a valid AVIF"); - } - } - - #[test] - fn rejects_16bit() { - let img = synthetic_image(); - let result = encode_rgb_image_to_vec( - &img, - &ImageMetadata::default(), - &libaom(BitDepth::Sixteen, false, false), - ); - assert!( - result.is_err(), - "16-bit must be rejected (AV1 maxes at 12-bit)" - ); - } - - #[test] - fn embeds_icc() { - let img = synthetic_image(); - let data = encode_rgb_image_to_vec( - &img, - &ImageMetadata::default(), - &libaom(BitDepth::Ten, false, true), - ) - .expect("libaom encode"); - assert!( - avif_has_icc(&data), - "libaom AVIF should embed an ICC profile" - ); - } - - /// Strongest proof the AV1 bitstream + container are valid: decode it back. - #[cfg(feature = "avif-decode")] - #[test] - fn round_trips_through_decoder() { - use rawshift_image::formats::decode_standard_image; - let img = synthetic_image(); - let data = encode_rgb_image_to_vec( - &img, - &ImageMetadata::default(), - &libaom(BitDepth::Eight, false, false), - ) - .expect("libaom encode"); - let decoded = decode_standard_image(&data).expect("decode libaom AVIF"); - assert_eq!((decoded.width(), decoded.height()), (4, 4)); - } -} - // ============================================================================ // JXL Export Tests // ============================================================================