feat: add minimum gain threshold for writes
This commit is contained in:
parent
ea9caac249
commit
5fba1a42ee
7 changed files with 263 additions and 13 deletions
|
|
@ -171,6 +171,12 @@ Options:
|
|||
--force
|
||||
Write the output even if it is larger than the input
|
||||
|
||||
--min-gain <value>
|
||||
Only write optimized output when savings meet this threshold. Accepts percentages
|
||||
(e.g. 1%, 0.5%) or byte sizes (e.g. 1024, 4KiB, 1MB).
|
||||
|
||||
If savings is below the threshold, it is treated as no-change.
|
||||
|
||||
-z, --zopfli
|
||||
Use the much slower but stronger Zopfli compressor for main compression trials.
|
||||
Recommended use is with '-o max' and '--fast'.
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ The most commonly used options are as follows:
|
|||
- Optimization: `-o 0` through `-o 6` (or `-o max`), lower is faster, higher is better compression.
|
||||
The default (`-o 2`) is quite fast and provides good compression. Higher levels can be notably
|
||||
better* but generally have increasingly diminishing returns.
|
||||
- Minimum gain: `--min-gain <value>` only writes optimized output when savings reach a threshold.
|
||||
Use a percentage (`1%`, `0.5%`) or bytes (`1024`, `4KiB`, `1MB`).
|
||||
- Strip: Used to remove metadata info from processed images. Used via `--strip [safe,all]`.
|
||||
Can save a few kilobytes if you don't need the metadata. "Safe" removes only metadata that
|
||||
will never affect rendering of the image. "All" removes all metadata that is not critical
|
||||
|
|
|
|||
13
src/cli.rs
13
src/cli.rs
|
|
@ -340,6 +340,19 @@ be processed successfully. The output will always have correct checksums.")
|
|||
.long("force")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("min-gain")
|
||||
.help("Require savings of at least <value> before writing")
|
||||
.long_help("\
|
||||
Only write optimized output if savings meets this threshold. The value may be specified as \
|
||||
a percentage (e.g. '1%' or '0.5%') or as a byte size (e.g. '1024', '4KiB', '1MB').
|
||||
|
||||
If savings is below the threshold, the result is treated as no-change:
|
||||
- in-place output will not overwrite the input file
|
||||
- explicit output path, stdout, and dry-run will use original data")
|
||||
.long("min-gain")
|
||||
.value_name("value"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("zopfli")
|
||||
.help("Use the much slower but stronger Zopfli compressor")
|
||||
|
|
|
|||
38
src/lib.rs
38
src/lib.rs
|
|
@ -30,7 +30,7 @@ pub use crate::{
|
|||
error::PngError,
|
||||
filters::{FilterStrategy, RowFilter},
|
||||
headers::StripChunks,
|
||||
options::{InFile, Options, OutFile},
|
||||
options::{InFile, MinGain, Options, OutFile},
|
||||
};
|
||||
use crate::{
|
||||
evaluate::{Candidate, Evaluator},
|
||||
|
|
@ -167,6 +167,19 @@ impl RawImage {
|
|||
///
|
||||
/// Returns the original and optimized file sizes
|
||||
pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult {
|
||||
optimize_with(input, output, opts, None)
|
||||
}
|
||||
|
||||
/// Perform optimization on the input file using the options provided, with an optional minimum
|
||||
/// savings threshold.
|
||||
///
|
||||
/// Returns the original and optimized file sizes
|
||||
pub fn optimize_with(
|
||||
input: &InFile,
|
||||
output: &OutFile,
|
||||
opts: &Options,
|
||||
min_gain: Option<MinGain>,
|
||||
) -> OptimizationResult {
|
||||
// Read in the file and try to decode as PNG.
|
||||
info!("Processing: {input}");
|
||||
|
||||
|
|
@ -190,7 +203,7 @@ pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio
|
|||
|
||||
let in_length = in_data.len();
|
||||
|
||||
if is_fully_optimized(in_length, optimized_output.len(), opts) {
|
||||
if should_keep_original(in_length, optimized_output.len(), opts, min_gain) {
|
||||
match (output, input) {
|
||||
// If output path is None, it also means same as the input path
|
||||
(OutFile::Path { path, .. }, InFile::Path(input_path))
|
||||
|
|
@ -285,6 +298,15 @@ pub fn optimize(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio
|
|||
/// Perform optimization on the input file using the options provided, where the file is already
|
||||
/// loaded in-memory
|
||||
pub fn optimize_from_memory(data: &[u8], opts: &Options) -> PngResult<Vec<u8>> {
|
||||
optimize_from_memory_with(data, opts, None)
|
||||
}
|
||||
|
||||
/// Perform optimization on in-memory image data with an optional minimum savings threshold.
|
||||
pub fn optimize_from_memory_with(
|
||||
data: &[u8],
|
||||
opts: &Options,
|
||||
min_gain: Option<MinGain>,
|
||||
) -> PngResult<Vec<u8>> {
|
||||
// Read in the file and try to decode as PNG.
|
||||
info!("Processing from memory");
|
||||
|
||||
|
|
@ -296,7 +318,7 @@ pub fn optimize_from_memory(data: &[u8], opts: &Options) -> PngResult<Vec<u8>> {
|
|||
// Run the optimizer on the decoded PNG.
|
||||
let optimized_output = optimize_png(&mut png, data, opts, deadline)?;
|
||||
|
||||
if is_fully_optimized(original_size, optimized_output.len(), opts) {
|
||||
if should_keep_original(original_size, optimized_output.len(), opts, min_gain) {
|
||||
info!("Image already optimized");
|
||||
Ok(data.to_vec())
|
||||
} else {
|
||||
|
|
@ -644,3 +666,13 @@ fn recompress_frames(
|
|||
const fn is_fully_optimized(original_size: usize, optimized_size: usize, opts: &Options) -> bool {
|
||||
original_size <= optimized_size && !opts.force
|
||||
}
|
||||
|
||||
fn should_keep_original(
|
||||
original_size: usize,
|
||||
optimized_size: usize,
|
||||
opts: &Options,
|
||||
min_gain: Option<MinGain>,
|
||||
) -> bool {
|
||||
is_fully_optimized(original_size, optimized_size, opts)
|
||||
|| min_gain.is_some_and(|threshold| !threshold.is_satisfied(original_size, optimized_size))
|
||||
}
|
||||
|
|
|
|||
77
src/main.rs
77
src/main.rs
|
|
@ -20,8 +20,10 @@ use log::{Level, LevelFilter, error, warn};
|
|||
#[cfg(feature = "zopfli")]
|
||||
use oxipng::ZopfliOptions;
|
||||
use oxipng::{
|
||||
Deflater, FilterStrategy, InFile, OptimizationResult, Options, OutFile, PngError, StripChunks,
|
||||
Deflater, FilterStrategy, InFile, MinGain, OptimizationResult, Options, OutFile, PngError,
|
||||
StripChunks,
|
||||
};
|
||||
use parse_size::parse_size;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::cli::DISPLAY_CHUNKS;
|
||||
|
|
@ -38,7 +40,7 @@ fn main() -> ExitCode {
|
|||
.after_long_help("")
|
||||
.get_matches_from(std::env::args());
|
||||
|
||||
let (mut out_file, out_dir, opts) = match parse_opts_into_struct(&matches) {
|
||||
let (mut out_file, out_dir, opts, min_gain) = match parse_opts_into_struct(&matches) {
|
||||
Ok(x) => x,
|
||||
Err(x) => {
|
||||
error!("{x}");
|
||||
|
|
@ -89,7 +91,7 @@ fn main() -> ExitCode {
|
|||
stdout().flush().ok();
|
||||
}
|
||||
let process = |(input, output): &(InFile, OutFile)| {
|
||||
let result = process_file(input, output, &opts);
|
||||
let result = process_file(input, output, &opts, min_gain);
|
||||
if print_progress && matches!(result, OptimizationResult::Ok(_)) {
|
||||
let value = num_processed.fetch_add(1, AcqRel) + 1;
|
||||
print!("\rFiles processed: {}/{}...", value, total_files);
|
||||
|
|
@ -115,7 +117,7 @@ fn main() -> ExitCode {
|
|||
num_succeeded += 1;
|
||||
total_in += *insize as i64;
|
||||
total_out += *outsize as i64;
|
||||
if !opts.force && insize == outsize {
|
||||
if insize == outsize && (!opts.force || min_gain.is_some()) {
|
||||
num_not_optimized += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -237,7 +239,7 @@ fn apply_glob_pattern(path: PathBuf) -> Vec<PathBuf> {
|
|||
|
||||
fn parse_opts_into_struct(
|
||||
matches: &ArgMatches,
|
||||
) -> Result<(OutFile, Option<PathBuf>, Options), String> {
|
||||
) -> Result<(OutFile, Option<PathBuf>, Options, Option<MinGain>), String> {
|
||||
let log_level = match matches.get_count("verbose") {
|
||||
_ if matches.get_flag("quiet") => LevelFilter::Off,
|
||||
0 => LevelFilter::Warn,
|
||||
|
|
@ -344,6 +346,11 @@ fn parse_opts_into_struct(
|
|||
|
||||
opts.force = matches.get_flag("force");
|
||||
|
||||
let min_gain = matches
|
||||
.get_one::<String>("min-gain")
|
||||
.map(|value| parse_min_gain(value))
|
||||
.transpose()?;
|
||||
|
||||
opts.fix_errors = matches.get_flag("fix");
|
||||
|
||||
opts.max_decompressed_size = matches.get_one::<u64>("max-size").map(|&x| x as usize);
|
||||
|
|
@ -450,7 +457,7 @@ fn parse_opts_into_struct(
|
|||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
|
||||
Ok((out_file, out_dir, opts))
|
||||
Ok((out_file, out_dir, opts, min_gain))
|
||||
}
|
||||
|
||||
fn parse_chunk_name(name: &str) -> Result<[u8; 4], String> {
|
||||
|
|
@ -460,6 +467,33 @@ fn parse_chunk_name(name: &str) -> Result<[u8; 4], String> {
|
|||
.map_err(|_| format!("Invalid chunk name {name}"))
|
||||
}
|
||||
|
||||
fn parse_min_gain(value: &str) -> Result<MinGain, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err("Minimum gain must not be empty".to_owned());
|
||||
}
|
||||
|
||||
if let Some(percent) = value.strip_suffix('%') {
|
||||
let parsed = percent
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.map_err(|_| format!("Invalid percentage for --min-gain: {value}"))?;
|
||||
if !(0.0..=100.0).contains(&parsed) || !parsed.is_finite() {
|
||||
return Err(format!(
|
||||
"Percentage for --min-gain must be between 0% and 100%: {value}"
|
||||
));
|
||||
}
|
||||
return MinGain::ratio(parsed / 100.0)
|
||||
.ok_or_else(|| format!("Invalid percentage for --min-gain: {value}"));
|
||||
}
|
||||
|
||||
let parsed =
|
||||
parse_size(value).map_err(|_| format!("Invalid byte size for --min-gain: {value}"))?;
|
||||
let bytes = usize::try_from(parsed)
|
||||
.map_err(|_| format!("Byte size for --min-gain is too large: {value}"))?;
|
||||
Ok(MinGain::bytes(bytes))
|
||||
}
|
||||
|
||||
fn parse_numeric_range_opts(
|
||||
input: &str,
|
||||
min_value: u8,
|
||||
|
|
@ -513,7 +547,12 @@ fn parse_numeric_range_opts(
|
|||
Err(ERROR_MESSAGE.to_owned())
|
||||
}
|
||||
|
||||
fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult {
|
||||
fn process_file(
|
||||
input: &InFile,
|
||||
output: &OutFile,
|
||||
opts: &Options,
|
||||
min_gain: Option<MinGain>,
|
||||
) -> OptimizationResult {
|
||||
if let (Some(max_size), InFile::Path(path)) = (opts.max_decompressed_size, input) {
|
||||
if path.metadata().is_ok_and(|m| m.len() > max_size as u64) {
|
||||
warn!("{input}: Skipped: File exceeds the maximum size ({max_size} bytes)");
|
||||
|
|
@ -521,7 +560,7 @@ fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> Optimizatio
|
|||
}
|
||||
}
|
||||
|
||||
let result = oxipng::optimize(input, output, opts);
|
||||
let result = oxipng::optimize_with(input, output, opts, min_gain);
|
||||
match &result {
|
||||
Ok(_) => {}
|
||||
Err(e @ PngError::C2PAMetadataPreventsChanges | e @ PngError::InflatedDataTooLong(_)) => {
|
||||
|
|
@ -631,7 +670,8 @@ fn format_bytes(count: i64, include_raw: bool) -> String {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::format_bytes;
|
||||
use super::{format_bytes, parse_min_gain};
|
||||
use oxipng::MinGain;
|
||||
|
||||
#[test]
|
||||
fn test_format_bytes() {
|
||||
|
|
@ -641,4 +681,23 @@ mod tests {
|
|||
assert_eq!(format_bytes(2_000_000_000, false), "1.86 GiB");
|
||||
assert_eq!(format_bytes(-1024, false), "-1.00 KiB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_min_gain_valid_values() {
|
||||
assert_eq!(parse_min_gain("1024"), Ok(MinGain::Bytes(1024)));
|
||||
assert_eq!(parse_min_gain("4KiB"), Ok(MinGain::Bytes(4096)));
|
||||
assert_eq!(parse_min_gain("1MB"), Ok(MinGain::Bytes(1_000_000)));
|
||||
|
||||
let parsed = parse_min_gain("0.5%").expect("valid percent should parse");
|
||||
assert!(matches!(parsed, MinGain::Ratio(ratio) if (ratio - 0.005).abs() < f64::EPSILON));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_min_gain_invalid_values() {
|
||||
assert!(parse_min_gain("").is_err());
|
||||
assert!(parse_min_gain("-1").is_err());
|
||||
assert!(parse_min_gain("-0.5%").is_err());
|
||||
assert!(parse_min_gain("101%").is_err());
|
||||
assert!(parse_min_gain("abc").is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,50 @@ impl<T: Into<PathBuf>> From<T> for InFile {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
/// Minimum savings threshold required to accept optimized output.
|
||||
pub enum MinGain {
|
||||
/// Require at least this many bytes to be saved.
|
||||
Bytes(usize),
|
||||
/// Require at least this ratio of the original size to be saved.
|
||||
///
|
||||
/// The value is represented as a fraction in the range `0.0..=1.0`,
|
||||
/// where `0.01` means 1%.
|
||||
Ratio(f64),
|
||||
}
|
||||
|
||||
impl MinGain {
|
||||
#[must_use]
|
||||
pub const fn bytes(bytes: usize) -> Self {
|
||||
Self::Bytes(bytes)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn ratio(ratio: f64) -> Option<Self> {
|
||||
if ratio.is_finite() && (0.0..=1.0).contains(&ratio) {
|
||||
Some(Self::Ratio(ratio))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a given optimized size satisfies this threshold.
|
||||
#[must_use]
|
||||
pub fn is_satisfied(self, original_size: usize, optimized_size: usize) -> bool {
|
||||
let Some(saved_bytes) = original_size.checked_sub(optimized_size) else {
|
||||
return false;
|
||||
};
|
||||
saved_bytes >= self.required_savings(original_size)
|
||||
}
|
||||
|
||||
fn required_savings(self, original_size: usize) -> usize {
|
||||
match self {
|
||||
Self::Bytes(bytes) => bytes,
|
||||
Self::Ratio(ratio) => (original_size as f64 * ratio).ceil() as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// Options controlling the output of the `optimize` function
|
||||
pub struct Options {
|
||||
|
|
|
|||
96
tests/lib.rs
96
tests/lib.rs
|
|
@ -1,4 +1,10 @@
|
|||
use std::{fs, fs::File, io::prelude::*};
|
||||
use std::{
|
||||
fs,
|
||||
fs::File,
|
||||
io::prelude::*,
|
||||
path::PathBuf,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use oxipng::*;
|
||||
|
||||
|
|
@ -87,3 +93,91 @@ fn optimize_srgb_icc() {
|
|||
let result = oxipng::optimize_from_memory(&file, &opts);
|
||||
assert!(result.unwrap().len() < 1000);
|
||||
}
|
||||
|
||||
fn temp_path(name: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("oxipng-{name}-{}-{nanos}.png", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_gain_bytes_skips_in_place_write() {
|
||||
let input_data = fs::read("tests/files/verbose_mode.png").unwrap();
|
||||
let opts = Options::default();
|
||||
let optimized = oxipng::optimize_from_memory(&input_data, &opts).unwrap();
|
||||
assert!(
|
||||
optimized.len() < input_data.len(),
|
||||
"fixture should produce measurable savings"
|
||||
);
|
||||
let savings = input_data.len() - optimized.len();
|
||||
|
||||
let input_path = temp_path("min-gain-bytes-input");
|
||||
fs::write(&input_path, &input_data).unwrap();
|
||||
let mut permissions = fs::metadata(&input_path).unwrap().permissions();
|
||||
permissions.set_readonly(true);
|
||||
fs::set_permissions(&input_path, permissions).unwrap();
|
||||
|
||||
let result = oxipng::optimize_with(
|
||||
&InFile::Path(input_path.clone()),
|
||||
&OutFile::Path {
|
||||
path: None,
|
||||
preserve_attrs: false,
|
||||
},
|
||||
&opts,
|
||||
Some(MinGain::Bytes(savings + 1)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, (input_data.len(), input_data.len()));
|
||||
assert_eq!(fs::read(&input_path).unwrap(), input_data);
|
||||
|
||||
let mut permissions = fs::metadata(&input_path).unwrap().permissions();
|
||||
permissions.set_readonly(false);
|
||||
fs::set_permissions(&input_path, permissions).unwrap();
|
||||
fs::remove_file(&input_path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_gain_percentage_threshold_behavior() {
|
||||
let input_data = fs::read("tests/files/verbose_mode.png").unwrap();
|
||||
let opts = Options::default();
|
||||
let optimized = oxipng::optimize_from_memory(&input_data, &opts).unwrap();
|
||||
assert!(
|
||||
optimized.len() < input_data.len(),
|
||||
"fixture should produce measurable savings"
|
||||
);
|
||||
let savings_ratio = (input_data.len() - optimized.len()) as f64 / input_data.len() as f64;
|
||||
let high_threshold = MinGain::ratio((savings_ratio + 0.001).min(1.0)).unwrap();
|
||||
let low_threshold = MinGain::ratio(savings_ratio / 2.0).unwrap();
|
||||
|
||||
let input_path = temp_path("min-gain-percent-input");
|
||||
let high_output = temp_path("min-gain-percent-high");
|
||||
let low_output = temp_path("min-gain-percent-low");
|
||||
fs::write(&input_path, &input_data).unwrap();
|
||||
|
||||
let high_result = oxipng::optimize_with(
|
||||
&InFile::Path(input_path.clone()),
|
||||
&OutFile::from_path(high_output.clone()),
|
||||
&opts,
|
||||
Some(high_threshold),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(high_result, (input_data.len(), input_data.len()));
|
||||
assert_eq!(fs::read(&high_output).unwrap(), input_data);
|
||||
|
||||
let low_result = oxipng::optimize_with(
|
||||
&InFile::Path(input_path.clone()),
|
||||
&OutFile::from_path(low_output.clone()),
|
||||
&opts,
|
||||
Some(low_threshold),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(low_result.0, input_data.len());
|
||||
assert!(low_result.1 < input_data.len());
|
||||
assert_eq!(fs::read(&low_output).unwrap().len(), low_result.1);
|
||||
|
||||
fs::remove_file(&input_path).ok();
|
||||
fs::remove_file(&high_output).ok();
|
||||
fs::remove_file(&low_output).ok();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue