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();