diff --git a/imager/Cargo.toml b/imager/Cargo.toml index d10f9da..fa72839 100644 --- a/imager/Cargo.toml +++ b/imager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imager" -version = "0.2.2" +version = "0.3.0" authors = ["colbyn "] edition = "2018" license = "MPL-2.0" @@ -41,3 +41,6 @@ buildtype-docs-only = [] [package.metadata.docs.rs] # no-default-features = true features = ["buildtype-docs-only"] + +# [profile.dev] +# opt-level = 2 \ No newline at end of file diff --git a/imager/src/api.rs b/imager/src/api.rs index f9b6743..5e99d08 100644 --- a/imager/src/api.rs +++ b/imager/src/api.rs @@ -1,26 +1,41 @@ +use std::convert::AsRef; +use std::path::Path; use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat}; use either::{Either, Either::*}; +use crate::data::{Resolution, OutputFormat}; use crate::codec::jpeg; use crate::codec::png; use crate::codec::webp; -pub struct Opt { +pub struct OptJob { source: DynamicImage, source_format: ImageFormat, - output_format: ImageFormat, + output_format: OutputFormat, + max_size: Option, } -impl Opt { +impl OptJob { + pub fn open>(path: P) -> Result { + let source = std::fs::read(path).expect("input file path"); + OptJob::new(&source) + } pub fn new(source: &[u8]) -> Result { let source_format = ::image::guess_format(source).map_err(drop)?; + let output_format = match source_format { + ImageFormat::JPEG => OutputFormat::Jpeg, + ImageFormat::PNG => OutputFormat::Png, + ImageFormat::WEBP => OutputFormat::Webp, + _ => OutputFormat::Jpeg + }; match source_format { ImageFormat::WEBP => { let source = webp::decode::decode(source); - Ok(Opt { - output_format: source_format, + Ok(OptJob { + output_format, source, source_format, + max_size: None, }) } _ => { @@ -29,30 +44,55 @@ impl Opt { source_format, ) .map_err(drop)?; - Ok(Opt { - output_format: source_format, + Ok(OptJob { + output_format, source, source_format, + max_size: None, }) } } } - pub fn set_output_format(&mut self, output_format: ImageFormat) { + pub fn output_format(&mut self, output_format: OutputFormat) { self.output_format = output_format; } + pub fn max_size(&mut self, max_size: Resolution) { + self.max_size = Some(max_size); + } pub fn run(self) -> Result, ()> { + let input = match self.max_size { + Some(res) if (res.width, res.height) > self.source.dimensions() => { + self.source.resize(res.width, res.height, ::image::FilterType::Lanczos3) + }, + _ => self.source.clone(), + }; match self.output_format { - ImageFormat::WEBP => { - Ok(webp::opt::opt(&self.source).0) + OutputFormat::Webp => { + Ok(webp::opt::opt(&input).0) } - ImageFormat::JPEG => { - Ok(jpeg::OptContext::from_image(self.source.clone()).run_search().0) + OutputFormat::Jpeg => { + Ok(jpeg::OptContext::from_image(input.clone()).run_search().0) } - ImageFormat::PNG => { - Ok(png::basic_optimize(&self.source)) + OutputFormat::Png => { + Ok(png::basic_optimize(&input)) } - _ => unimplemented!() } } } +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_opt_basic() { + let test_image = include_bytes!("../assets/test/1.jpeg"); + for output_format in vec![OutputFormat::Jpeg, OutputFormat::Png, OutputFormat::Webp] { + let mut opt_job = OptJob::new(test_image).expect("new opt job"); + opt_job.output_format(output_format); + opt_job.max_size(Resolution::new(1000, 1000)); + let result = opt_job.run(); + assert!(result.is_ok()); + } + } +} \ No newline at end of file diff --git a/imager/src/codec/png.rs b/imager/src/codec/png.rs index 3346c1f..ff0749e 100644 --- a/imager/src/codec/png.rs +++ b/imager/src/codec/png.rs @@ -109,7 +109,7 @@ pub fn basic_optimize(source: &DynamicImage) -> Vec { let vmaf_derivative = VideoBuffer::from_png(&compressed).expect("to VideoBuffer"); vmaf::get_report(&vmaf_source, &vmaf_derivative) }; - println!("vmaf: {}", report); + // println!("vmaf: {}", report); (compressed, report) }; let fallback = || { @@ -119,7 +119,7 @@ pub fn basic_optimize(source: &DynamicImage) -> Vec { }; // RUN for num_colors in 1..256 { - println!("num_colors: {}", num_colors); + // println!("num_colors: {}", num_colors); let (compressed, report) = run(num_colors); if report >= 90.0 || num_colors <= 5 { return compressed; diff --git a/imager/src/codec/webp/opt.rs b/imager/src/codec/webp/opt.rs index 0f2e926..8818c43 100644 --- a/imager/src/codec/webp/opt.rs +++ b/imager/src/codec/webp/opt.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use itertools::Itertools; use serde::{Serialize, Deserialize}; use rayon::prelude::*; use image::{DynamicImage, GenericImage, GenericImageView}; @@ -7,7 +8,7 @@ use crate::classifier::{self, Class}; use crate::vmaf; use crate::codec::webp::encode::lossy::{encode}; -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct OutMeta { pub class: Class, pub score: f64, @@ -46,7 +47,7 @@ pub fn opt(source: &DynamicImage) -> (Vec, OutMeta) { let terminate = |score: f64| { let (width, height) = source.dimensions(); let is_small = { - width < 600 || height < 600 + (width * height) < (600 * 600) }; let mut threshold; match class.class { @@ -64,22 +65,61 @@ pub fn opt(source: &DynamicImage) -> (Vec, OutMeta) { } } Class::H1 | Class::H2 if is_small => { - threshold = 88.0; + threshold = 70.0; } Class::H1 => { - threshold = 75.0; + threshold = 60.0; } Class::H2 => { - threshold = 65.0; + threshold = 55.0; } } score >= threshold }; // SEARCH - let start_q = match class.class { - Class::H1 | Class::H2 => 1, - _ => 10, + let start_q = { + let reduce_starting_values = |qs: Vec| -> Option { + let mut last_q = 0; + for q in qs { + let vmaf_score = run(q as f32).1; + let passed = terminate(vmaf_score); + if passed && q <= 10 { + return Some(0); + } + if passed { + return Some(last_q); + } + last_q = q; + } + None + }; + let bad_fallback_low_range = || reduce_starting_values(vec![ + 10, + 35, + 65, + 75, + 85, + ]); + let bad_fallback = || reduce_starting_values(vec![ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 90, + ]); + // TODO: + match class.class { + Class::L0 | Class::L1 | Class::L2 => { + bad_fallback_low_range() + } + _ => bad_fallback() + } }; + let start_q = start_q.unwrap_or(1) as u32; let mut last_q = None; let mut last_score = None; for q in start_q..100 { diff --git a/imager/src/data.rs b/imager/src/data.rs index d480a7f..2364a84 100644 --- a/imager/src/data.rs +++ b/imager/src/data.rs @@ -12,7 +12,7 @@ use std::os::raw::{c_char, c_int}; use libc::{size_t, c_float, c_void}; use serde::{Serialize, Deserialize}; use itertools::Itertools; -use image::{DynamicImage, GenericImage, GenericImageView, ImageBuffer}; +use image::{DynamicImage, GenericImage, GenericImageView, ImageBuffer, ImageFormat}; use rayon::prelude::*; use webp_dev::sys::webp::{ self as webp_sys, @@ -30,7 +30,27 @@ use webp_dev::sys::webp::{ pub enum OutputFormat { Jpeg, Png, - WebP, + Webp, +} + +impl OutputFormat { + pub fn infer_from_file_container>(path: P) -> Option { + let buffer = std::fs::read(path).ok()?; + let format = ::image::guess_format(&buffer).ok()?; + match format { + ImageFormat::JPEG => Some(OutputFormat::Jpeg), + ImageFormat::PNG => Some(OutputFormat::Png), + ImageFormat::WEBP => Some(OutputFormat::Webp), + _ => None + } + } + pub fn infer_from_path>(path: P) -> Option { + let ext = path + .as_ref() + .extension()? + .to_str()?; + OutputFormat::from_str(ext).ok() + } } impl FromStr for OutputFormat { @@ -40,7 +60,7 @@ impl FromStr for OutputFormat { "jpeg" => Ok(OutputFormat::Jpeg), "jpg" => Ok(OutputFormat::Jpeg), "png" => Ok(OutputFormat::Png), - "webp" => Ok(OutputFormat::Png), + "webp" => Ok(OutputFormat::Webp), _ => { Err(format!("Unknown or unsupported output format {}", s)) } @@ -54,6 +74,39 @@ impl Default for OutputFormat { } } +#[derive(Debug, Clone)] +pub struct OutputFormats(pub Vec); + +impl Default for OutputFormats { + fn default() -> Self { + OutputFormats(vec![OutputFormat::Jpeg, OutputFormat::Webp]) + } +} + +impl FromStr for OutputFormats { + type Err = String; + fn from_str(s: &str) -> Result { + let mut invalids = Vec::new(); + let results = s + .split_whitespace() + .filter_map(|x| { + match OutputFormat::from_str(x) { + Ok(x) => Some(x), + Err(e) => { + invalids.push(e); + None + } + } + }) + .collect::>(); + if invalids.is_empty() { + Ok(OutputFormats(results)) + } else { + Err(invalids.join(", ")) + } + } +} + /////////////////////////////////////////////////////////////////////////////// // RESOLUTION diff --git a/imager/src/main.rs b/imager/src/main.rs index 54dcad1..48a3e14 100644 --- a/imager/src/main.rs +++ b/imager/src/main.rs @@ -11,12 +11,45 @@ use serde::{Serialize, Deserialize}; use structopt::StructOpt; use structopt::clap::ArgGroup; use indicatif::{ProgressBar, ProgressStyle}; -use imager::data::{ + +use crate::data::{ OutputFormat, - OutputSize, + OutputFormats, Resolution, }; +/////////////////////////////////////////////////////////////////////////////// +// CLI FRONTEND - INTERNAL HELPER TYPES +/////////////////////////////////////////////////////////////////////////////// + +#[derive(Debug, Clone, PartialEq)] +enum OutputType { + Dir(PathBuf), + File(PathBuf), + Replace, +} + +impl OutputType { + pub fn is_dir(&self) -> bool { + match self { + OutputType::Dir(_) => true, + _ => false + } + } + pub fn is_file(&self) -> bool { + match self { + OutputType::File(_) => true, + _ => false + } + } + pub fn is_replace(&self) -> bool { + match self { + OutputType::Replace => true, + _ => false + } + } +} + /////////////////////////////////////////////////////////////////////////////// // CLI FRONTEND /////////////////////////////////////////////////////////////////////////////// @@ -24,7 +57,7 @@ use imager::data::{ /// The Imager CLI Interface /// /// Output type much be one of: `--output-file`, `--output-dir`, or `--replace`. -#[derive(Debug, Clone, Serialize, Deserialize, StructOpt)] +#[derive(Debug, Clone, StructOpt)] #[structopt( name = "imager", // rename_all = "kebab-case", @@ -35,7 +68,7 @@ pub struct Command { #[structopt(short, long, required = true, min_values = 1)] inputs: Vec, - /// Output file path. + /// Save the result to this file path. /// /// Save the optimized file to this path. /// Only works for single input/output files. @@ -47,10 +80,11 @@ pub struct Command { /// Dump results to this directory. /// Files will have the same name as the input file. /// Valid for multiple input/output files. - #[structopt(long, parse(from_os_str), group = "output_type")] + #[structopt(short="O", long, parse(from_os_str), group = "output_type")] output_dir: Option, /// Replace input files with their optimized results. + /// /// Valid for multiple input/output files. #[structopt(long, group = "output_type")] replace: bool, @@ -60,75 +94,116 @@ pub struct Command { /// Multiple output formats may be specified, e.g. `--formats webp jpeg`. /// The saved results will have their file extension updated if different /// from the original. - #[structopt(short, long, default_value = "jpeg")] - formats: Vec, + #[structopt(short, long, default_value = "jpeg webp")] + formats: Vec, - /// Output image size (resolution). - /// - /// To target a specific resolution (say 100x100) use `--resize 100x100`. - /// This will always preserve aspect ratio and only downscales when necessary. - /// - /// To preserve the original resolution use `--resize full`. - #[structopt(short, long)] - resize: Option, + /// Resize or downscale images if their resolution exceeds the given size. + #[structopt(long)] + max_size: Option, } -// impl Command { -// pub fn run(&self) { -// let inputs = self.input -// .clone() -// .into_iter() -// .filter_map(|x| glob::glob(&x).ok()) -// .map(|x| x.collect::>()) -// .flatten() -// .filter_map(Result::ok) -// .collect::>(); -// let to_out_path_for = |input_path: &PathBuf| -> PathBuf { -// let filename = input_path -// .file_name() -// .expect("file name from path") -// .to_str() -// .expect("str path"); -// let mut output_path = self.output.clone(); -// std::fs::create_dir_all(&output_path) -// .expect("create output dir if missing"); -// output_path.push(&filename); -// match self.format { -// OutputFormat::Jpeg => output_path.set_extension("jpeg") -// }; -// output_path -// }; -// if self.single { -// if inputs.len() > 1 { -// panic!("The single flag is incompatible with multiple inputs."); -// } -// } -// let progress_bar = ProgressBar::new(inputs.len() as u64); -// progress_bar.tick(); -// inputs -// .par_iter() -// .for_each(|input_path| { -// let resize = self.size.clone(); -// let source = opt::Source::open(input_path, resize).expect("load source"); -// let (output, opt_meta) = source.run_search(); -// let output_path = if self.single { -// self.output -// .parent() -// .map(|parent| { -// std::fs::create_dir_all(parent) -// .expect("create missing parent directory"); -// }); -// self.output.clone() -// } else { -// to_out_path_for(input_path) -// }; -// std::fs::write(&output_path, output).expect("write output file"); -// progress_bar.inc(1); -// }); -// progress_bar.finish(); -// } -// } +impl Command { + pub fn run(&self) { + let inputs = self.inputs + .clone() + .into_iter() + .filter_map(|x| glob::glob(&x).ok()) + .map(|x| x.collect::>()) + .flatten() + .filter_map(Result::ok) + .collect::>(); + if inputs.len() > 1 && self.output_file.is_some() { + panic!("Output file isn’t valid for multiple input file paths, maybe use `--output-dir`?"); + } + let output = match (self.output_file.clone(), self.output_dir.clone(), self.replace) { + (Some(x), None, false) => OutputType::File(x), + (None, Some(x), false) => OutputType::Dir(x), + (None, None, true) => OutputType::Replace, + _ => panic!("invalid output type") + }; + if output.is_replace() { + eprintln!("[warning] replacing input files"); + eprintln!("[note] imager only works for original images, i.e. your highest quality versions") + } + let entries = inputs + .clone() + .into_iter() + .flat_map(|input_path| { + self.formats + .clone() + .into_iter() + .flat_map(|f| f.0) + .map(|f| (input_path.clone(), f)) + .collect::>() + }) + .collect::>(); + let progress_bar = ProgressBar::new(entries.len() as u64); + progress_bar.tick(); + if entries.is_empty() { + eprintln!("[warning] no (or missing) input files given"); + } + let entries_len = entries.len(); + entries + .into_par_iter() + .for_each(|(input_path, output_format)| { + let mut opt_job = crate::api::OptJob::open(&input_path).expect("open input file path"); + opt_job.output_format(output_format.clone()); + if let Some(max_size) = self.max_size.clone() { + opt_job.max_size(max_size); + } + let encoded = opt_job.run().expect("opt job failed"); + let different_format = { + OutputFormat::infer_from_path(&input_path) + .map(|src| src != output_format.clone()) + .unwrap_or(true) + }; + let file_name = input_path + .file_name() + .expect("file name") + .to_str() + .expect("OsStr to str"); + let output_ext = match output_format { + OutputFormat::Jpeg => "jpeg", + OutputFormat::Png => "png", + OutputFormat::Webp => "webp", + }; + match output.clone() { + OutputType::Dir(path) => { + if !path.exists() { + std::fs::create_dir_all(&path).expect("create parent dir"); + } + let mut output_path = path.join(file_name); + if different_format { + output_path.set_extension(output_ext); + } + std::fs::write(output_path, encoded).expect("failed to write output file"); + } + OutputType::File(mut output_path) => { + let parent_dir = output_path + .parent() + .expect("get parent path"); + if !parent_dir.exists() { + std::fs::create_dir_all(&parent_dir).expect("create parent dir"); + } + if different_format { + output_path.set_extension(output_ext); + } + std::fs::write(output_path, encoded).expect("failed to write output file"); + } + OutputType::Replace => { + let mut output_path = input_path.clone(); + if different_format { + output_path.set_extension(output_ext); + } + std::fs::write(output_path, encoded).expect("failed to write output file"); + } + } + progress_bar.inc(1); + }); + progress_bar.finish(); + } +} /////////////////////////////////////////////////////////////////////////////// @@ -139,6 +214,5 @@ pub struct Command { fn main() { let cmd = Command::from_args(); - println!("output: {:#?}", cmd); - // cmd.run(); + cmd.run(); } \ No newline at end of file