From a1bf71ae2747ab850bd813999aed565ec61667cc Mon Sep 17 00:00:00 2001 From: Vladyslav Vladinov Date: Tue, 13 Oct 2026 14:50:33 +0200 Subject: [PATCH] feat(wasm): options(), the keys of optimize and encode with their defaults and help The page at sqzer.dev shows each control at the package's default and read the defaults from the README by hand. `codecs()` already reports every backend option with its default and help; `options()` does the same for the keys of `optimize` and `encode`, in the shape of `CodecOption`. The defaults are read from the builder an encode without options uses, so they cannot drift; a key whose default depends on the image or the format says `auto`, and one that does nothing when left out has none. The key table that feeds the listing is the one a misspelt key is checked against. Closes #57 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + crates/sqzer-wasm/README.md | 10 ++ crates/sqzer-wasm/src/lib.rs | 20 ++- crates/sqzer-wasm/src/options.rs | 236 +++++++++++++++++++++++++++---- crates/sqzer-wasm/src/tests.rs | 62 ++++++++ 5 files changed, 300 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6bb51a..5ca838e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * **sqzer:** `Sqzer::lossy_options` names the options set on the builder under which an encoder gives samples up, for a caller that wants to refuse a lossless target before encoding * **cli:** each entry of `trials` in a `--json` record carries `size`, the bytes that trial encoded to. `-v` prints it after the score, and the progress line shows it per trial * **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)) ## 0.3.0 diff --git a/crates/sqzer-wasm/README.md b/crates/sqzer-wasm/README.md index 5c6e847..016e77e 100644 --- a/crates/sqzer-wasm/README.md +++ b/crates/sqzer-wasm/README.md @@ -82,6 +82,16 @@ onTrial called per trial of the search with { n, max, quality, score, size > **Note**: `maxPixels` defaults to 24 million, not the 268 million of the command line. The target search needs about 160 bytes per pixel, and a WebAssembly module addresses 4 GiB. +`options()` lists these keys with their defaults, as `codecs()` lists the backend options, so a page can show every control at the package's default without a copy of this table. + +```js +options(); +// [{ key: "format", default: "auto", help: "output format: jpeg, png, webp, avif or jxl. ..." }, +// { key: "target", default: "70", help: "SSIMULACRA2 score to reach by searching encoder quality, up to 100. ..." }, +// { key: "width", help: "target width in pixels. ..." }, // no default: left out, nothing is resized +// ...] +``` + ## Results and errors `optimize` and `encode` return the record `sqzer --json` prints, in camel case, with the encoded file added. Keys that do not apply are left out. diff --git a/crates/sqzer-wasm/src/lib.rs b/crates/sqzer-wasm/src/lib.rs index fe5708e..47d1611 100644 --- a/crates/sqzer-wasm/src/lib.rs +++ b/crates/sqzer-wasm/src/lib.rs @@ -2,7 +2,7 @@ //! The API is the library's builder spelled the way JavaScript spells it. //! //! ```js -//! import init, { optimize, decode, decodeAny, fromPixels, codecs } from "sqzer"; +//! import init, { optimize, decode, decodeAny, fromPixels, codecs, options } from "sqzer"; //! await init(); //! //! // everything in one call: probe, decode, prepare, resize, encode @@ -41,7 +41,7 @@ use sqzer::{Progress, Ready, Sqzer}; use wasm_bindgen::prelude::*; pub use error::SqzerError; -use options::{DecodeAnyOptions, DecodeOptions, MAX_PIXELS, Options}; +use options::{DecodeAnyOptions, DecodeOptions, MAX_PIXELS, OptionInfo, Options}; use output::{Codec, Output, Source, TrialProgress, tidy}; use tsify::{Ts, Tsify}; @@ -272,6 +272,22 @@ pub fn codecs() -> Result>, SqzerError> { .collect() } +/// The keys `optimize` and `SqzerImage.encode` take, each with its default +/// as the package spells it and what it does, as `codecs()` lists the +/// backend options. A key whose default depends on the image or the format +/// says `auto`; one that has no default has none. The defaults are the +/// library's own, read from the builder an encode without options uses. +/// +/// # Errors +/// None in practice: the listing always serialises. +#[wasm_bindgen] +pub fn options() -> Result>, SqzerError> { + Options::listing() + .iter() + .map(|option| Ok(option.into_ts()?)) + .collect() +} + /// A decoded image, from `decode`, `decodeAny` or `fromPixels`. Call /// `free()` when done with it: its pixels live in the module's memory, /// which the garbage collector does not see. diff --git a/crates/sqzer-wasm/src/options.rs b/crates/sqzer-wasm/src/options.rs index 152c856..0cd1e7f 100644 --- a/crates/sqzer-wasm/src/options.rs +++ b/crates/sqzer-wasm/src/options.rs @@ -5,8 +5,8 @@ use std::collections::BTreeMap; use js_sys::{Function, Object}; -use serde::Deserialize; use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use sqzer::Sqzer; use sqzer::core::params::Target; use sqzer::core::resize::{Resize, Size}; @@ -287,32 +287,125 @@ impl From for resize::Filter { } } -/// The keys of [`Options`], [`DecodeOptions`] and [`DecodeAnyOptions`] as -/// JavaScript spells them. `serde-wasm-bindgen` reads the fields it knows -/// and never sees the rest, so a misspelt key is refused against these. +/// One key of [`Options`], as `options()` lists it: the shape of +/// [`crate::output::CodecOption`]. +#[derive(Debug, Clone, Serialize, Tsify)] +pub struct OptionInfo { + /// The key, as `optimize` and `encode` take it. + pub key: &'static str, + /// The value used when the key is not set, as the package spells it. + /// `auto` where it depends on the image or the format. Left out for a + /// key that has none: leaving `width` out resizes nothing, and + /// `quality` left out means the `target` is searched for. + #[serde(skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + pub default: Option, + /// What it does and which values it takes. + pub help: &'static str, +} + +/// A key of [`Options`] and what `options()` says about it. +struct Key { + name: &'static str, + help: &'static str, +} + +/// The keys of [`Options`] as JavaScript spells them, with their help. +/// `serde-wasm-bindgen` reads the fields it knows and never sees the rest, +/// so a misspelt key is refused against these, and `options()` lists them. /// Add a field's key here with the field. -const OPTIONS: &[&str] = &[ - "format", - "target", - "quality", - "lossless", - "preset", - "effort", - "subsampling", - "keepIcc", - "keepMetadata", - "fast", - "width", - "height", - "fit", - "position", - "background", - "scale", - "enlarge", - "filter", - "maxPixels", - "codecOpts", - "onTrial", +const OPTIONS: &[Key] = &[ + Key { + name: "format", + help: "output format: jpeg, png, webp, avif or jxl. Left out, it is chosen per image: \ + avif for a photograph, lossless webp for a graphic", + }, + Key { + name: "target", + help: "SSIMULACRA2 score to reach by searching encoder quality, up to 100. 100 is \ + identical, 50 shows artefacts on close inspection", + }, + Key { + name: "quality", + help: "encode at this quality, 0 to 100, with no search. Excludes target and lossless", + }, + Key { + name: "lossless", + help: "lossless output. Excludes target and quality", + }, + Key { + name: "preset", + help: "named settings: web, thumbnail, archive or lossless. The other keys override it", + }, + Key { + name: "effort", + help: "0 to 10. Higher is slower and smaller", + }, + Key { + name: "subsampling", + help: "chroma subsampling, for codecs that have it: auto, 444, 422 or 420", + }, + Key { + name: "keepIcc", + help: "keep the ICC profile instead of converting to sRGB", + }, + Key { + name: "keepMetadata", + help: "keep EXIF and XMP instead of stripping them", + }, + Key { + name: "fast", + help: "one encode at the calibrated seed quality for the target, no search", + }, + Key { + name: "width", + help: "target width in pixels. With the default fit, scales down to fit inside width x \ + height; one side may be given alone", + }, + Key { + name: "height", + help: "target height in pixels", + }, + Key { + name: "fit", + help: "how the image meets the width x height box, as CSS object-fit: inside, cover, \ + contain, fill or outside", + }, + Key { + name: "position", + help: "where cover crops and contain places the image: center, top, bottom, left, \ + right, top-left, top-right, bottom-left or bottom-right", + }, + Key { + name: "background", + help: "padding of contain: #rrggbb, #rrggbbaa, white, black or transparent. auto is \ + transparent for a format with alpha, white for one without", + }, + Key { + name: "scale", + help: "a factor instead of a box: 0.5 halves both sides. Above 1 needs enlarge", + }, + Key { + name: "enlarge", + help: "allow scaling up. Nothing scales up without it", + }, + Key { + name: "filter", + help: "resampling filter: lanczos3, mitchell, catmull-rom, bilinear, box or nearest", + }, + Key { + name: "maxPixels", + help: "refuse an image above this many pixels, decoded or resized", + }, + Key { + name: "codecOpts", + help: "backend options, { \"jpeg:progressive\": \"false\" }. codecs() lists every key \ + with its default", + }, + Key { + name: "onTrial", + help: "called once per trial of the target search with { n, max, quality, score, size }", + }, ]; const DECODE_OPTIONS: &[&str] = &["maxPixels"]; const DECODE_ANY_OPTIONS: &[&str] = &["maxPixels", "width", "height"]; @@ -347,7 +440,8 @@ impl Options { /// [`Error::InvalidParams`] for a key this package does not have or a /// value of the wrong type. pub fn parse(options: Option>) -> Result { - let options: Self = parse(options.map(Into::into), OPTIONS)?; + let keys: Vec<&str> = OPTIONS.iter().map(|k| k.name).collect(); + let options: Self = parse(options.map(Into::into), &keys)?; let on_trial = &options.on_trial; if !(on_trial.is_undefined() || on_trial.is_null() || on_trial.is_function()) { return Err(invalid("`onTrial` is not a function")); @@ -521,6 +615,94 @@ impl Options { pub fn on_trial(&self) -> Option<&Function> { self.on_trial.dyn_ref() } + + /// Every key with its default and its help, for `options()`. The + /// defaults are read from the builder the package starts from, so + /// they cannot drift from what an encode without options does. + #[must_use] + pub fn listing() -> Vec { + let sqzer = crate::sqzer(None); + let params = sqzer.params(); + let resize = sqzer.resize_bounds(); + let default = |key: &str| -> Option { + Some(match key { + // Decided per image or per format. + "format" | "background" => "auto".to_string(), + "target" => match params.target { + Target::Ssimulacra2(t) => crate::output::tidy(t).to_string(), + Target::Quality(_) | Target::Lossless => return None, + }, + "lossless" | "fast" => false.to_string(), + "effort" => params.effort.to_string(), + "subsampling" => subsampling_name(params.subsampling).to_string(), + "keepIcc" => params.keep_icc.to_string(), + "keepMetadata" => params.keep_metadata.to_string(), + "fit" => fit_name(resize.fit).to_string(), + "position" => position_name(resize.position).to_string(), + "enlarge" => resize.enlarge.to_string(), + "filter" => filter_name(resize.filter).to_string(), + "maxPixels" => MAX_PIXELS.to_string(), + // Nothing happens when these are left out. + _ => return None, + }) + }; + OPTIONS + .iter() + .map(|k| OptionInfo { + key: k.name, + default: default(k.name), + help: k.help, + }) + .collect() + } +} + +/// The spelling of [`Subsampling`] for a library value. +fn subsampling_name(s: params::Subsampling) -> &'static str { + match s { + params::Subsampling::Auto => "auto", + params::Subsampling::S444 => "444", + params::Subsampling::S422 => "422", + params::Subsampling::S420 => "420", + } +} + +/// The spelling of [`Fit`] for a library value. +fn fit_name(f: resize::Fit) -> &'static str { + match f { + resize::Fit::Inside => "inside", + resize::Fit::Cover => "cover", + resize::Fit::Contain => "contain", + resize::Fit::Fill => "fill", + resize::Fit::Outside => "outside", + } +} + +/// The spelling of [`Position`] for a library value. +fn position_name(p: resize::Position) -> &'static str { + match p { + resize::Position::Center => "center", + resize::Position::Top => "top", + resize::Position::Bottom => "bottom", + resize::Position::Left => "left", + resize::Position::Right => "right", + resize::Position::TopLeft => "top-left", + resize::Position::TopRight => "top-right", + resize::Position::BottomLeft => "bottom-left", + resize::Position::BottomRight => "bottom-right", + } +} + +/// The spelling of [`Filter`] for a library value. +fn filter_name(f: resize::Filter) -> &'static str { + match f { + resize::Filter::Lanczos3 => "lanczos3", + resize::Filter::Mitchell => "mitchell", + resize::Filter::CatmullRom => "catmull-rom", + resize::Filter::Bilinear => "bilinear", + resize::Filter::Box => "box", + resize::Filter::Nearest => "nearest", + } } impl DecodeOptions { diff --git a/crates/sqzer-wasm/src/tests.rs b/crates/sqzer-wasm/src/tests.rs index 966b9fb..14dde07 100644 --- a/crates/sqzer-wasm/src/tests.rs +++ b/crates/sqzer-wasm/src/tests.rs @@ -501,6 +501,68 @@ fn options_are_checked_before_any_work() { assert_ne!(bytes(&plain), bytes(&baseline)); } +#[wasm_bindgen_test] +fn options_lists_every_key_with_its_default() { + // `options` here is the test helper; the export is the crate's. + let entries: Vec = crate::options().unwrap().iter().map(Ts::js_value).collect(); + let keys: Vec = entries.iter().map(|e| text(e, "key")).collect(); + assert_eq!(keys.len(), 21, "{keys:?}"); + // The keys are the ones `optimize` takes: a misspelt key is refused + // against the same list, and the refusal names every one of them. + let error = thrown(optimize(&[], options(r#"{"nope":1}"#)), "InvalidParams"); + let message = text(&error, "message"); + for key in &keys { + assert!(message.contains(key.as_str()), "{key}: {message}"); + } + let entry = |key: &str| { + entries + .iter() + .find(|e| text(e, "key") == key) + .unwrap_or_else(|| panic!("no entry for {key}")) + .clone() + }; + for (key, default) in [ + ("format", "auto"), + ("target", "70"), + ("lossless", "false"), + ("effort", "6"), + ("subsampling", "auto"), + ("keepIcc", "false"), + ("keepMetadata", "false"), + ("fast", "false"), + ("fit", "inside"), + ("position", "center"), + ("background", "auto"), + ("enlarge", "false"), + ("filter", "lanczos3"), + ("maxPixels", "24000000"), + ] { + assert_eq!(text(&entry(key), "default"), default, "{key}"); + } + // Keys that do nothing when left out have no default, not null. + for key in [ + "quality", + "preset", + "width", + "height", + "scale", + "codecOpts", + "onTrial", + ] { + assert!( + !Reflect::has(&entry(key), &"default".into()).unwrap(), + "{key}" + ); + } + for e in &entries { + assert!(!text(e, "help").is_empty(), "{}", text(e, "key")); + } + // The listed defaults are what an encode without options does. + let out = optimize(RGB, None).unwrap().js_value(); + assert_eq!(number(&out, "target"), 70.0); + assert_eq!(text(&out, "format"), "avif"); +} + #[wasm_bindgen_test] fn codecs_lists_this_build() { let listing = codecs().unwrap();