diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index ba4892d..7c556c3 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -8,7 +8,7 @@ use crate::encoder::{EncoderOptions, OutputEncoder}; use crate::error::{PixstripError, Result}; use crate::loader::ImageLoader; use crate::operations::adjustments::apply_adjustments; -use crate::operations::resize::resize_image; +use crate::operations::resize::resize_image_with_algorithm; use crate::operations::watermark::apply_watermark; use crate::operations::{Flip, Rotation}; use crate::pipeline::ProcessingJob; @@ -344,7 +344,7 @@ impl PipelineExecutor { // Resize if let Some(ref config) = job.resize { - img = resize_image(&img, config)?; + img = resize_image_with_algorithm(&img, config, job.resize_algorithm)?; } // Adjustments (brightness, contrast, saturation, effects, crop, padding) @@ -413,6 +413,25 @@ impl PipelineExecutor { job.output_path_for(source, Some(output_format)) }; + // Handle overwrite behavior + let output_path = match job.overwrite_behavior { + crate::operations::OverwriteBehavior::Skip => { + if output_path.exists() { + // Return 0 bytes written - file was skipped + return Ok((input_size, 0)); + } + output_path + } + crate::operations::OverwriteBehavior::AutoRename => { + if output_path.exists() { + find_unique_path(&output_path) + } else { + output_path + } + } + crate::operations::OverwriteBehavior::Overwrite => output_path, + }; + // Ensure output directory exists if let Some(parent) = output_path.parent() { std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; @@ -451,6 +470,30 @@ fn num_cpus() -> usize { .unwrap_or(1) } +fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("output"); + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("bin"); + let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + + for i in 1u32..10000 { + let candidate = parent.join(format!("{}_{}.{}", stem, i, ext)); + if !candidate.exists() { + return candidate; + } + } + // Extremely unlikely fallback + parent.join(format!("{}_{}.{}", stem, std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0), ext)) +} + fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) { // Best-effort: try to copy EXIF from source to output using little_exif. // If it fails (e.g. non-JPEG, no EXIF), silently continue. diff --git a/pixstrip-core/src/operations/mod.rs b/pixstrip-core/src/operations/mod.rs index c58de0f..df2abe2 100644 --- a/pixstrip-core/src/operations/mod.rs +++ b/pixstrip-core/src/operations/mod.rs @@ -46,6 +46,22 @@ impl ResizeConfig { } } +// --- Resize Algorithm --- + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum ResizeAlgorithm { + Lanczos3, + CatmullRom, + Bilinear, + Nearest, +} + +impl Default for ResizeAlgorithm { + fn default() -> Self { + Self::Lanczos3 + } +} + // --- Rotation / Flip --- #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -225,6 +241,21 @@ impl AdjustmentsConfig { } } +// --- Overwrite Behavior --- + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum OverwriteBehavior { + AutoRename, + Overwrite, + Skip, +} + +impl Default for OverwriteBehavior { + fn default() -> Self { + Self::AutoRename + } +} + // --- Rename --- #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/pixstrip-core/src/operations/resize.rs b/pixstrip-core/src/operations/resize.rs index 5423445..7702ddd 100644 --- a/pixstrip-core/src/operations/resize.rs +++ b/pixstrip-core/src/operations/resize.rs @@ -3,11 +3,19 @@ use fast_image_resize::{images::Image, Resizer, ResizeOptions, ResizeAlg, Filter use crate::error::{PixstripError, Result}; use crate::types::Dimensions; -use super::ResizeConfig; +use super::{ResizeConfig, ResizeAlgorithm}; pub fn resize_image( src: &image::DynamicImage, config: &ResizeConfig, +) -> Result { + resize_image_with_algorithm(src, config, ResizeAlgorithm::default()) +} + +pub fn resize_image_with_algorithm( + src: &image::DynamicImage, + config: &ResizeConfig, + algorithm: ResizeAlgorithm, ) -> Result { let original = Dimensions { width: src.width(), @@ -40,7 +48,13 @@ pub fn resize_image( ); let mut resizer = Resizer::new(); - let options = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(FilterType::Lanczos3)); + let alg = match algorithm { + ResizeAlgorithm::Lanczos3 => ResizeAlg::Convolution(FilterType::Lanczos3), + ResizeAlgorithm::CatmullRom => ResizeAlg::Convolution(FilterType::CatmullRom), + ResizeAlgorithm::Bilinear => ResizeAlg::Convolution(FilterType::Bilinear), + ResizeAlgorithm::Nearest => ResizeAlg::Nearest, + }; + let options = ResizeOptions::new().resize_alg(alg); resizer .resize(&src_image, &mut dst_image, &options) diff --git a/pixstrip-core/src/pipeline.rs b/pixstrip-core/src/pipeline.rs index 37b2126..60b9b8b 100644 --- a/pixstrip-core/src/pipeline.rs +++ b/pixstrip-core/src/pipeline.rs @@ -12,6 +12,7 @@ pub struct ProcessingJob { #[serde(skip)] pub sources: Vec, pub resize: Option, + pub resize_algorithm: ResizeAlgorithm, pub rotation: Option, pub flip: Option, pub adjustments: Option, @@ -20,6 +21,7 @@ pub struct ProcessingJob { pub metadata: Option, pub watermark: Option, pub rename: Option, + pub overwrite_behavior: OverwriteBehavior, pub preserve_directory_structure: bool, pub progressive_jpeg: bool, pub avif_speed: u8, @@ -32,6 +34,7 @@ impl ProcessingJob { output_dir: output_dir.as_ref().to_path_buf(), sources: Vec::new(), resize: None, + resize_algorithm: ResizeAlgorithm::default(), rotation: None, flip: None, adjustments: None, @@ -40,6 +43,7 @@ impl ProcessingJob { metadata: None, watermark: None, rename: None, + overwrite_behavior: OverwriteBehavior::default(), preserve_directory_structure: false, progressive_jpeg: false, avif_speed: 6, diff --git a/pixstrip-core/src/preset.rs b/pixstrip-core/src/preset.rs index d65242d..f3ba1a8 100644 --- a/pixstrip-core/src/preset.rs +++ b/pixstrip-core/src/preset.rs @@ -31,6 +31,7 @@ impl Preset { output_dir: output_dir.into(), sources: Vec::new(), resize: self.resize.clone(), + resize_algorithm: crate::operations::ResizeAlgorithm::default(), rotation: self.rotation, flip: self.flip, adjustments: None, @@ -39,6 +40,7 @@ impl Preset { metadata: self.metadata.clone(), watermark: self.watermark.clone(), rename: self.rename.clone(), + overwrite_behavior: crate::operations::OverwriteBehavior::default(), preserve_directory_structure: false, progressive_jpeg: false, avif_speed: 6, diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 567747d..95cb256 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -18,6 +18,7 @@ pub struct JobConfig { pub resize_width: u32, pub resize_height: u32, pub allow_upscale: bool, + pub resize_algorithm: u32, // Adjustments pub adjustments_enabled: bool, pub rotation: u32, @@ -222,6 +223,7 @@ fn build_ui(app: &adw::Application) { resize_width: if remember { sess_state.resize_width.unwrap_or(1200) } else { 1200 }, resize_height: if remember { sess_state.resize_height.unwrap_or(0) } else { 0 }, allow_upscale: false, + resize_algorithm: 0, adjustments_enabled: false, rotation: 0, flip: 0, @@ -1255,6 +1257,12 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { allow_upscale: cfg.allow_upscale, }); } + job.resize_algorithm = match cfg.resize_algorithm { + 1 => pixstrip_core::operations::ResizeAlgorithm::CatmullRom, + 2 => pixstrip_core::operations::ResizeAlgorithm::Bilinear, + 3 => pixstrip_core::operations::ResizeAlgorithm::Nearest, + _ => pixstrip_core::operations::ResizeAlgorithm::Lanczos3, + }; } if cfg.convert_enabled { @@ -1344,8 +1352,8 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { }); } - // Rotation, Flip, and Adjustments (only when adjustments step is enabled) - if cfg.adjustments_enabled { + // Rotation and Flip apply from the resize step, so enable when either resize or adjustments is active + if cfg.resize_enabled || cfg.adjustments_enabled { job.rotation = Some(match cfg.rotation { 1 => pixstrip_core::operations::Rotation::Cw90, 2 => pixstrip_core::operations::Rotation::Cw180, @@ -1359,7 +1367,10 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { 2 => pixstrip_core::operations::Flip::Vertical, _ => pixstrip_core::operations::Flip::None, }); + } + // Adjustments (brightness, contrast, etc.) + if cfg.adjustments_enabled { let crop = match cfg.crop_aspect_ratio { 1 => Some((1.0, 1.0)), 2 => Some((4.0, 3.0)), @@ -1436,6 +1447,12 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { } job.preserve_directory_structure = cfg.preserve_dir_structure; + job.overwrite_behavior = match cfg.overwrite_behavior { + 1 => pixstrip_core::operations::OverwriteBehavior::AutoRename, + 2 => pixstrip_core::operations::OverwriteBehavior::Overwrite, + 3 => pixstrip_core::operations::OverwriteBehavior::Skip, + _ => pixstrip_core::operations::OverwriteBehavior::AutoRename, // 0 "Ask" defaults to auto-rename in batch + }; drop(cfg); diff --git a/pixstrip-gtk/src/steps/step_resize.rs b/pixstrip-gtk/src/steps/step_resize.rs index 71c0529..db3bcf1 100644 --- a/pixstrip-gtk/src/steps/step_resize.rs +++ b/pixstrip-gtk/src/steps/step_resize.rs @@ -453,6 +453,12 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage { jc.borrow_mut().flip = row.selected(); }); } + { + let jc = state.job_config.clone(); + algorithm_row.connect_selected_notify(move |row| { + jc.borrow_mut().resize_algorithm = row.selected(); + }); + } scrolled.set_child(Some(&content));