diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca838e..5e28b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * **sqzer:** `Output` has a new public field, `lossless`: the bytes hold exactly the samples the encoder was given. Code that builds an `Output` by hand sets it, and code that read `Output::target` to learn whether an encode was lossless reads the field instead (ADR-0012 D4) * **metrics:** `Trial` has a new public field, `size`: the bytes the trial encoded to. `Search::run` takes a closure that returns `Scored`, the output with its score and its size, in place of the pair of output and score. Code that builds a `Trial` or calls `run` adds the size * **sqzer:** `Progress::Trial` has a new field, `size`, the bytes the trial encoded to. A match that names every field adds it, or `..` +* **sqzer:** `Target::Quality` on a format set with `Sqzer::format`, or `format` in the npm package, whose encoder in this build only writes lossless is an error, where it encoded lossless and reported the quality: `EncoderUnavailable` with `native-webp` in `available_in` for WebP in the portable tier, as the README has said and the command line has done, and `Unsupported` for PNG, which no build writes lossy. Pass `Target::Lossless` instead, which is what the bytes were; a build with `native-webp` takes the quality ([#56](https://github.com/sqzer-dev/sqzer/issues/56)) * **cli:** `--lossless` and `--preset lossless` refuse `-x png:optimize_alpha=true`, which changes the colour under fully transparent pixels. Drop `--lossless`: PNG comes from a lossless-only encoder either way, so the file is the same. `--json` now reports such a run as `"lossless": false` ### Features @@ -18,6 +19,10 @@ * **wasm:** each `Trial` in `trials`, and what `onTrial` receives, carries `size`, the bytes that trial encoded to, so a page can plot quality against score and size as the search runs ([#55](https://github.com/sqzer-dev/sqzer/issues/55)) * **wasm:** `options()` lists the keys `optimize` and `encode` take, each with its default as the package spells it and its help, as `codecs()` lists the backend options: `[{ key: "target", default: "70", help: "..." }, ...]`. A key whose default depends on the image or the format says `auto`; one that does nothing when left out has none. The defaults are read from the library's builder, so a page can show every control at the package's default and drop its own copy ([#57](https://github.com/sqzer-dev/sqzer/issues/57)) +### Bug Fixes + +* **sqzer:** an explicit quality with no format, on a graphic the portable tier sends to lossless WebP, is reported as what it was: `Output::target` is `Resolved::Lossless` and `Output::lossless` is `true`, where the record said the quality asked for and `lossless: false` over the same lossless bytes. `--json`, `{quality}` in a `--template` and the npm package follow ([#56](https://github.com/sqzer-dev/sqzer/issues/56)) + ## 0.3.0 ### ⚠️ BREAKING CHANGES diff --git a/README.md b/README.md index cdf2033..7dd4341 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ native C bindings, opt-in, one feature per library, `native` for all five. agpl reserved. Never a default dependency, never in the library. ``` -> **Note**: The portable tier cannot write lossy WebP or JPEG XL. No permissive pure-Rust encoder exists for either as of September 2026 (`jixel` is a candidate for JPEG XL, unmeasured). Requesting one in a portable build returns `EncoderUnavailable` with the feature that would provide it, it never silently falls back. +> **Note**: The portable tier cannot write lossy WebP or JPEG XL. No permissive pure-Rust encoder exists for either as of September 2026 (`jixel` is a candidate for JPEG XL, unmeasured). Requesting one in a portable build returns `EncoderUnavailable` with the feature that would provide it, it never silently falls back. With no `-f`, a graphic goes to lossless WebP whatever `-q` says, and the record says `lossless`. > **Note**: Every native feature vendors and builds its C library from source (`cc` or cmake; nasm on x86; a C++ compiler for `native-jxl` and `native-jpegli`), except `native-heif`, which links nothing. HEIC comes from the OS decoder on macOS (ImageIO, every Mac since 10.13) and Windows (WIC, needs the HEIF Image Extension and HEVC Video Extensions from the Microsoft Store), and from `libheif` (LGPL-3.0, >= 1.17, with an HEVC decoder) loaded at run time everywhere but musl: `libheif1` plus `libheif-plugin-libde265` on Debian and Ubuntu, `libheif-freeworld` on Fedora, `brew install libheif` on a Mac without ImageIO's decoder, or `SQZER_LIBHEIF` pointing at the library. A binary always starts; `sqzer --list-codecs` says which HEIC decoder it has and whether this machine can use it, and a HEIC input on a machine with none gets an error naming the fix. Static musl builds have no HEIC. [`docs/adr/0004-native-tier.md`](docs/adr/0004-native-tier.md) has the crate choices and the licence facts, [`docs/adr/0005-heic-through-os-decoders.md`](docs/adr/0005-heic-through-os-decoders.md) the HEIC design, and which targets CI covers: all five on x86_64 Linux and both macOS targets, four on aarch64 Linux (jpegli crashes there), four on Windows (jpegli cannot share a cmake generator with libjxl there), three on musl (no C++ toolchain there yet). That per-target list is what `--features native` builds on `sqzer` and `sqzer-cli` and what the release binaries carry; a single `native-*` feature is strict and fails to build where the backend cannot ([`docs/adr/0006-release-matrix.md`](docs/adr/0006-release-matrix.md)). diff --git a/crates/sqzer-cli/src/job.rs b/crates/sqzer-cli/src/job.rs index 3f6feed..f0de77a 100644 --- a/crates/sqzer-cli/src/job.rs +++ b/crates/sqzer-cli/src/job.rs @@ -338,11 +338,13 @@ fn plan( } } let quality = match sqzer.params().target { - Target::Quality(q) => Some(QualityTag::Quality(q)), - Target::Lossless => Some(QualityTag::Lossless), - Target::Ssimulacra2(_) if !caps.lossy => { + // A lossless-only encoder picked per image writes its one mode, + // as the facade does; `-f` with `-q` was refused with the flags. + Target::Ssimulacra2(_) | Target::Quality(_) if !caps.lossy => { Some(QualityTag::of(Resolved::Lossless, lossy_options.is_empty())) } + Target::Quality(q) => Some(QualityTag::Quality(q)), + Target::Lossless => Some(QualityTag::Lossless), Target::Ssimulacra2(t) => { rec.target = Some(t); None diff --git a/crates/sqzer-cli/tests/cli.rs b/crates/sqzer-cli/tests/cli.rs index 41ba9cf..f477b24 100644 --- a/crates/sqzer-cli/tests/cli.rs +++ b/crates/sqzer-cli/tests/cli.rs @@ -950,6 +950,47 @@ fn png_colors_write_a_palette_and_the_record_says_lossy() { ); } +#[test] +fn a_quality_on_a_graphic_picked_for_lossless_webp_is_reported_lossless() { + // Lossy WebP needs the native tier; this is about the portable pick. + if cfg!(feature = "native") { + return; + } + let sb = Sandbox::new("graphic-quality"); + // A flat 4 x 4 image: a graphic by the content heuristic. + let mut ppm = b"P6\n4 4\n255\n".to_vec(); + ppm.extend(std::iter::repeat_n([200u8, 200, 200], 16).flatten()); + fs::write(sb.path("in.ppm"), ppm).unwrap(); + let args = [ + "in.ppm", + "-q", + "80", + "--force", + "--json", + "--template", + "{stem}-{quality}.{ext}", + ]; + let (code, out, err) = run(sb.sqzer().args(args)); + assert_eq!(code, 0, "{err}"); + let r = &json_lines(&out)[0]; + assert_eq!(r["status"], "written", "{r}"); + assert_eq!(r["content"], "graphic", "{r}"); + assert_eq!(r["format"], "webp", "{r}"); + // The one WebP encoder writes lossless whatever the quality: the + // record says so and names no quality (issue 56). + assert_eq!(r["lossless"], true, "{r}"); + assert!(r.get("quality").is_none(), "{r}"); + assert_eq!(r["output"], "in-lossless.webp", "{r}"); + assert_eq!( + &fs::read(sb.path("in-lossless.webp")).unwrap()[8..12], + b"WEBP" + ); + // The dry run plans the same name. + let (code, out, err) = run(sb.sqzer().args(args).arg("-n")); + assert_eq!(code, 0, "{err}"); + assert_eq!(json_lines(&out)[0]["output"], "in-lossless.webp"); +} + #[test] fn lossless_refuses_an_option_that_gives_samples_up() { let sb = Sandbox::new("palette-refused"); diff --git a/crates/sqzer-core/src/codec.rs b/crates/sqzer-core/src/codec.rs index a83caff..869819f 100644 --- a/crates/sqzer-core/src/codec.rs +++ b/crates/sqzer-core/src/codec.rs @@ -156,6 +156,20 @@ impl Format { } } + /// The subset of [`Format::encoder_features`] whose encoder has a lossy + /// mode. This is the `available_in` list in + /// [`crate::Error::EncoderUnavailable`] for an explicit quality on a + /// format whose encoder in this build only writes lossless, WebP in the + /// portable tier. Empty for PNG, which no build writes lossy. + #[must_use] + pub const fn lossy_encoder_features(self) -> &'static [&'static str] { + match self { + Self::WebP => &["native-webp"], + Self::Png => &[], + _ => self.encoder_features(), + } + } + /// Cargo features of `sqzer-codecs` that provide a decoder for this /// format, the mirror of [`Format::encoder_features`]. This is the /// `available_in` list in [`crate::Error::DecoderUnavailable`]. @@ -361,6 +375,23 @@ pub trait Encoder: Send + Sync { mod tests { use super::*; + #[test] + fn lossy_encoder_features_are_a_subset_of_encoder_features() { + for &f in Format::ALL { + for feature in f.lossy_encoder_features() { + assert!(f.encoder_features().contains(feature), "{f}: {feature}"); + } + } + // The one format whose portable encoder is lossless only names the + // native feature alone; no build writes lossy PNG. + assert_eq!(Format::WebP.lossy_encoder_features(), ["native-webp"]); + assert_eq!(Format::Png.lossy_encoder_features(), [] as [&str; 0]); + assert_eq!( + Format::Jpeg.lossy_encoder_features(), + Format::Jpeg.encoder_features() + ); + } + #[test] fn extension_round_trips() { for &f in Format::ALL { diff --git a/crates/sqzer-wasm/README.md b/crates/sqzer-wasm/README.md index 016e77e..4a2941b 100644 --- a/crates/sqzer-wasm/README.md +++ b/crates/sqzer-wasm/README.md @@ -23,7 +23,7 @@ decode JPEG, PNG, WebP, AVIF, JPEG XL, GIF, TIFF, BMP, TGA, ICO, QOI, PNM, Ope encode JPEG (mozjpeg-rs), PNG (oxipng), AVIF (ravif), lossless WebP (image-webp) ``` -> **Note**: Lossy WebP and JPEG XL cannot be written: no permissively licensed pure-Rust encoder exists for either. Asking for one throws an `EncoderUnavailable` error, it never falls back to another format. `codecs()` lists what the build carries. +> **Note**: Lossy WebP and JPEG XL cannot be written: no permissively licensed pure-Rust encoder exists for either. Asking for one throws an `EncoderUnavailable` error, it never falls back to another format. With no `format`, a graphic goes to lossless WebP whatever `quality` says, and the result says `lossless: true`. `codecs()` lists what the build carries. ## Decode once, encode many times diff --git a/crates/sqzer-wasm/src/tests.rs b/crates/sqzer-wasm/src/tests.rs index 14dde07..4958c18 100644 --- a/crates/sqzer-wasm/src/tests.rs +++ b/crates/sqzer-wasm/src/tests.rs @@ -265,6 +265,36 @@ fn a_missing_backend_names_the_builds_that_have_it() { assert!(get(&error, "availableIn").is_undefined()); } +#[wasm_bindgen_test] +fn a_quality_on_a_graphic_picked_for_lossless_webp_is_reported_lossless() { + // A flat image: a graphic by the content heuristic, WebP by default. + let flat = vec![200u8; 16 * 16 * 4]; + let image = from_pixels(&flat, 16, 16, None).unwrap(); + let out = image + .encode(options(r#"{"quality":80}"#)) + .unwrap() + .js_value(); + assert_eq!(text(&out, "content"), "graphic"); + assert_eq!(text(&out, "format"), "webp"); + // The one WebP encoder writes lossless whatever the quality: the + // record says so and carries no `quality` (issue 56). + assert_eq!(get(&out, "lossless"), true); + assert!(!Reflect::has(&out, &"quality".into()).unwrap()); + assert_eq!(bytes(&out), bytes(&image.encode(None).unwrap().js_value())); + // Named, WebP at a quality asks for the lossy mode this build lacks. + let error = thrown( + image.encode(options(r#"{"format":"webp","quality":80}"#)), + "EncoderUnavailable", + ); + assert_eq!(strings(&error, "availableIn"), ["native-webp"]); + // PNG has no lossy mode in any build: nothing to name. + let error = thrown( + image.encode(options(r#"{"format":"png","quality":80}"#)), + "Unsupported", + ); + assert!(text(&error, "message").contains("no build writes lossy PNG")); +} + #[wasm_bindgen_test] fn on_trial_is_called_once_per_trial() { let seen = Rc::new(RefCell::new(Vec::new())); diff --git a/crates/sqzer/src/lib.rs b/crates/sqzer/src/lib.rs index 97ce5a5..47ab12e 100644 --- a/crates/sqzer/src/lib.rs +++ b/crates/sqzer/src/lib.rs @@ -341,11 +341,13 @@ impl Sqzer { /// [`sqzer_metrics::seeds`] when the backend has one. A target the /// encoder cannot reach is not an error; the best candidate is /// returned and [`Output::report`] says the target was missed. An - /// encoder that only writes lossless meets any target with its one - /// mode and skips the search. Whether that one encode kept every - /// sample is [`Output::lossless`]: an option such as `png:colors` - /// gives samples up there, and is refused next to an explicit - /// [`Target::Lossless`] (ADR-0012 D4). + /// encoder that only writes lossless meets a perceptual target with + /// its one mode and skips the search, and does the same for an + /// explicit quality when the format was picked per image: a graphic + /// going to WebP in the portable tier. Whether that one encode kept + /// every sample is [`Output::lossless`]: an option such as + /// `png:colors` gives samples up there, and is refused next to an + /// explicit [`Target::Lossless`] (ADR-0012 D4). /// /// > **Note**: scoring needs the output format decodable in this /// > build. A registry with an encoder and no decoder for a format @@ -358,8 +360,12 @@ impl Sqzer { /// the missing library), an image over the pixel limit, a decoder /// failure, a resize [`Resize::check`] refuses or whose output is over /// the pixel limit, [`sqzer_core::Error::EncoderUnavailable`] - /// for the chosen format, or [`sqzer_core::Error::Unsupported`] for a perceptual - /// target whose output this build cannot decode. + /// for the chosen format, or for an explicit quality on a format set + /// with [`Sqzer::format`] whose encoder in this build only writes + /// lossless, naming the build that writes it lossy, or + /// [`sqzer_core::Error::Unsupported`] for the same when no build does + /// (PNG), and for a perceptual target whose output this build cannot + /// decode. pub fn run(&self, input: &[u8]) -> Result { self.encode(&self.transform(self.decode(input)?)?) } @@ -461,6 +467,7 @@ impl Sqzer { let (format, content) = self.pick(ready); let encoder = self.registry.encoder(format)?; let caps = encoder.caps(); + self.check_lossy_mode(format, caps.lossy)?; if ready.translucent_padding() && !caps.alpha { return Err(Error::Unsupported { format, @@ -485,7 +492,7 @@ impl Sqzer { } let (bytes, target, report) = match self.params.target { - Target::Ssimulacra2(_) if !caps.lossy => { + Target::Ssimulacra2(_) | Target::Quality(_) if !caps.lossy => { let params = EncodeParams { target: Target::Lossless, ..self.params.clone() @@ -556,6 +563,29 @@ impl Sqzer { }) } + /// A quality asked for by name is a lossy mode asked for by name: an + /// error when the encoder for a format set with [`Sqzer::format`] has + /// none. Picked per image, the format is "whatever fits", and a + /// lossless encoder fits a graphic. + fn check_lossy_mode(&self, format: Format, lossy: bool) -> Result<()> { + if lossy || self.format.is_none() || !matches!(self.params.target, Target::Quality(_)) { + return Ok(()); + } + let available_in = format.lossy_encoder_features(); + Err(if available_in.is_empty() { + // The mirror of what a lossy-only encoder says for lossless. + Error::Unsupported { + format, + what: format!("a quality: no build writes lossy {format}"), + } + } else { + Error::EncoderUnavailable { + format, + available_in, + } + }) + } + /// The codec options set on this builder under which `encoder` gives /// samples up at a lossless target, each as `` `codec:key=value` ``. /// Empty when, and only when, the encoder is [exact](Encoder::exact) @@ -726,6 +756,17 @@ mod tests { assert_eq!(out.backend, "image-webp"); assert_eq!(out.tier, Tier::Portable); assert!(out.report.is_none()); + assert!(out.lossless); + // An explicit quality changes nothing about that: the encoder has + // one mode and the record says which (issue 56). + let out = portable() + .target(Target::Quality(80.0)) + .run(&encode_png(&flat(ColorType::Rgb))) + .unwrap(); + assert_eq!(out.format, Format::WebP); + assert_eq!(out.target, Resolved::Lossless); + assert!(out.lossless); + assert!(out.report.is_none()); // Without a WebP encoder the graphic goes to PNG. let mut narrow = Registry::new(); narrow.register_decoder(sqzer_codecs::png::PngDecoder); @@ -736,6 +777,61 @@ mod tests { .run(&encode_png(&flat(ColorType::Rgb))) .unwrap(); assert_eq!(out.format, Format::Png); + assert_eq!(out.target, Resolved::Lossless); + assert!(out.lossless); + } + + #[test] + fn an_explicit_quality_on_a_named_lossless_only_format_is_unavailable() { + // Named with `format`, WebP at a quality asks for the lossy mode + // the portable tier lacks, as the README documents. + for input in [png_bytes(ColorType::Rgb), encode_png(&flat(ColorType::Rgb))] { + let err = portable() + .format(Format::WebP) + .target(Target::Quality(80.0)) + .run(&input) + .unwrap_err(); + assert!( + matches!( + err, + Error::EncoderUnavailable { + format: Format::WebP, + available_in: ["native-webp"], + } + ), + "{err:?}" + ); + } + // PNG has no lossy mode in any build: nothing to name. + let err = portable() + .format(Format::Png) + .target(Target::Quality(80.0)) + .run(&png_bytes(ColorType::Rgb)) + .unwrap_err(); + assert!( + matches!( + &err, + Error::Unsupported { + format: Format::Png, + .. + } + ), + "{err:?}" + ); + assert_eq!( + err.to_string(), + "PNG encoder does not support a quality: no build writes lossy PNG" + ); + // A perceptual target and lossless still take its one mode. + for target in [Target::Ssimulacra2(70.0), Target::Lossless] { + let out = portable() + .format(Format::WebP) + .target(target) + .run(&png_bytes(ColorType::Rgb)) + .unwrap(); + assert_eq!(out.target, Resolved::Lossless); + assert!(out.lossless); + } } #[test] @@ -1312,7 +1408,11 @@ mod tests { .unwrap(); assert_eq!(s.pick_format(&decoded), Format::Avif); let a = s.clone().format(Format::Jpeg).encode(&decoded).unwrap(); - let b = s.format(Format::Png).encode(&decoded).unwrap(); + let b = s + .format(Format::Png) + .target(Target::Lossless) + .encode(&decoded) + .unwrap(); assert_eq!(a.format, Format::Jpeg); assert_eq!(b.format, Format::Png); assert_eq!(a.input.format, Format::Png);