diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4e8a7..d6bb51a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### ⚠️ BREAKING CHANGES * **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 `..` * **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 @@ -12,6 +14,8 @@ * **codecs:** `-x png:colors=<2..=256>` reduces a PNG to a palette, what `rimage` had as `--quantization`, through `quantizr`. `-x png:dither=<0..=100>` sets how much of the error is diffused, 100 by default. An image that already has that few colours is written exactly, at its own bit depth. The output is lossy: `--json` and the npm package say `"lossless": false`, `{quality}` in a `--template` renders `lossy`, and `--lossless` refuses the option (ADR-0012) * **core:** `Encoder::exact` says whether an encode at a lossless target keeps every sample under the given options. It defaults to `true`; a backend with an option that gives samples up overrides it, as `oxipng` does for `png:colors` and `png:optimize_alpha` * **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)) ## 0.3.0 diff --git a/crates/sqzer-cli/src/report.rs b/crates/sqzer-cli/src/report.rs index b473b38..71b0a7b 100644 --- a/crates/sqzer-cli/src/report.rs +++ b/crates/sqzer-cli/src/report.rs @@ -39,6 +39,8 @@ pub struct Trial { pub quality: f32, /// SSIMULACRA2 score. pub score: f32, + /// Size of the encoded trial in bytes. + pub size: u64, } /// One line of `--json`: one input, one output. Fields are omitted when @@ -187,6 +189,7 @@ impl Record { .map(|t| Trial { quality: t.quality, score: t.score, + size: t.size, }) .collect(), ); @@ -425,6 +428,8 @@ pub enum Stage { quality: f32, /// Score it reached. score: f32, + /// Size of the encoded trial in bytes. + size: u64, }, /// Writing the output. Write, @@ -438,11 +443,13 @@ impl From for Stage { max, quality, score, + size, } => Self::Trial { n, max, quality, score, + size, }, // Stages the facade may add later show as plain encoding. _ => Self::Encode, @@ -493,9 +500,13 @@ impl Worker<'_> { max, quality, score, + size, } => ( "magenta", - format!("search {n}/{max} q{quality} -> {score:.1}"), + format!( + "search {n}/{max} q{quality} -> {score:.1} {}", + fmt_bytes(size) + ), ), Stage::Write => ("green", "write".to_string()), }; @@ -638,7 +649,7 @@ impl Printer { { let list: Vec = trials .iter() - .map(|t| format!("q{} {:.2}", t.quality, t.score)) + .map(|t| format!("q{} {:.2} {}", t.quality, t.score, fmt_bytes(t.size))) .collect(); let _ = writeln!( err, diff --git a/crates/sqzer-cli/tests/cli.rs b/crates/sqzer-cli/tests/cli.rs index 55bf9a7..41ba9cf 100644 --- a/crates/sqzer-cli/tests/cli.rs +++ b/crates/sqzer-cli/tests/cli.rs @@ -129,6 +129,20 @@ fn shape_1_one_photo_becomes_a_sibling_avif() { assert!(r["quality"].is_number(), "{r}"); assert!(r["iterations"].as_u64().unwrap() <= 6, "{r}"); assert!(r["trials"].is_array(), "{r}"); + // Every trial carries the size of its encode, and the chosen one is + // the file written. + let trials = r["trials"].as_array().unwrap(); + assert!( + trials + .iter() + .all(|t| t["size"].as_u64().is_some_and(|s| s > 0)), + "{r}" + ); + let chosen = trials + .iter() + .find(|t| t["quality"] == r["quality"]) + .unwrap(); + assert_eq!(chosen["size"], r["output_bytes"], "{r}"); assert!(is_avif(&fs::read(sb.path("photo.avif")).unwrap())); } diff --git a/crates/sqzer-metrics/src/lib.rs b/crates/sqzer-metrics/src/lib.rs index 9c84908..9911a74 100644 --- a/crates/sqzer-metrics/src/lib.rs +++ b/crates/sqzer-metrics/src/lib.rs @@ -25,6 +25,6 @@ pub mod search; pub mod seeds; pub mod ssimulacra2; -pub use search::{Found, Search, SearchReport, Trial}; +pub use search::{Found, Scored, Search, SearchReport, Trial}; pub use seeds::{Seed, SeedPoint, SeedTable}; pub use ssimulacra2::{Reference, Ssimulacra2}; diff --git a/crates/sqzer-metrics/src/search.rs b/crates/sqzer-metrics/src/search.rs index 360a632..4c26b7b 100644 --- a/crates/sqzer-metrics/src/search.rs +++ b/crates/sqzer-metrics/src/search.rs @@ -36,6 +36,8 @@ pub struct Trial { pub quality: f32, /// Metric score at that quality. pub score: f32, + /// Size of the encoded output in bytes. + pub size: u64, } /// Result of one target search. @@ -65,6 +67,17 @@ pub struct Found { pub report: SearchReport, } +/// What one trial of [`Search::run`] produced. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Scored { + /// The encoded output. + pub output: T, + /// Its metric score. + pub score: f32, + /// Its size in bytes, carried into [`Trial::size`]. + pub size: u64, +} + /// Search settings. #[derive(Debug, Clone, PartialEq)] pub struct Search { @@ -159,12 +172,12 @@ impl Search { } /// Run the search over `trial`, which encodes at the given quality and - /// returns the encoded output together with its score. + /// returns the encoded output with its score and its size in bytes. /// /// # Errors /// [`Error::InvalidParams`] from [`Search::validate`], or the first /// error `trial` returns. - pub fn run(&self, mut trial: impl FnMut(f32) -> Result<(T, f32)>) -> Result> { + pub fn run(&self, mut trial: impl FnMut(f32) -> Result>) -> Result> { self.validate()?; let floor = self.floor.round(); let ceiling = self.ceiling.round(); @@ -202,10 +215,15 @@ impl Search { midpoint(lo, hi) }; } - let (output, score) = trial(next)?; + let Scored { + output, + score, + size, + } = trial(next)?; trials.push(Trial { quality: next, score, + size, }); let reaches = score >= self.target - self.tolerance; let better = match &best { @@ -325,8 +343,18 @@ impl Search { Err(e) => return Err(e), }; let s = score(&decoded)?; - observe(Trial { quality, score: s }); - Ok((bytes, s)) + // The output is a byte vector, so the size fits. + let size = bytes.len() as u64; + observe(Trial { + quality, + score: s, + size, + }); + Ok(Scored { + output: bytes, + score: s, + size, + }) }) } } @@ -342,10 +370,29 @@ fn midpoint(lo: f32, hi: f32) -> f32 { mod tests { use super::*; - /// Run the search over a pure score curve, returning the quality as - /// the output so the two can be checked against each other. + /// A trial over a pure curve: the quality as the output, so the two + /// can be checked against each other, and a size that follows it. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn scored(q: f32, score: f32) -> Scored { + Scored { + output: q, + score, + size: (q * 100.0) as u64, + } + } + + /// Run the search over a pure score curve. fn over(search: &Search, curve: impl Fn(f32) -> f32) -> Found { - search.run(|q| Ok((q, curve(q)))).unwrap() + search.run(|q| Ok(scored(q, curve(q)))).unwrap() + } + + #[test] + fn trials_carry_the_size_of_their_output() { + let found = over(&Search::new(70.0), |q| q * 0.9); + assert!(found.report.iterations > 1); + for t in &found.report.trials { + assert_eq!(t.size, scored(t.quality, 0.0).size, "{t:?}"); + } } /// Lowest whole quality whose score reaches the target under the @@ -393,7 +440,7 @@ mod tests { let found = search .run(|q| { seen.push(q); - Ok((q, q * 0.9)) + Ok(scored(q, q * 0.9)) }) .unwrap(); assert_eq!( @@ -645,7 +692,7 @@ mod tests { fn settings_are_validated() { let bad = |s: Search| { assert!(matches!( - s.run(|q| Ok((q, q))), + s.run(|q| Ok(scored(q, q))), Err(Error::InvalidParams(_)) )); }; @@ -687,7 +734,7 @@ mod tests { fn trial_errors_propagate() { let mut calls = 0; let err = Search::new(70.0) - .run(|_: f32| -> Result<((), f32)> { + .run(|_: f32| -> Result> { calls += 1; Err(Error::Codec("boom".into())) }) diff --git a/crates/sqzer-wasm/README.md b/crates/sqzer-wasm/README.md index 3c6c5e1..5c6e847 100644 --- a/crates/sqzer-wasm/README.md +++ b/crates/sqzer-wasm/README.md @@ -77,7 +77,7 @@ enlarge allow scaling up. nothing scales up without it filter "lanczos3" | "mitchell" | "catmull-rom" | "bilinear" | "box" | "nearest" maxPixels refuse a larger image. default 24 million codecOpts backend options, { "jpeg:progressive": "false" }. codecs() lists the keys -onTrial called per trial of the search with { n, max, quality, score } +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. @@ -94,7 +94,7 @@ onTrial called per trial of the search with { n, max, quality, score } content: "photo", outputWidth: 1600, outputHeight: 1200, quality: 62.5, lossless: false, target: 70, score: 70.3, reached: true, capped: false, iterations: 3, - trials: [{ quality: 62, score: 69.1 }, ...] + trials: [{ quality: 62, score: 69.1, size: 48213 }, ...] } ``` diff --git a/crates/sqzer-wasm/src/lib.rs b/crates/sqzer-wasm/src/lib.rs index 186b938..fe5708e 100644 --- a/crates/sqzer-wasm/src/lib.rs +++ b/crates/sqzer-wasm/src/lib.rs @@ -425,6 +425,7 @@ fn encode( max, quality, score, + size, }, ) = (on_trial, progress) else { @@ -435,6 +436,7 @@ fn encode( max, quality: tidy(quality), score: tidy(score), + size, }; let called = serde_wasm_bindgen::to_value(&trial) .map_err(JsValue::from) diff --git a/crates/sqzer-wasm/src/output.rs b/crates/sqzer-wasm/src/output.rs index 0f30149..8f7921f 100644 --- a/crates/sqzer-wasm/src/output.rs +++ b/crates/sqzer-wasm/src/output.rs @@ -118,6 +118,8 @@ pub struct Trial { pub quality: f64, /// SSIMULACRA2 score. pub score: f64, + /// Size of the encoded trial in bytes. + pub size: u64, } /// What `onTrial` receives: a trial as it is scored. @@ -131,6 +133,8 @@ pub struct TrialProgress { pub quality: f64, /// SSIMULACRA2 score it reached. pub score: f64, + /// Size of the encoded trial in bytes. + pub size: u64, } impl Output { @@ -174,6 +178,7 @@ impl Output { .map(|t| Trial { quality: tidy(t.quality), score: tidy(t.score), + size: t.size, }) .collect() }), diff --git a/crates/sqzer-wasm/src/tests.rs b/crates/sqzer-wasm/src/tests.rs index f4291f4..966b9fb 100644 --- a/crates/sqzer-wasm/src/tests.rs +++ b/crates/sqzer-wasm/src/tests.rs @@ -283,7 +283,17 @@ fn on_trial_is_called_once_per_trial() { assert_eq!(number(called, "max"), 6.0); assert_eq!(number(called, "quality"), number(&listed, "quality")); assert_eq!(number(called, "score"), number(&listed, "score")); + assert_eq!(number(called, "size"), number(&listed, "size")); + assert!(number(called, "size") > 0.0); } + // The chosen trial's size is the file's. + let quality = number(&out, "quality"); + let chosen = trials + .iter() + .find(|t| number(t, "quality") == quality) + .unwrap(); + let size = f64::from(u32::try_from(bytes(&out).len()).unwrap()); + assert_eq!(number(&chosen, "size"), size); // No search, no call. let before = seen.borrow().len(); let png = JSON::parse(r#"{"format":"png"}"#).unwrap(); diff --git a/crates/sqzer/src/lib.rs b/crates/sqzer/src/lib.rs index 3b58dd4..97ce5a5 100644 --- a/crates/sqzer/src/lib.rs +++ b/crates/sqzer/src/lib.rs @@ -84,6 +84,8 @@ pub enum Progress { quality: f32, /// Score it reached. score: f32, + /// Size of the encoded trial in bytes. + size: u64, }, } @@ -526,6 +528,7 @@ impl Sqzer { max, quality: trial.quality, score: trial.score, + size: trial.size, }); }, )?; @@ -1285,6 +1288,13 @@ mod tests { .map(|(i, t)| (u8::try_from(i + 1).unwrap(), t.quality)) .collect(); assert_eq!(seen, expected); + // The chosen trial's size is the output's. + let chosen = report + .trials + .iter() + .find(|t| t.quality.to_bits() == report.quality.to_bits()) + .unwrap(); + assert_eq!(chosen.size, u64::try_from(out.bytes.len()).unwrap()); // No search, no progress. let mut count = 0; Sqzer::new()