From 6b566e72fed2475c16ebae86e590d737a1269408 Mon Sep 17 00:00:00 2001 From: Lilith River Date: Wed, 3 Jun 2026 23:41:03 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20Fidelity=20API=20=E2=80=94=20generic=20?= =?UTF-8?q?lossy/near-lossless/lossless=20encode=20quality=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sum-type fidelity abstraction on the EncoderConfig surface: - Fidelity { Lossy(LossyTarget), NearLossless(NearLosslessBudget), Lossless } - LossyTarget (non-exhaustive): Quality / Distance / Metric{QualityMetric,target} / TargetBytes / Bitrate — arms differ in cost/support - NearLosslessBudget(u16): codec-agnostic max per-channel L-infinity error as a parts-per-65535 fraction; exact at 8- and 16-bit via max_error_at_depth - FidelityMatch: Supported / MetricTranslated / TargetRaised / TargetLowered / Lossless / Unsupported (returned by try_target_fidelity) - QualityMetric (non-exhaustive): Ssimulacra2 / Butteraugli / Dssim / Psnr EncoderConfig gains with_fidelity (infallible, best-effort, chainable), try_target_fidelity (fail-fast, returns FidelityMatch), resolved_target_fidelity, with_alpha_fidelity/alpha_fidelity. EncodeCapabilities gains near_lossless + supports_{distance,metric_target,size_target}. Default impls bridge to the legacy with_generic_quality/with_lossless scalars both ways, so a codec implements either pair and gets the other free — additive, non-breaking. Codec impls land separately. Design + per-codec mapping: docs/near-lossless-design.md --- CHANGELOG.md | 12 + docs/near-lossless-design.md | 541 ++++++++--------------------------- src/capabilities.rs | 50 ++++ src/fidelity.rs | 372 ++++++++++++++++++++++++ src/lib.rs | 4 + src/traits/encoding.rs | 77 +++++ 6 files changed, 636 insertions(+), 420 deletions(-) create mode 100644 src/fidelity.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7f43a..140fc74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to zencodec are documented here. ### Added +- **Fidelity API** (`encode::{Fidelity, LossyTarget, NearLosslessBudget, + QualityMetric, FidelityMatch}`) — a generic lossy / near-lossless / lossless + encode-quality abstraction for [#12](https://github.com/imazen/zencodec/issues/12). + `Fidelity` is a sum type (lossy with a + `LossyTarget`, near-lossless within a per-channel `NearLosslessBudget`, or + lossless). `EncoderConfig` gains `with_fidelity` (infallible, best-effort), + `try_target_fidelity` (fail-fast, returns `FidelityMatch`), + `resolved_target_fidelity`, and `with_alpha_fidelity`/`alpha_fidelity` (lossy + color + lossless alpha). `EncodeCapabilities` gains `near_lossless`, + `supports_distance`, `supports_metric_target`, `supports_size_target`. Default + impls bridge to the legacy `with_generic_quality`/`with_lossless` scalars, so + the change is additive and non-breaking. Design: `docs/near-lossless-design.md`. - `ThreadingPolicy::resolve_thread_count()` — cross-codec shared helper that translates a [`ThreadingPolicy`] to the integer thread count that native-threaded encoder libraries (rav1e/ravif, dav1d/rav1d, libwebp, etc.) diff --git a/docs/near-lossless-design.md b/docs/near-lossless-design.md index 0b85e51..0b44308 100644 --- a/docs/near-lossless-design.md +++ b/docs/near-lossless-design.md @@ -1,475 +1,176 @@ -# Near-lossless / lossless-mode: a generic cross-codec abstraction +# Fidelity: a generic encode-quality abstraction (lossy / near-lossless / lossless) -Status: design proposal for [zencodec#12](https://github.com/imazen/zencodec/issues/12). -Date: 2026-06-03. Scope: the `EncoderConfig` fidelity surface. +Status: implemented in this crate (types + `EncoderConfig` surface) for +[zencodec#12](https://github.com/imazen/zencodec/issues/12). Codec +implementations land separately in each codec crate. -This document maps how every zen codec actually treats lossless and -near-lossless, identifies why the naive three-state enum in #12 is not -expressive enough, and proposes a generic abstraction that fits all of them -without lying to the caller. +#12 asked for `enum LosslessMode { Lossy, NearLossless, Lossless }`. After +walking the codecs and the trait idiom it became a single **`Fidelity` sum +type**, with the lossy arm an extensible **`LossyTarget`**, a codec-agnostic +**`NearLosslessBudget`**, and a rich **`FidelityMatch`** outcome. The types live +in `src/fidelity.rs` (full rustdoc there); this doc captures the *why* and the +per-codec mapping. --- -## 1. The request (#12) +## 1. What each codec actually does (verified against source) -> `with_lossless(bool)` can't express near-lossless modes that WebP, JXL, and -> PNG support. -> - WebP: `with_near_lossless(0-100)` pre-rounds pixels in the VP8L lossless path -> - JXL: distance 0.0-1.0 "perceptually lossless zone" (distinct from Modular lossless) -> - PNG: `with_near_lossless_bits(1-4)` rounds LSBs before DEFLATE -> - AVIF, JPEG, GIF have no near-lossless mode -> -> Proposal: add `enum LosslessMode { Lossy, NearLossless, Lossless }` to -> `EncoderConfig`; codecs without near-lossless treat `NearLossless` as -> high-quality lossy; deprecate `with_lossless(bool)`. - -The instinct is right — `bool` is too coarse — but the survey below shows the -three bullet points are **not the same kind of thing**, and a parameterless -`NearLossless` variant throws away the one number (the error budget) that makes -near-lossless a *contract* instead of a vibe. +| Codec | True lossless? | Near-lossless mechanism | Native parameter | Error semantics | +|-------|----------------|-------------------------|------------------|-----------------| +| **WebP** (`zenwebp`) | yes (VP8L) | **adaptive pre-quant on the lossless path** | `near_lossless: u8`, 0–100, **100 = off** (`config.rs:707`) | max per-channel error ∈ {0,1,3,7,15,31}; non-smooth pixels only; borders preserved | +| **PNG** (`zenpng`) | yes (always) | **global LSB rounding** before filter+DEFLATE | `near_lossless_bits: u8`, 0–4 (`encode.rs:58`) | round to 2^b → max err 2^(b−1); every pixel; 8-bit only | +| **JXL** (`jxl-encoder`) | yes (modular, d=0) | (a) `lossy_palette` (error-diffused, no clean ceiling); (b) small distance = a *lossy* "visually lossless" zone | `with_lossy_palette(bool)`; distance | distance is a perception bound, not a per-channel ceiling | +| **AVIF** (`zenavif`) | yes (qindex 0) | **none** (low-QP lossy only) | `with_lossless(bool)` (`codec.rs:84`) | — | +| **JPEG** (`zenjpeg`) | **no** | none | quality only | q100 still lossy | +| **GIF** (`zengif`) | yes (≤256 colors) | none for pixels (`lossy_tolerance` is animation frame-diff) | — | palette reduction is the lossy step | --- -## 2. What each codec actually does (verified against source) - -| Codec | True lossless? | "Near-lossless" mechanism | Native parameter | Error semantics | -|-------|----------------|---------------------------|------------------|-----------------| -| **WebP** (`zenwebp`) | yes (VP8L) | **adaptive pre-quantization on the lossless path** | `near_lossless: u8`, 0–100, **100 = off** | Guaranteed max per-channel error ∈ {0,1,3,7,15,31}; only non-smooth pixels touched; image borders never modified; multi-pass refinement. Requires lossless mode. | -| **PNG** (`zenpng`) | yes (always) | **global LSB rounding** before filter + DEFLATE | `near_lossless_bits: u8`, 0–4 | Round every channel to nearest multiple of 2^b → max error 2^(b−1). Uniform, every pixel. | -| **JXL** (`jxl-encoder`/`zenjxl`) | yes (modular, distance 0) | (a) `lossy_palette: bool` in modular; (b) *small butteraugli distance* = "visually lossless" — but that is a **lossy** codestream | `with_lossy_palette(bool)`; distance `-d` | Palette: error-diffused quantization, no clean ceiling. Distance: perception-bounded, **not** a per-channel ceiling, and not a lossless codestream. No `max_delta_error` knob is exposed (libjxl has it internally, unserialized). | -| **AVIF** (`zenavif`/`zenrav1e`) | yes (qindex 0) | **none** — only true-lossless or low-QP lossy | `with_lossless(bool)` + quality | No dedicated near-lossless preprocessing. | -| **JPEG** (`zenjpeg`) | **no** | none | quality only | Baseline only; q100 is still lossy (quantization > 0). | -| **GIF** (`zengif`) | yes (≤256 colors) | none for pixels; `lossy_tolerance` is **animation frame-diff** tolerance, not a pixel near-lossless mode | `lossy_tolerance: u8` | Palette reduction is the lossy step; LZW of indices is lossless. | - -File references for the live APIs: -`zenwebp/src/encoder/vp8l/near_lossless.rs` + `src/codec.rs:144`; -`zenpng/src/optimize.rs:537` (`near_lossless_quantize`) + `src/codec.rs:115`; -`jxl-encoder/.../api.rs:1191` (`with_lossy_palette`); -`zenavif/src/codec.rs:430` (`with_lossless`); -`zengif/src/codec.rs:399` (`with_lossless` → `lossy_tolerance=0`). - ---- - -## 3. The key insight: three axes are being conflated - -`LosslessMode { Lossy, NearLossless, Lossless }` collapses **three independent -properties** into one enum: - -1. **Coding mode** — is the *codestream* produced by a lossless coder or a lossy - coder? This is the fundamental fork. PNG/GIF are structurally lossless; - JPEG is structurally lossy; WebP/JXL/AVIF support both. - -2. **Near-lossless = bounded pre-quantization on a lossless coding path.** A - lossless coder applied to *deliberately, boundedly degraded* pixels. This is - the **only** thing that is technically "near-lossless." Its natural, - codec-independent currency is a **maximum per-channel error budget ε** (in - sample LSBs). WebP and PNG implement exactly this. JXL's `lossy_palette` is a - cousin (bounded, but error-diffused — no clean ε ceiling). +## 2. Three axes get conflated -3. **"Visually lossless" = the top of the lossy quality scale.** JXL d ∈ [0.1, - 1.0], AVIF very-high-quality, JPEG q95+, WebP-lossy q95+. This is **not a - separate mode** — it is `with_generic_quality()` near 100 (or a small - distance). It already has a knob. +A single "quality" or 3-state enum hides three independent properties: -The defect in the naive enum is that it **merges axis 2 and axis 3.** The #12 -bullet lists WebP/PNG (axis 2: an *ε ceiling on pixels*, lossless codestream) -alongside JXL distance 0.0–1.0 (axis 3: a *perception bound*, lossy codestream) -as if they were one mode. They are different contracts: +1. **Coding mode** — lossy vs lossless *codestream*. The real fork. +2. **Near-lossless** — *bounded pre-quantization on a lossless path*. Currency: a + **max per-channel L∞ error budget ε**. WebP and PNG implement exactly this. +3. **"Visually lossless"** — the *top of the lossy quality scale* (small distance + / very high quality). Not a separate mode — a lossy target. -| | Axis 2 — near-lossless | Axis 3 — visually lossless | -|---|---|---| -| Guarantee | "no channel deviates by more than ε" | "no human can tell" | -| Parameter | ε in LSBs | quality / butteraugli distance | -| Codestream | **lossless** coder | **lossy** coder | -| Reproducible/exact-ish | yes, bit-bounded | no, perceptual | -| Who has it | WebP, PNG | JXL, AVIF, JPEG, WebP-lossy | +Near-lossless (axis 2, an L∞ ε ceiling on a lossless codestream) and +visually-lossless (axis 3, a perceptual target on a lossy codestream) are +different contracts and must stay distinct arms, not be merged. -A generic abstraction must keep these separate, or it will mis-map JXL (its -"perceptually lossless zone" is reachable with the **existing quality knob**, -not a near-lossless mode) and over-promise on AVIF/JPEG. +The near-lossless metric is **L∞ per channel** (the worst single channel of the +worst pixel), **not** the mean OKLab ΔE / SSIM2 that `zenpng::QualityGate` and +`zenquant` use — those are soft, image-aggregate quality gates (a different +axis; mean ΔE 0.3 still allows individual pixels far off). --- -## 4. Proposed abstraction - -Two pieces, both small, both back-compatible. - -### 4.1 `LosslessMode` — the coding-mode selector (carries the budget) +## 3. The types (see `src/fidelity.rs` for full rustdoc) ```rust -/// How faithfully the encoder reproduces the input. -/// -/// This is the *coding-mode* axis. The "visually lossless" zone (a small -/// butteraugli distance / very high quality) is **not** here — it is the top -/// of the lossy quality scale, reachable with -/// [`with_generic_quality`](EncoderConfig::with_generic_quality). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum LosslessMode { - /// Lossy codestream. Fidelity is governed by quality / effort / distance. - Lossy, - - /// Lossless codestream of pixels that were pre-quantized within a bounded - /// per-channel error. The codec rounds pixel samples so the lossless coder - /// compresses better, while guaranteeing the deviation ceiling below. - /// - /// The budget is a **guaranteed L∞-per-channel ceiling** (see §4.2). `EXACT` - /// is equivalent to [`Lossless`](Self::Lossless). A codec must **never - /// exceed** this ceiling: it rounds the budget *down* to the nearest level - /// it can honor, never up. See [`EncoderConfig::lossless_mode`] for what was - /// actually resolved. +pub enum Fidelity { // #[non_exhaustive] + Lossy(LossyTarget), NearLossless(NearLosslessBudget), - - /// Mathematically exact. Decoding reproduces the input sample-for-sample. Lossless, } -impl LosslessMode { - /// A sensible default near-lossless budget (±2/255): visually transparent on - /// photographic content, meaningfully smaller files. Use when you want - /// "near-lossless" without choosing a number. - pub const NEAR_LOSSLESS_DEFAULT: Self = - Self::NearLossless(NearLosslessBudget::from_lsb8(2)); +pub enum LossyTarget { // #[non_exhaustive] + Quality(f32), // 0–100, single-pass everywhere + Distance(f32), // butteraugli; JXL single-pass, else iterative + Metric { metric: QualityMetric, target: f32 }, // iterative convergence + TargetBytes(u64), Bitrate(f32), // iterative } -``` -### 4.2 The error metric and the budget type (the part that matters) - -There are **two unrelated "error tolerance" notions** already living in our -codecs, and near-lossless must use the right one: - -| | **L∞ per-channel** (near-lossless) | **aggregate perceptual** (quality gate) | -|---|---|---| -| What it bounds | the *worst* single channel of the *worst* pixel | an *image-wide average* of perceptual distance | -| Examples in tree | `zengif::pixels_similar` (`dr,dg,db,da ≤ tol`); `zenpng::near_lossless_quantize` (round to 2^b); WebP `near_lossless` | `zenpng::QualityGate::{MaxDeltaE, MaxMpe, MinSsim2}` (mean OKLab ΔE / MPE / SSIMULACRA2); `zenquant` OKLab `base_tolerance` | -| Guarantee | **per-pixel, hard.** "no channel of any pixel moves by more than ε" | **statistical, soft.** mean ΔE 0.3 still allows individual pixels far off | -| Verifiable by | decode + diff + `max()` | requires colour-space conversion + averaging | - -**Near-lossless is L∞ per channel — a hard per-pixel ceiling — not an aggregate -metric.** That is the whole value proposition: a caller can promise downstream -"no pixel shifted by more than ε." A mean-ΔE / SSIM2 bound permits arbitrary -local excursions while the average stays low, so it is *not* near-lossless — it -is a *lossy quality target* (axis 3 / palette quality, `zenquant`'s domain). -Folding the OKLab-ΔE metric into `NearLossless` would repeat the §3 mistake of -merging two contracts. Keep them apart: `NearLossless` = L∞ ε; perceptual -targets stay on `with_generic_quality` / the quantizer's quality gate. - -**Units across bit depths (the 8-bit-vs-16-bit question).** A bare integer -`max_channel_error` is ambiguous: `2` means ±2/255 (~0.8%) at 8-bit but -±2/65535 (~0.003%) at 16-bit — wildly different. Worse, the two natural ways a -caller thinks about the budget scale with depth *differently*: - -- *"±2 levels of 255"* — a **fraction of full range**, depth-independent. The - 8-bit web case. (`zenpng`'s perceptual gates are already f32 fractions: - `MaxMpe(0.008)`.) -- *"the bottom 4 bits are sensor noise, drop them"* — **bits dropped**, - depth-relative. Same *count* of LSBs (15) at any depth, but a different - *fraction*. The 16-bit scientific case the question is about. - -So the budget is its own small type that can carry either intent and resolve to -a per-depth integer ceiling — never a raw depth-blind integer: +pub enum QualityMetric { Ssimulacra2, Butteraugli, Dssim, Psnr } // #[non_exhaustive] -```rust -/// A near-lossless error budget. The metric is **L∞ per channel** (max absolute -/// deviation of any single channel) — a hard per-pixel ceiling, *not* an -/// image-aggregate. Resolves to an integer ceiling at the codec's encoded depth. -#[derive(Clone, Copy, Debug, PartialEq)] -#[non_exhaustive] -pub enum NearLosslessBudget { - /// Fraction of a channel's full range, depth-independent. `0.0` == exact. - /// e.g. `Fraction(0.008)` ≈ ±2/255. The portable default representation. - Fraction(f32), - /// Absolute LSBs at 8-bit (parts per 255). Matches WebP / PNG-8 / GIF tables - /// 1:1; scaled up for deeper encodes. `Lsb8(2)` ⇒ ±2/255 ⇒ ±514/65535. - Lsb8(u8), - /// Low bits permitted to change (PNG-style, depth-relative). `Bits(4)` keeps - /// the same LSB count at any depth. Ceiling = 2^(b-1) at the encoded depth. - Bits(u8), -} - -impl NearLosslessBudget { - pub const EXACT: Self = Self::Fraction(0.0); - pub const fn from_lsb8(n: u8) -> Self { Self::Lsb8(n) } - - /// Largest integer L∞ ceiling (in LSBs) honorable at a `depth`-bit sample - /// that does **not exceed** this budget. Codecs round *down* from here to - /// their nearest representable level. - pub fn max_lsb_at_depth(self, depth: u32) -> u32 { - let full = (1u32 << depth) - 1; // e.g. 255 or 65535 - match self { - Self::Fraction(f) => (f.clamp(0.0, 1.0) * full as f32) as u32, - Self::Lsb8(n) => ((n as u32 * full) / 255), // scale 8-bit → depth - Self::Bits(b) => if b == 0 { 0 } else { 1u32 << (b - 1) }, // 2^(b-1) - } - } -} +pub struct NearLosslessBudget(u16); // max per-channel L∞ error, parts-per-65535 ``` -Recommendation: **`Fraction` is the canonical/portable form** (depth-independent, -matches the style of the existing perceptual gates), with `Lsb8` as the -ergonomic 8-bit constructor (matches every existing near-lossless impl 1:1) and -`Bits` for the deep-content "drop N noisy bits" intent. Codecs only ever consume -`max_lsb_at_depth(their_depth)` and round down — so the per-codec mapping tables -in §6 stay exactly as written for the 8-bit path. - -**Alpha.** The metric is per-channel; whether alpha is *in* the ceiling differs -today — `zengif` includes alpha, `zenpng::near_lossless_quantize` exempts it, -WebP includes it. The contract: **ε applies to every encoded channel including -alpha, unless the codec documents an exemption** (zenpng's exemption is then a -documented, queryable deviation, not silent). +**Why a sum type, not a scalar.** A scalar where `100 == lossless` has the JPEG +footgun (`quality(100)` isn't lossless there) and a fragile float boundary. A sum +type keeps the regimes — and their different metrics — apart: `LossyTarget` +(perceptual) and `NearLosslessBudget` (L∞) are different quantities, which is +exactly why each is its own arm. -**Today's reality:** all three near-lossless impls (WebP, PNG, GIF-tolerance) -are **8-bit only**; none does 16-bit near-lossless yet. The budget type is -forward-compatible so a 16-bit path can land later without an API change. +**Why `NearLosslessBudget` is parts-per-65535.** A codec-agnostic max-error +*fraction* (every value valid for every codec, resolved by rounding the +guarantee *down* at the codec's depth). `255 × 257 = 65535` makes 8- and 16-bit +both exact: `from_8bit_steps(2)` → `±2` at 8-bit, `±514` at 16-bit, no float +floor trap. Codecs only consume `budget.max_error_at_depth(depth)`. -### 4.3 `EncoderConfig` additions +**Why `LossyTarget` is non-exhaustive.** Its arms differ wildly in cost/support: +`Quality` is single-pass everywhere; `Metric`/`TargetBytes`/`Bitrate` need +iterative re-encoding only some codecs implement; `Distance` is single-pass on +JXL only. Ship `Quality` now, add the rest behind capability flags as +convergence machinery lands — without a breaking change. -```rust -pub trait EncoderConfig: Clone + Send + Sync { - // ... existing ... - - /// Set the coding-mode / fidelity for this encode. - /// - /// Default is a no-op (returns `self`). Codecs that support a fidelity - /// choice override this. After calling, read [`lossless_mode`] to see what - /// the codec actually resolved (it may promote or demote — see below). - fn with_lossless_mode(self, _mode: LosslessMode) -> Self { - self - } - - /// The resolved coding mode, or `None` if the codec has no fidelity choice. - /// - /// Returns what the codec will *actually* do, which may differ from what was - /// requested via [`with_lossless_mode`]: - /// - **honored** — WebP/PNG return `NearLossless { ε' }` with `ε' <= ε`. - /// - **promoted to `Lossless`** — a lossless-capable codec with no ε - /// mechanism (AVIF, GIF, JXL) returns exact `Lossless`. Fidelity is - /// *better* than asked; file is larger. Never worse than the contract. - /// - **demoted to `Lossy`** — a codec with no lossless path (JPEG) returns - /// `Lossy`. This is the only case where the result is lossier than asked, - /// so it is observable here. - /// - /// Default forwards [`is_lossless`] for codecs that only know the bool axis. - fn lossless_mode(&self) -> Option { - self.is_lossless().map(|l| { - if l { LosslessMode::Lossless } else { LosslessMode::Lossy } - }) - } - - // `with_lossless(bool)` and `is_lossless()` stay (see §5). Default impls - // now forward to the mode API so a codec only has to implement one side. - fn with_lossless(self, lossless: bool) -> Self { - self.with_lossless_mode(if lossless { - LosslessMode::Lossless - } else { - LosslessMode::Lossy - }) - } - fn is_lossless(&self) -> Option { - self.lossless_mode().map(|m| matches!(m, LosslessMode::Lossless)) - } -} -``` - -A codec implements **one** of the two pairs and gets the other for free. New -codecs implement `with_lossless_mode` + `lossless_mode`; existing codecs that -only implement `with_lossless` + `is_lossless` keep working unchanged (the -default `with_lossless_mode` is a no-op, so they simply ignore `NearLossless` -until they opt in — identical to today's behavior for any unknown setting). +--- -### 4.4 `EncodeCapabilities` addition +## 4. The `EncoderConfig` surface ```rust -// in struct EncodeCapabilities: -near_lossless: bool, // honors an ε-bounded near-lossless path -near_lossless_min_error: u16, // finest non-zero ε it can actually honor (0 = n/a) - -// const builder + getters mirroring the existing `with_lossless` / `lossless`: -pub const fn with_near_lossless(mut self, v: bool) -> Self { self.near_lossless = v; self } -pub const fn near_lossless(&self) -> bool { self.near_lossless } +fn with_fidelity(self, f: Fidelity) -> Self; // infallible, best-effort, chainable +fn try_target_fidelity(&mut self, f: Fidelity) -> FidelityMatch; // fail-fast, rich outcome +fn resolved_target_fidelity(&self) -> Option; // what the codec resolved to +fn with_alpha_fidelity(self, a: Option) -> Self; // lossy color + lossless alpha +fn alpha_fidelity(&self) -> Option; ``` -`lossy` / `lossless` already exist; `near_lossless` slots in beside them so a -codec-agnostic pipeline can query support before requesting it. - -### 4.5 `DynEncoderConfig` addition +Two setters, the Rust `x` / `try_x` convention: +- `with_fidelity` stays infallible and chainable (the common path; `Quality` is + universally supported). Best-effort — verify via the getter. +- `try_target_fidelity` is the opt-in strict path, returning **`FidelityMatch`**: ```rust -fn set_lossless_mode(&mut self, mode: LosslessMode); +pub enum FidelityMatch { // #[non_exhaustive] + Supported, // honored exactly + MetricTranslated(Fidelity), // a metric/distance target mapped to native scale + TargetRaised(Fidelity), // rounded up — fidelity ≥ request, contract holds + TargetLowered(Fidelity), // rounded down — still within the requested regime + Lossless, // promoted to exact lossless + Unsupported, // not honorable even approximately +} ``` -Blanket-implemented over `EncoderConfig` exactly like the existing `set_*` -forwarders in `traits/dyn_encoding.rs`. - ---- - -## 5. Back-compat & the `with_lossless` deprecation question - -#12 asks to deprecate `with_lossless(bool)`. **Recommendation: keep it, do not -deprecate.** Reasons: +The rule: a codec may quietly give you *better* fidelity than asked +(`TargetRaised`, `Lossless`) but never silently *less* — a downgrade across the +lossy/lossless fence is `Unsupported`, not a silent substitution. `try_` is a +cheap up-front resolution (no encode); for iterative targets it confirms the +codec will *attempt* convergence, the *achieved* value is an encode output. -- `bool` ↔ 3-state is lossy in only one direction (`bool` can't express - `NearLossless`), and the proposal already adds `with_lossless_mode` for that. - `with_lossless(true/false)` remains the correct, ergonomic call for the 90% - case that just wants exact-vs-lossy. -- Deprecating a widely-used setter is churn (every codec crate + callers) for no - expressive gain — the new method covers the gap additively. -- Wiring the defaults so each codec implements one side (§4.2) means there is no - duplication to drift. +The legacy scalars stay as **derived sugar** — defaults bridge both ways, so a +codec implements either the legacy `with_generic_quality`/`with_lossless` pair +*or* `with_fidelity`/`resolved_target_fidelity` and gets the other for free. +Additive, non-breaking. (No `DynEncoderConfig` change: fidelity is set on the +concrete config before type-erasure, like quality/lossless today.) -So: **additive only.** `with_lossless` / `is_lossless` keep their signatures and -semantics; `with_lossless_mode` / `lossless_mode` are the richer surface; nothing -is removed. This is a non-breaking minor release. - -(If a future major release does want to collapse them, the migration is trivial -because `bool` is exactly the `{Lossy, Lossless}` subset.) +Capabilities gain `near_lossless`, `supports_distance`, `supports_metric_target`, +`supports_size_target` so a pipeline can query before requesting. --- -## 6. Per-codec mapping (the ε → native-parameter table) - -ε is in 8-bit LSBs below. Codecs **round the guarantee down** — pick the largest -native level whose worst-case error does **not exceed** ε. +## 5. Per-codec resolution -### WebP — honored -WebP's guaranteed max error is `(1<0)` | `Lossless` | `caps.near_lossless` | |---|---|---|---|---| -| 0 | 100 (off) | 0 | 0 | `Lossless` | -| 1–2 | 80 | 1 | 1 | `NearLossless{1}` | -| 3–6 | 60 | 2 | 3 | `NearLossless{3}` | -| 7–14 | 40 | 3 | 7 | `NearLossless{7}` | -| 15–30 | 20 | 4 | 15 | `NearLossless{15}` | -| ≥31 | 0 | 5 | 31 | `NearLossless{31}` | - -Requires the VP8L (lossless) path; `with_lossless_mode(NearLossless{..})` -implies lossless coding and sets `near_lossless` accordingly. - -### PNG — honored -PNG rounds to nearest 2^b → max error `2^(b-1)`. Pick the largest `b ≤ 4` with -`2^(b-1) ≤ ε`: - -| requested ε | PNG `near_lossless_bits` | actual max err | `lossless_mode()` returns | -|---|---|---|---| -| 0 | 0 | 0 | `Lossless` | -| 1 | 1 | 1 | `NearLossless{1}` | -| 2–3 | 2 | 2 | `NearLossless{2}` | -| 4–7 | 3 | 4 | `NearLossless{4}` | -| ≥8 | 4 | 8 | `NearLossless{8}` | - -### JXL — promoted to `Lossless` (with a codec-specific escape hatch) -JXL has **no clean ε ceiling**. Its `lossy_palette` is error-diffused, so it -cannot promise "≤ ε per channel." The honest generic mapping is: -`capabilities.near_lossless = false`; `NearLossless{ε}` resolves to exact -`Lossless` (fidelity ≥ asked, never worse). `lossy_palette` stays a -**codec-specific extension** on `JxlEncoderConfig` (not wired to the generic ε), -because exposing it through ε would misreport its guarantee. JXL's "perceptually -lossless" use case is served by `with_generic_quality(~95–100)` / small distance -— axis 3, not this API. - -### AVIF, GIF — promoted to `Lossless` -Lossless-capable, no ε mechanism. `NearLossless{ε}` → exact `Lossless`. - -### JPEG — demoted to `Lossy` -No lossless path. `NearLossless{ε}` (and `Lossless`) → `Lossy` at a documented -high quality (≈ q95). This is the single case where the result is lossier than -the contract; it is observable via `lossless_mode()` returning `Lossy`. - -### Summary of resolution policy - -| Codec | `Lossy` | `NearLossless{ε>0}` | `Lossless` | `caps.near_lossless` | -|---|---|---|---|---| -| WebP | Lossy | **honored** (≤ ε) | Lossless | true | -| PNG | (indexed/lossy via quality) | **honored** (≤ ε) | Lossless | true | -| JXL | Lossy (VarDCT) | promote → Lossless | Lossless | false | -| AVIF | Lossy | promote → Lossless | Lossless | false | -| GIF | (palette) | promote → Lossless | Lossless | false | -| JPEG | Lossy | demote → Lossy | demote → Lossy | false | - -The rule in one line: **honor if you can; otherwise promote to exact lossless -(fidelity-first) if you have a lossless path; demote to high-q lossy only if you -have no lossless path — and always report the truth via `lossless_mode()`.** - -This refines #12's "treat NearLossless as high-quality lossy *for all* codecs -without near-lossless." For AVIF/GIF/JXL that would needlessly throw away -fidelity; promoting to exact lossless is the better default and keeps the "near" -in near-lossless. Only JPEG (no lossless path) actually has to demote. +| WebP | native q / iter for non-Quality | **honored** ≤ ε → native level | Lossless | true | +| PNG | indexed/lossy via quality | **honored** ≤ ε → bits | Lossless | true | +| JXL | VarDCT distance | promote → Lossless¹ | Lossless | false | +| AVIF | native q | promote → Lossless | Lossless | false | +| GIF | palette | promote → Lossless | Lossless | false | +| JPEG | native q | demote → Lossy² | demote → Lossy² | false | ---- +¹ JXL `lossy_palette` has no clean ε ceiling → stays a codec-specific knob, not +wired to the generic ε. ² JPEG has no lossless path; `Lossless`/`NearLossless` +report `FidelityMatch::Unsupported` (or, via the best-effort `with_fidelity`, +fall back to lossy — observable in the getter). -## 7. Edge cases & scope - -- **Bit depth.** Resolved per §4.2: the budget is depth-portable - (`Fraction`/`Lsb8`/`Bits`) and each codec consumes `max_lsb_at_depth(depth)`. - Never a raw depth-blind integer. -- **Metric.** L∞ per channel (max absolute per-channel deviation), a hard - per-pixel ceiling — *not* the mean OKLab ΔE / MPE / SSIM2 used by the - quantizer's quality gate. See §4.2 for why these are different axes. -- **Float / HDR formats.** A per-channel LSB ceiling is undefined for `f32` - pixels. For float formats `NearLossless` resolves to `Lossless` (or `Lossy` if - no lossless path) and `near_lossless` capability is false. -- **Alpha.** Per §4.2: ε applies to every encoded channel including alpha unless - the codec documents an exemption (zenpng exempts alpha today; zengif/WebP do - not). `with_alpha_quality` is orthogonal and unchanged. -- **`NearLosslessBudget::EXACT`** is exactly `Lossless`; codecs may normalize it - to the `Lossless` variant in `lossless_mode()`. +**ε → native (8-bit), rounding the guarantee down:** WebP ε∈{0,1,3,7,15,31} → +`near_lossless` {100,80,60,40,20,0}; PNG ε → `bits` where `2^(b-1) ≤ ε` (b≤4). --- -## 8. Why not the alternatives - -- **Parameterless `NearLossless`** (literal #12): throws away ε. Two callers - asking for "near-lossless" get unpredictable, codec-defined error. Not a - contract. (Kept as `NEAR_LOSSLESS_DEFAULT` for ergonomics, but the variant - still carries the number.) -- **Expose each codec's native knob generically** (`bits`, `0–100`): leaks codec - internals, doesn't compose, and the two scales are inverses of each other - (WebP 100 = off, PNG 0 = off) — a trap. -- **Fold "visually lossless" into the enum** (a `VisuallyLossless` variant): - re-merges axis 3 into axis 2. It's already `with_generic_quality(~98)`; a - second path to the same lossy codestream is redundant and confuses "ε ceiling" - with "perception bound." -- **A bare depth-blind integer `max_channel_error`** (the first cut here): - ambiguous across bit depths (±2 means 0.8% at 8-bit, 0.003% at 16-bit) and - can't express the "drop N noisy bits" intent. Replaced by `NearLosslessBudget` - (§4.2), which carries the intent (`Fraction`/`Lsb8`/`Bits`) and resolves to a - per-depth integer ceiling. The integer ceiling each codec honors is recovered - via `max_lsb_at_depth`; nothing is lost. -- **An aggregate perceptual metric (mean OKLab ΔE / SSIM2) for near-lossless**: - that is a *soft, image-wide* bound — it permits individual pixels to be far - off, so it is not "near-lossless." It is the right metric for the *lossy - quality* axis (`zenquant` / `QualityGate`), not the per-pixel ε ceiling. See - §4.2. +## 6. Where the field lives + +`EncoderConfig` is a trait; zencodec exports only the **types**. The stored value +lives in each codec's own config struct — for WebP/PNG, **reuse the existing +near-lossless field** (`LosslessConfig.near_lossless: u8`, +`EncodeConfig.near_lossless_bits: u8`); don't add a parallel one. Map at the +trait-impl boundary, store once. --- -## 9. Implementation checklist (when this lands — on clean `main`) - -zencodec (this crate): -1. Add `LosslessMode` (in a new `src/fidelity.rs` or alongside the encode - traits) + re-export at crate root. -2. Add `with_lossless_mode` / `lossless_mode` to `EncoderConfig` with the - forwarding defaults in §4.2; redefine `with_lossless` / `is_lossless` - defaults to forward (no signature change). -3. Add `near_lossless` (+ `near_lossless_min_error`) to `EncodeCapabilities` - with const builder + getter. -4. Add `set_lossless_mode` to `DynEncoderConfig` + blanket impl. -5. Document in `docs/spec.md` (§ EncoderConfig) and README. -6. `cargo semver-checks` — this is additive, expect a **minor** bump. - -Per codec (each in its own crate, its own commit): -7. WebP: implement `with_lossless_mode`/`lossless_mode`; map ε per §6; set - `caps.near_lossless = true`. -8. PNG: same; map ε → bits per §6; `caps.near_lossless = true`. -9. AVIF, GIF, JXL: implement `lossless_mode` to promote `NearLossless`→ - `Lossless`; `caps.near_lossless = false`. -10. JPEG: implement `lossless_mode` to demote `NearLossless`/`Lossless`→`Lossy`. -11. Round-trip tests per codec asserting the resolved `lossless_mode()` and the - actual decoded max-channel-error ≤ requested ε for WebP/PNG. - -> Note: at the time of writing, `main` has a separate in-flight, already-pushed -> feature branch (`feat/metadata-policy`). This is a design doc only; the trait -> changes above should land after that branch reconciles, to avoid entangling -> two API changes in one minor. +## 7. Status / next steps + +- **This PR (zencodec):** the types + `EncoderConfig` methods + capability flags + + tests. Default impls bridge to the legacy scalars so nothing breaks. +- **Follow-up (per codec crate):** WebP/PNG honor ε and set + `caps.near_lossless = true`; AVIF/GIF/JXL promote `NearLossless`→`Lossless`; + JPEG leaves the default. Round-trip tests assert the decoded + max-channel-error ≤ requested ε for WebP/PNG. Iterative `LossyTarget` arms land + with their capability flags and `FidelityMatch` reporting. diff --git a/src/capabilities.rs b/src/capabilities.rs index cc8cef4..e8e55e0 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -110,6 +110,10 @@ pub struct EncodeCapabilities { // Format capabilities lossy: bool, lossless: bool, + near_lossless: bool, + supports_distance: bool, + supports_metric_target: bool, + supports_size_target: bool, hdr: bool, gain_map: bool, native_gray: bool, @@ -149,6 +153,10 @@ impl EncodeCapabilities { encode_from: false, lossy: false, lossless: false, + near_lossless: false, + supports_distance: false, + supports_metric_target: false, + supports_size_target: false, hdr: false, gain_map: false, native_gray: false, @@ -205,6 +213,28 @@ impl EncodeCapabilities { pub const fn lossless(&self) -> bool { self.lossless } + /// Whether the codec honors an ε-bounded near-lossless path + /// ([`Fidelity::NearLossless`](crate::encode::Fidelity::NearLossless) with a + /// non-exact budget). True for WebP and PNG. + pub const fn near_lossless(&self) -> bool { + self.near_lossless + } + /// Whether the codec can target a butteraugli + /// [`Distance`](crate::encode::LossyTarget::Distance) (single-pass-able). + pub const fn supports_distance(&self) -> bool { + self.supports_distance + } + /// Whether the codec can converge to a + /// [`Metric`](crate::encode::LossyTarget::Metric) score (iterative). + pub const fn supports_metric_target(&self) -> bool { + self.supports_metric_target + } + /// Whether the codec can converge to a + /// [`TargetBytes`](crate::encode::LossyTarget::TargetBytes) / + /// [`Bitrate`](crate::encode::LossyTarget::Bitrate) budget (iterative). + pub const fn supports_size_target(&self) -> bool { + self.supports_size_target + } /// Whether the codec supports HDR content. pub const fn hdr(&self) -> bool { self.hdr @@ -346,6 +376,26 @@ impl EncodeCapabilities { self.lossless = v; self } + /// Set whether an ε-bounded near-lossless path is honored. + pub const fn with_near_lossless(mut self, v: bool) -> Self { + self.near_lossless = v; + self + } + /// Set whether a butteraugli distance target is supported. + pub const fn with_supports_distance(mut self, v: bool) -> Self { + self.supports_distance = v; + self + } + /// Set whether metric-score convergence is supported. + pub const fn with_supports_metric_target(mut self, v: bool) -> Self { + self.supports_metric_target = v; + self + } + /// Set whether size/bitrate convergence is supported. + pub const fn with_supports_size_target(mut self, v: bool) -> Self { + self.supports_size_target = v; + self + } /// Set whether HDR content is supported. pub const fn with_hdr(mut self, v: bool) -> Self { self.hdr = v; diff --git a/src/fidelity.rs b/src/fidelity.rs new file mode 100644 index 0000000..dd71622 --- /dev/null +++ b/src/fidelity.rs @@ -0,0 +1,372 @@ +//! Encode fidelity: how faithfully an encoder reproduces its input. +//! +//! [`Fidelity`] is the complete fidelity request — *exactly one of*: +//! - **lossy**, aiming at a [`LossyTarget`] (a quality dial, a perceptual +//! distance, a metric score, or a size/bitrate budget), +//! - **near-lossless**, within a per-channel [`NearLosslessBudget`], or +//! - **mathematically lossless**. +//! +//! It is a sum type so each regime carries the parameter its own metric needs, +//! illegal states (lossy ∧ lossless) are unrepresentable, and lossless is +//! explicit rather than "quality == 100". See `docs/near-lossless-design.md` +//! for the full rationale and per-codec mapping. + +/// The complete fidelity request for an encode — exactly one of three things. +/// +/// Set with [`EncoderConfig::with_fidelity`](crate::encode::EncoderConfig::with_fidelity); +/// read what the codec resolved with +/// [`resolved_target_fidelity`](crate::encode::EncoderConfig::resolved_target_fidelity). +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub enum Fidelity { + /// Lossy codestream. *What* it aims at is a [`LossyTarget`]. + Lossy(LossyTarget), + /// Lossless codestream of pixels pre-quantized within a per-channel L∞ + /// budget. [`NearLosslessBudget::EXACT`] is mathematically lossless. + NearLossless(NearLosslessBudget), + /// Mathematically exact — decode reproduces the input sample-for-sample. + Lossless, +} + +impl Fidelity { + /// Convenience constructor for lossy encoding at a 0–100 quality. + #[must_use] + pub const fn quality(q: f32) -> Self { + Self::Lossy(LossyTarget::Quality(q)) + } + + /// Convenience constructor for near-lossless within `budget`. + #[must_use] + pub const fn near_lossless(budget: NearLosslessBudget) -> Self { + Self::NearLossless(budget) + } + + /// Whether this request is mathematically lossless (exact `Lossless`, or a + /// near-lossless budget of [`NearLosslessBudget::EXACT`]). + #[must_use] + pub const fn is_lossless(self) -> bool { + match self { + Self::Lossless => true, + Self::NearLossless(b) => b.is_exact(), + Self::Lossy(_) => false, + } + } +} + +/// What a lossy encode aims at. +/// +/// **Non-exhaustive — the arms differ in cost and support.** `Quality` maps to a +/// native dial in a single pass on every codec; `Metric`, `TargetBytes`, and +/// `Bitrate` require *iterative* re-encoding (binary search over the quantizer) +/// that only some codecs implement; `Distance` is single-pass on JXL only. Query +/// [`EncodeCapabilities`](crate::encode::EncodeCapabilities) before requesting a +/// non-trivial target, and check +/// [`try_target_fidelity`](crate::encode::EncoderConfig::try_target_fidelity) +/// for how (or whether) it was honored. +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub enum LossyTarget { + /// Calibrated 0–100 quality dial (the same scale as + /// [`with_generic_quality`](crate::encode::EncoderConfig::with_generic_quality)). + /// Single-pass on every codec. The safe default. + Quality(f32), + /// Butteraugli distance (JXL-native; lower is better, ~0.5–1.0 is visually + /// lossless). Single-pass on JXL; iterative elsewhere. + Distance(f32), + /// Hit a quality-metric score — the codec binary-searches the quantizer. + /// Iterative; only codecs that implement convergence honor it. + Metric { + /// Which metric to target. + metric: QualityMetric, + /// Target score on that metric's own scale. + target: f32, + }, + /// Hit a target encoded size in bytes (iterative). + TargetBytes(u64), + /// Hit a target bitrate in bits per pixel (iterative). + Bitrate(f32), +} + +/// A quality / perceptual metric a lossy encode can target. +/// +/// Non-exhaustive: metrics are added as convergence support lands. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum QualityMetric { + /// SSIMULACRA2 (0–100, higher is better). + Ssimulacra2, + /// Butteraugli (lower is better). + Butteraugli, + /// DSSIM (lower is better). + Dssim, + /// PSNR in dB (higher is better). + Psnr, +} + +/// The maximum a near-lossless encode may change **any single channel of any +/// single pixel** — the L∞-per-channel ceiling — as a fraction of that +/// channel's full range. +/// +/// **Codec-agnostic and total: every value is valid for every lossless codec.** +/// A codec resolves it to the largest native setting whose *guaranteed* error +/// does not exceed the budget at its own bit depth (rounding **down**, never +/// up), and reports what it honored. +/// +/// Stored as parts-per-65535 of full scale — a *fraction*, not "16-bit LSBs". +/// `255 × 257 = 65535` makes both 8-bit and 16-bit resolve exactly with integer +/// math (no float-floor trap): `from_8bit_steps(2)` is `±2` at 8-bit and `±514` +/// at 16-bit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NearLosslessBudget(u16); + +impl NearLosslessBudget { + /// Exact — identical to [`Fidelity::Lossless`]. + pub const EXACT: Self = Self(0); + /// The whole channel range (loosest possible budget). + pub const MAX: Self = Self(u16::MAX); + /// A sensible default (±2/255): visually transparent on photographic + /// content, meaningfully smaller files. Use when you want "near-lossless" + /// without choosing a number. + pub const DEFAULT: Self = Self::from_8bit_steps(2); + + /// From the familiar 0–255 scale. `from_8bit_steps(2)` ⇒ `±2` on an 8-bit + /// channel, and the same *fraction* (`±514`) on a 16-bit channel. + #[must_use] + pub const fn from_8bit_steps(n: u8) -> Self { + // n ≤ 255 ⇒ n*257 ≤ 65535, exact in u16. + Self(((n as u32) * 257) as u16) + } + + /// From the 0–65535 scale, for deep content. + #[must_use] + pub const fn from_16bit_steps(n: u16) -> Self { + Self(n) + } + + /// From a fraction of full range (depth-independent). Clamped to `[0, 1]`. + #[must_use] + pub fn from_fraction(f: f32) -> Self { + let v = (f.clamp(0.0, 1.0) * 65535.0 + 0.5) as u32; + Self(if v > 65535 { 65535 } else { v as u16 }) + } + + /// Whether this is the exact (zero-error) budget. + #[must_use] + pub const fn is_exact(self) -> bool { + self.0 == 0 + } + + /// The budget as a fraction of full scale (`0.0..=1.0`). + #[must_use] + pub fn as_fraction(self) -> f32 { + f32::from(self.0) / 65535.0 + } + + /// The integer L∞ ceiling (in LSBs) a `depth`-bit codec may not exceed. + /// Exact integer math; the floor *is* the "round the guarantee down" rule. + /// + /// `from_8bit_steps(2).max_error_at_depth(8) == 2` and + /// `from_8bit_steps(2).max_error_at_depth(16) == 514`. + #[must_use] + pub const fn max_error_at_depth(self, depth: u32) -> u32 { + let full = (1u32 << depth) - 1; + ((self.0 as u32) * full) / 65535 + } +} + +/// How a codec resolved a requested [`Fidelity`], returned by +/// [`try_target_fidelity`](crate::encode::EncoderConfig::try_target_fidelity). +/// +/// A codec may quietly give you *better* fidelity than you asked +/// ([`TargetRaised`](Self::TargetRaised), [`Lossless`](Self::Lossless)) but a +/// move to *lower* fidelity than requested is always observable here (and a +/// downgrade across the lossy/lossless fence is [`Unsupported`](Self::Unsupported), +/// not a silent substitution). +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub enum FidelityMatch { + /// Honored exactly as requested. + Supported, + /// A metric / distance target was translated to the codec's native scale. + /// The applied fidelity is in the payload. + MetricTranslated(Fidelity), + /// Rounded up to a higher-quality / tighter supported setting than + /// requested (fidelity ≥ request — the contract still holds). + TargetRaised(Fidelity), + /// Rounded down to a lower-quality / looser supported setting than + /// requested (still within the requested regime). + TargetLowered(Fidelity), + /// Resolved to exact lossless — e.g. a near-lossless budget on a codec with + /// no ε mechanism, or an [`NearLosslessBudget::EXACT`] budget. + Lossless, + /// Not honorable even approximately (e.g. `Lossless` on a codec with no + /// lossless path, or a metric target it cannot converge to). + Unsupported, +} + +impl FidelityMatch { + /// Whether the codec will produce output for this request (anything other + /// than [`Unsupported`](Self::Unsupported)). + #[must_use] + pub const fn is_honored(self) -> bool { + !matches!(self, Self::Unsupported) + } + + /// The applied fidelity carried by this match, when it differs from the + /// request. `Supported` and `Unsupported` carry none. + #[must_use] + pub const fn resolved(self) -> Option { + match self { + Self::MetricTranslated(f) | Self::TargetRaised(f) | Self::TargetLowered(f) => Some(f), + Self::Lossless => Some(Fidelity::Lossless), + Self::Supported | Self::Unsupported => None, + } + } +} + +/// Classify how a `resolved` fidelity relates to the `requested` one. +/// +/// Used by the default [`try_target_fidelity`](crate::encode::EncoderConfig::try_target_fidelity) +/// implementation. The generic default can classify the common cases (exact, +/// quality raised/lowered, metric-translated-to-quality, promoted-to-lossless, +/// unsupported); codecs override `try_target_fidelity` for fully precise +/// reporting that knows their native quantization. +pub(crate) fn classify_fidelity_match( + requested: Fidelity, + resolved: Option, +) -> FidelityMatch { + let Some(resolved) = resolved else { + return FidelityMatch::Unsupported; + }; + if resolved == requested { + return FidelityMatch::Supported; + } + match (requested, resolved) { + (_, Fidelity::Lossless) => FidelityMatch::Lossless, + (Fidelity::Lossy(req), Fidelity::Lossy(LossyTarget::Quality(rq))) => match req { + LossyTarget::Quality(reqq) if rq > reqq => FidelityMatch::TargetRaised(resolved), + LossyTarget::Quality(_) => FidelityMatch::TargetLowered(resolved), + // requested a non-Quality lossy target, got a plain quality back + _ => FidelityMatch::MetricTranslated(resolved), + }, + // anything else changed but the direction isn't generically knowable; + // report the conservative "not better than asked" so callers inspect it. + _ => FidelityMatch::TargetLowered(resolved), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn budget_exact_round_trips_both_depths() { + let b = NearLosslessBudget::from_8bit_steps(2); + assert_eq!(b.max_error_at_depth(8), 2, "±2 at 8-bit"); + assert_eq!(b.max_error_at_depth(16), 514, "same fraction at 16-bit"); + assert!(!b.is_exact()); + assert!(NearLosslessBudget::EXACT.is_exact()); + assert_eq!(NearLosslessBudget::EXACT.max_error_at_depth(8), 0); + } + + #[test] + fn budget_max_is_full_range() { + assert_eq!(NearLosslessBudget::MAX.max_error_at_depth(8), 255); + assert_eq!(NearLosslessBudget::MAX.max_error_at_depth(16), 65535); + } + + #[test] + fn budget_default_and_steps() { + assert_eq!( + NearLosslessBudget::DEFAULT, + NearLosslessBudget::from_8bit_steps(2) + ); + // from_8bit_steps(1) is exactly one 8-bit LSB. + assert_eq!( + NearLosslessBudget::from_8bit_steps(1).max_error_at_depth(8), + 1 + ); + assert_eq!( + NearLosslessBudget::from_8bit_steps(255), + NearLosslessBudget::MAX + ); + } + + #[test] + fn budget_from_fraction_is_clamped() { + assert_eq!( + NearLosslessBudget::from_fraction(-1.0), + NearLosslessBudget::EXACT + ); + assert_eq!( + NearLosslessBudget::from_fraction(2.0), + NearLosslessBudget::MAX + ); + // ~2/255 ≈ 0.00784 → 2 at 8-bit. + assert_eq!( + NearLosslessBudget::from_fraction(2.0 / 255.0).max_error_at_depth(8), + 2 + ); + } + + #[test] + fn fidelity_is_lossless() { + assert!(Fidelity::Lossless.is_lossless()); + assert!(Fidelity::NearLossless(NearLosslessBudget::EXACT).is_lossless()); + assert!(!Fidelity::NearLossless(NearLosslessBudget::DEFAULT).is_lossless()); + assert!(!Fidelity::quality(90.0).is_lossless()); + } + + #[test] + fn classify_exact_and_unsupported() { + let q = Fidelity::quality(85.0); + assert_eq!( + classify_fidelity_match(q, Some(q)), + FidelityMatch::Supported + ); + assert_eq!(classify_fidelity_match(q, None), FidelityMatch::Unsupported); + } + + #[test] + fn classify_promote_to_lossless() { + let nl = Fidelity::NearLossless(NearLosslessBudget::DEFAULT); + assert_eq!( + classify_fidelity_match(nl, Some(Fidelity::Lossless)), + FidelityMatch::Lossless + ); + } + + #[test] + fn classify_quality_raised_and_lowered() { + let req = Fidelity::quality(83.0); + assert_eq!( + classify_fidelity_match(req, Some(Fidelity::quality(85.0))), + FidelityMatch::TargetRaised(Fidelity::quality(85.0)) + ); + assert_eq!( + classify_fidelity_match(req, Some(Fidelity::quality(80.0))), + FidelityMatch::TargetLowered(Fidelity::quality(80.0)) + ); + } + + #[test] + fn classify_metric_translated() { + let req = Fidelity::Lossy(LossyTarget::Metric { + metric: QualityMetric::Ssimulacra2, + target: 90.0, + }); + let got = Fidelity::quality(88.0); + assert_eq!( + classify_fidelity_match(req, Some(got)), + FidelityMatch::MetricTranslated(got) + ); + } + + #[test] + fn fidelity_match_resolved_and_honored() { + assert!(FidelityMatch::Supported.is_honored()); + assert!(!FidelityMatch::Unsupported.is_honored()); + assert_eq!(FidelityMatch::Lossless.resolved(), Some(Fidelity::Lossless)); + assert_eq!(FidelityMatch::Supported.resolved(), None); + } +} diff --git a/src/lib.rs b/src/lib.rs index d3c112d..31432d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,7 @@ mod cost; mod detect; mod error; mod extensions; +mod fidelity; mod format; /// Cross-codec gain map types (ISO 21496-1). pub mod gainmap; @@ -143,6 +144,9 @@ pub mod encode { // Types pub use crate::capabilities::EncodeCapabilities; + pub use crate::fidelity::{ + Fidelity, FidelityMatch, LossyTarget, NearLosslessBudget, QualityMetric, + }; pub use crate::negotiate::best_encode_format; pub use crate::output::EncodeOutput; pub use crate::policy::EncodePolicy; diff --git a/src/traits/encoding.rs b/src/traits/encoding.rs index 89ef4a0..9625423 100644 --- a/src/traits/encoding.rs +++ b/src/traits/encoding.rs @@ -2,6 +2,7 @@ use alloc::boxed::Box; +use crate::fidelity::{Fidelity, FidelityMatch, LossyTarget}; use crate::format::ImageFormat; use crate::{EncodeCapabilities, Metadata, ResourceLimits}; use zenpixels::PixelDescriptor; @@ -128,6 +129,82 @@ pub trait EncoderConfig: Clone + Send + Sync { None } + // ----------------------------------------------------------------------- + // Fidelity (lossy target / near-lossless budget / lossless) + // ----------------------------------------------------------------------- + + /// Set the encode [`Fidelity`] — a lossy [`LossyTarget`], a near-lossless + /// [`NearLosslessBudget`](crate::encode::NearLosslessBudget), or lossless. + /// + /// Infallible and **best-effort**: the codec does what it can and silently + /// substitutes the rest. To learn up-front *how* (or whether) the request + /// was honored, use [`try_target_fidelity`](Self::try_target_fidelity); to + /// read what it resolved to afterwards, use + /// [`resolved_target_fidelity`](Self::resolved_target_fidelity). + /// + /// The default bridges to the legacy quality/lossless setters so codecs that + /// have not implemented this yet still behave sensibly (a near-lossless + /// budget promotes to exact lossless; non-`Quality` lossy targets fall back + /// to default-quality lossy). Codecs override to honor budgets and targets. + fn with_fidelity(self, fidelity: Fidelity) -> Self { + match fidelity { + Fidelity::Lossy(LossyTarget::Quality(q)) => { + self.with_lossless(false).with_generic_quality(q) + } + Fidelity::Lossy(_) => self.with_lossless(false), + Fidelity::NearLossless(_) | Fidelity::Lossless => self.with_lossless(true), + } + } + + /// Set fidelity, fail-fast, reporting how the codec resolved the request. + /// + /// Unlike [`with_fidelity`](Self::with_fidelity) this tells you immediately + /// whether the target was honored exactly, approximated (rounded up/down or + /// metric-translated), promoted to lossless, or is unsupported even + /// approximately. A codec may quietly give you *better* fidelity than asked, + /// never *worse* — a downgrade across the lossy/lossless fence reports + /// [`FidelityMatch::Unsupported`](crate::encode::FidelityMatch::Unsupported). + /// + /// This is a cheap up-front resolution — no encoding. For iterative targets + /// (`Metric`/`TargetBytes`/`Bitrate`) it confirms the codec will *attempt* + /// convergence; the *achieved* value is an encode output. + /// + /// The default applies via [`with_fidelity`](Self::with_fidelity) and + /// classifies the common cases; codecs override for fully precise reporting. + fn try_target_fidelity(&mut self, fidelity: Fidelity) -> FidelityMatch { + *self = self.clone().with_fidelity(fidelity); + crate::fidelity::classify_fidelity_match(fidelity, self.resolved_target_fidelity()) + } + + /// The fidelity the codec actually resolved to, or `None` if it has no + /// fidelity control. + /// + /// The default derives from [`is_lossless`](Self::is_lossless) and + /// [`generic_quality`](Self::generic_quality), so codecs that only implement + /// the legacy getters still report a `Fidelity`. + fn resolved_target_fidelity(&self) -> Option { + if self.is_lossless() == Some(true) { + return Some(Fidelity::Lossless); + } + self.generic_quality().map(Fidelity::quality) + } + + /// Set an independent [`Fidelity`] for the alpha plane, or `None` to follow + /// the color fidelity / codec default. + /// + /// Expresses lossy-color with lossless-alpha (which WebP and AVIF support) + /// — something a single color fidelity cannot. Default: no-op (follow + /// color). Verify with [`alpha_fidelity`](Self::alpha_fidelity). + fn with_alpha_fidelity(self, _alpha: Option) -> Self { + self + } + + /// The resolved alpha-plane fidelity, or `None` if alpha follows color / the + /// codec has no independent alpha fidelity. + fn alpha_fidelity(&self) -> Option { + None + } + /// Create a per-operation job, consuming the config. /// /// The job owns the config and all configuration set on it