diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index ebaebe9..9e6effc 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -7,6 +7,7 @@ use rayon::prelude::*; use crate::encoder::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::watermark::apply_watermark; use crate::operations::{Flip, Rotation}; @@ -340,6 +341,13 @@ impl PipelineExecutor { img = resize_image(&img, config)?; } + // Adjustments (brightness, contrast, saturation, effects, crop, padding) + if let Some(ref adj) = job.adjustments { + if !adj.is_noop() { + img = apply_adjustments(img, adj)?; + } + } + // Watermark (after resize so watermark is at correct scale) if let Some(ref config) = job.watermark { img = apply_watermark(img, config)?; diff --git a/pixstrip-core/src/operations/adjustments.rs b/pixstrip-core/src/operations/adjustments.rs new file mode 100644 index 0000000..c1d7e0c --- /dev/null +++ b/pixstrip-core/src/operations/adjustments.rs @@ -0,0 +1,198 @@ +use image::{DynamicImage, Rgba, RgbaImage}; + +use crate::error::Result; +use super::AdjustmentsConfig; + +pub fn apply_adjustments( + mut img: DynamicImage, + config: &AdjustmentsConfig, +) -> Result { + // Crop to aspect ratio (before other adjustments) + if let Some((w_ratio, h_ratio)) = config.crop_aspect_ratio { + img = crop_to_aspect_ratio(img, w_ratio, h_ratio); + } + + // Trim whitespace + if config.trim_whitespace { + img = trim_whitespace(img); + } + + // Brightness + if config.brightness != 0 { + img = img.brighten(config.brightness); + } + + // Contrast + if config.contrast != 0 { + img = img.adjust_contrast(config.contrast as f32); + } + + // Saturation + if config.saturation != 0 { + img = adjust_saturation(img, config.saturation); + } + + // Grayscale + if config.grayscale { + img = img.grayscale(); + } + + // Sepia + if config.sepia { + img = apply_sepia(img); + } + + // Sharpen + if config.sharpen { + img = img.unsharpen(1.0, 5); + } + + // Canvas padding + if config.canvas_padding > 0 { + img = add_canvas_padding(img, config.canvas_padding); + } + + Ok(img) +} + +fn crop_to_aspect_ratio(img: DynamicImage, w_ratio: f64, h_ratio: f64) -> DynamicImage { + let (iw, ih) = (img.width(), img.height()); + let target_ratio = w_ratio / h_ratio; + let current_ratio = iw as f64 / ih as f64; + + let (crop_w, crop_h) = if current_ratio > target_ratio { + // Image is wider than target, crop width + let new_w = (ih as f64 * target_ratio) as u32; + (new_w, ih) + } else { + // Image is taller than target, crop height + let new_h = (iw as f64 / target_ratio) as u32; + (iw, new_h) + }; + + let x = (iw - crop_w) / 2; + let y = (ih - crop_h) / 2; + + img.crop_imm(x, y, crop_w, crop_h) +} + +fn trim_whitespace(img: DynamicImage) -> DynamicImage { + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width(), rgba.height()); + + if w == 0 || h == 0 { + return img; + } + + // Get corner pixel as reference background color + let bg = *rgba.get_pixel(0, 0); + let threshold = 30u32; + + let is_bg = |p: &Rgba| -> bool { + let dr = (p[0] as i32 - bg[0] as i32).unsigned_abs(); + let dg = (p[1] as i32 - bg[1] as i32).unsigned_abs(); + let db = (p[2] as i32 - bg[2] as i32).unsigned_abs(); + dr + dg + db < threshold + }; + + let mut top = 0u32; + let mut bottom = h - 1; + let mut left = 0u32; + let mut right = w - 1; + + // Find top + 'top: for y in 0..h { + for x in 0..w { + if !is_bg(rgba.get_pixel(x, y)) { + top = y; + break 'top; + } + } + } + + // Find bottom + 'bottom: for y in (0..h).rev() { + for x in 0..w { + if !is_bg(rgba.get_pixel(x, y)) { + bottom = y; + break 'bottom; + } + } + } + + // Find left + 'left: for x in 0..w { + for y in top..=bottom { + if !is_bg(rgba.get_pixel(x, y)) { + left = x; + break 'left; + } + } + } + + // Find right + 'right: for x in (0..w).rev() { + for y in top..=bottom { + if !is_bg(rgba.get_pixel(x, y)) { + right = x; + break 'right; + } + } + } + + let crop_w = right.saturating_sub(left) + 1; + let crop_h = bottom.saturating_sub(top) + 1; + + if crop_w == 0 || crop_h == 0 || (crop_w == w && crop_h == h) { + return img; + } + + img.crop_imm(left, top, crop_w, crop_h) +} + +fn adjust_saturation(img: DynamicImage, amount: i32) -> DynamicImage { + let mut rgba = img.into_rgba8(); + let factor = 1.0 + (amount as f64 / 100.0); + + for pixel in rgba.pixels_mut() { + let r = pixel[0] as f64; + let g = pixel[1] as f64; + let b = pixel[2] as f64; + + let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b; + + pixel[0] = (gray + (r - gray) * factor).clamp(0.0, 255.0) as u8; + pixel[1] = (gray + (g - gray) * factor).clamp(0.0, 255.0) as u8; + pixel[2] = (gray + (b - gray) * factor).clamp(0.0, 255.0) as u8; + } + + DynamicImage::ImageRgba8(rgba) +} + +fn apply_sepia(img: DynamicImage) -> DynamicImage { + let mut rgba = img.into_rgba8(); + + for pixel in rgba.pixels_mut() { + let r = pixel[0] as f64; + let g = pixel[1] as f64; + let b = pixel[2] as f64; + + pixel[0] = (0.393 * r + 0.769 * g + 0.189 * b).clamp(0.0, 255.0) as u8; + pixel[1] = (0.349 * r + 0.686 * g + 0.168 * b).clamp(0.0, 255.0) as u8; + pixel[2] = (0.272 * r + 0.534 * g + 0.131 * b).clamp(0.0, 255.0) as u8; + } + + DynamicImage::ImageRgba8(rgba) +} + +fn add_canvas_padding(img: DynamicImage, padding: u32) -> DynamicImage { + let (w, h) = (img.width(), img.height()); + let new_w = w + padding * 2; + let new_h = h + padding * 2; + + let mut canvas = RgbaImage::from_pixel(new_w, new_h, Rgba([255, 255, 255, 255])); + + image::imageops::overlay(&mut canvas, &img.to_rgba8(), padding as i64, padding as i64); + + DynamicImage::ImageRgba8(canvas) +} diff --git a/pixstrip-core/src/operations/mod.rs b/pixstrip-core/src/operations/mod.rs index d21627f..c58de0f 100644 --- a/pixstrip-core/src/operations/mod.rs +++ b/pixstrip-core/src/operations/mod.rs @@ -1,3 +1,4 @@ +pub mod adjustments; pub mod metadata; pub mod rename; pub mod resize; @@ -195,6 +196,35 @@ pub enum WatermarkConfig { }, } +// --- Adjustments --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdjustmentsConfig { + pub brightness: i32, + pub contrast: i32, + pub saturation: i32, + pub sharpen: bool, + pub grayscale: bool, + pub sepia: bool, + pub crop_aspect_ratio: Option<(f64, f64)>, + pub trim_whitespace: bool, + pub canvas_padding: u32, +} + +impl AdjustmentsConfig { + pub fn is_noop(&self) -> bool { + self.brightness == 0 + && self.contrast == 0 + && self.saturation == 0 + && !self.sharpen + && !self.grayscale + && !self.sepia + && self.crop_aspect_ratio.is_none() + && !self.trim_whitespace + && self.canvas_padding == 0 + } +} + // --- Rename --- #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/pixstrip-core/src/pipeline.rs b/pixstrip-core/src/pipeline.rs index d13c85a..f316df5 100644 --- a/pixstrip-core/src/pipeline.rs +++ b/pixstrip-core/src/pipeline.rs @@ -14,6 +14,7 @@ pub struct ProcessingJob { pub resize: Option, pub rotation: Option, pub flip: Option, + pub adjustments: Option, pub convert: Option, pub compress: Option, pub metadata: Option, @@ -31,6 +32,7 @@ impl ProcessingJob { resize: None, rotation: None, flip: None, + adjustments: None, convert: None, compress: None, metadata: None, @@ -49,6 +51,7 @@ impl ProcessingJob { if self.resize.is_some() { count += 1; } if self.rotation.is_some() { count += 1; } if self.flip.is_some() { count += 1; } + if self.adjustments.as_ref().is_some_and(|a| !a.is_noop()) { count += 1; } if self.convert.is_some() { count += 1; } if self.compress.is_some() { count += 1; } if self.metadata.is_some() { count += 1; } diff --git a/pixstrip-core/src/preset.rs b/pixstrip-core/src/preset.rs index 37720ad..19b4a38 100644 --- a/pixstrip-core/src/preset.rs +++ b/pixstrip-core/src/preset.rs @@ -33,6 +33,7 @@ impl Preset { resize: self.resize.clone(), rotation: self.rotation, flip: self.flip, + adjustments: None, convert: self.convert.clone(), compress: self.compress.clone(), metadata: self.metadata.clone(), diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index d190ee3..270370a 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -1163,6 +1163,34 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { _ => pixstrip_core::operations::Flip::None, }); + // Adjustments + { + let crop = match cfg.crop_aspect_ratio { + 1 => Some((1.0, 1.0)), + 2 => Some((4.0, 3.0)), + 3 => Some((3.0, 2.0)), + 4 => Some((16.0, 9.0)), + 5 => Some((9.0, 16.0)), + 6 => Some((3.0, 4.0)), + 7 => Some((2.0, 3.0)), + _ => None, + }; + let adj = pixstrip_core::operations::AdjustmentsConfig { + brightness: cfg.brightness, + contrast: cfg.contrast, + saturation: cfg.saturation, + sharpen: cfg.sharpen, + grayscale: cfg.grayscale, + sepia: cfg.sepia, + crop_aspect_ratio: crop, + trim_whitespace: cfg.trim_whitespace, + canvas_padding: cfg.canvas_padding, + }; + if !adj.is_noop() { + job.adjustments = Some(adj); + } + } + // Watermark if cfg.watermark_enabled { let position = match cfg.watermark_position {