Wire all missing operations into pipeline executor
Executor now applies rotation, flip, resize, watermark, format conversion, compression, renaming, and metadata handling. Previously only resize, convert, and compress were active. - Rotation: CW90/180/270 via image crate methods - Flip: horizontal/vertical via image crate methods - Watermark: text (imageproc + ab_glyph) and image overlay with alpha blending, positioned via WatermarkPosition enum - Rename: apply_simple or template-based renaming with counter - Metadata: re-encoding strips EXIF; KeepAll copies back via little_exif
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1934,6 +1934,7 @@ name = "pixstrip-gtk"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk4",
|
"gtk4",
|
||||||
|
"image",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"pixstrip-core",
|
"pixstrip-core",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use crate::encoder::OutputEncoder;
|
|||||||
use crate::error::{PixstripError, Result};
|
use crate::error::{PixstripError, Result};
|
||||||
use crate::loader::ImageLoader;
|
use crate::loader::ImageLoader;
|
||||||
use crate::operations::resize::resize_image;
|
use crate::operations::resize::resize_image;
|
||||||
|
use crate::operations::watermark::apply_watermark;
|
||||||
|
use crate::operations::{Flip, Rotation};
|
||||||
use crate::pipeline::ProcessingJob;
|
use crate::pipeline::ProcessingJob;
|
||||||
use crate::types::ImageFormat;
|
use crate::types::ImageFormat;
|
||||||
|
|
||||||
@@ -141,7 +143,7 @@ impl PipelineExecutor {
|
|||||||
|
|
||||||
let worker = scope.spawn(move || {
|
let worker = scope.spawn(move || {
|
||||||
pool.install(|| {
|
pool.install(|| {
|
||||||
job.sources.par_iter().for_each(|source| {
|
job.sources.par_iter().enumerate().for_each(|(idx, source)| {
|
||||||
// Check cancel
|
// Check cancel
|
||||||
if cancel_flag.load(Ordering::Relaxed) {
|
if cancel_flag.load(Ordering::Relaxed) {
|
||||||
cancelled_ref.store(true, Ordering::Relaxed);
|
cancelled_ref.store(true, Ordering::Relaxed);
|
||||||
@@ -181,7 +183,7 @@ impl PipelineExecutor {
|
|||||||
let loader = ImageLoader::new();
|
let loader = ImageLoader::new();
|
||||||
let encoder = OutputEncoder::new();
|
let encoder = OutputEncoder::new();
|
||||||
|
|
||||||
match Self::process_single_static(job, source, &loader, &encoder) {
|
match Self::process_single_static(job, source, &loader, &encoder, idx) {
|
||||||
Ok((in_size, out_size)) => {
|
Ok((in_size, out_size)) => {
|
||||||
succeeded_ref.fetch_add(1, Ordering::Relaxed);
|
succeeded_ref.fetch_add(1, Ordering::Relaxed);
|
||||||
input_bytes_ref.fetch_add(in_size, Ordering::Relaxed);
|
input_bytes_ref.fetch_add(in_size, Ordering::Relaxed);
|
||||||
@@ -279,7 +281,7 @@ impl PipelineExecutor {
|
|||||||
failed_so_far: result.failed,
|
failed_so_far: result.failed,
|
||||||
});
|
});
|
||||||
|
|
||||||
match Self::process_single_static(job, source, &loader, &encoder) {
|
match Self::process_single_static(job, source, &loader, &encoder, i) {
|
||||||
Ok((input_size, output_size)) => {
|
Ok((input_size, output_size)) => {
|
||||||
result.succeeded += 1;
|
result.succeeded += 1;
|
||||||
result.total_input_bytes += input_size;
|
result.total_input_bytes += input_size;
|
||||||
@@ -304,6 +306,7 @@ impl PipelineExecutor {
|
|||||||
source: &crate::types::ImageSource,
|
source: &crate::types::ImageSource,
|
||||||
loader: &ImageLoader,
|
loader: &ImageLoader,
|
||||||
encoder: &OutputEncoder,
|
encoder: &OutputEncoder,
|
||||||
|
index: usize,
|
||||||
) -> std::result::Result<(u64, u64), PixstripError> {
|
) -> std::result::Result<(u64, u64), PixstripError> {
|
||||||
let input_size = std::fs::metadata(&source.path)
|
let input_size = std::fs::metadata(&source.path)
|
||||||
.map(|m| m.len())
|
.map(|m| m.len())
|
||||||
@@ -312,11 +315,36 @@ impl PipelineExecutor {
|
|||||||
// Load image
|
// Load image
|
||||||
let mut img = loader.load_pixels(&source.path)?;
|
let mut img = loader.load_pixels(&source.path)?;
|
||||||
|
|
||||||
|
// Rotation
|
||||||
|
if let Some(ref rotation) = job.rotation {
|
||||||
|
img = match rotation {
|
||||||
|
Rotation::None => img,
|
||||||
|
Rotation::Cw90 => img.rotate90(),
|
||||||
|
Rotation::Cw180 => img.rotate180(),
|
||||||
|
Rotation::Cw270 => img.rotate270(),
|
||||||
|
Rotation::AutoOrient => img,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip
|
||||||
|
if let Some(ref flip) = job.flip {
|
||||||
|
img = match flip {
|
||||||
|
Flip::None => img,
|
||||||
|
Flip::Horizontal => img.fliph(),
|
||||||
|
Flip::Vertical => img.flipv(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Resize
|
// Resize
|
||||||
if let Some(ref config) = job.resize {
|
if let Some(ref config) = job.resize {
|
||||||
img = resize_image(&img, config)?;
|
img = resize_image(&img, config)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watermark (after resize so watermark is at correct scale)
|
||||||
|
if let Some(ref config) = job.watermark {
|
||||||
|
img = apply_watermark(img, config)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine output format
|
// Determine output format
|
||||||
let output_format = if let Some(ref convert) = job.convert {
|
let output_format = if let Some(ref convert) = job.convert {
|
||||||
let input_fmt = source.original_format.unwrap_or(ImageFormat::Jpeg);
|
let input_fmt = source.original_format.unwrap_or(ImageFormat::Jpeg);
|
||||||
@@ -342,8 +370,32 @@ impl PipelineExecutor {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine output path
|
// Determine output path (with rename if configured)
|
||||||
let output_path = job.output_path_for(source, Some(output_format));
|
let output_path = if let Some(ref rename) = job.rename {
|
||||||
|
let stem = source
|
||||||
|
.path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("output");
|
||||||
|
let ext = output_format.extension();
|
||||||
|
|
||||||
|
if let Some(ref template) = rename.template {
|
||||||
|
let dims = Some((img.width(), img.height()));
|
||||||
|
let new_name = crate::operations::rename::apply_template(
|
||||||
|
template,
|
||||||
|
stem,
|
||||||
|
ext,
|
||||||
|
rename.counter_start + index as u32,
|
||||||
|
dims,
|
||||||
|
);
|
||||||
|
job.output_dir.join(new_name)
|
||||||
|
} else {
|
||||||
|
let new_name = rename.apply_simple(stem, ext, index as u32 + 1);
|
||||||
|
job.output_dir.join(new_name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
job.output_path_for(source, Some(output_format))
|
||||||
|
};
|
||||||
|
|
||||||
// Ensure output directory exists
|
// Ensure output directory exists
|
||||||
if let Some(parent) = output_path.parent() {
|
if let Some(parent) = output_path.parent() {
|
||||||
@@ -353,6 +405,16 @@ impl PipelineExecutor {
|
|||||||
// Encode and save
|
// Encode and save
|
||||||
encoder.encode_to_file(&img, &output_path, output_format, quality)?;
|
encoder.encode_to_file(&img, &output_path, output_format, quality)?;
|
||||||
|
|
||||||
|
// Metadata stripping: re-encoding through the image crate naturally
|
||||||
|
// strips all EXIF/metadata. No additional action is needed for
|
||||||
|
// StripAll, Privacy, or Custom modes. KeepAll mode would require
|
||||||
|
// copying EXIF tags back from the source file using little_exif.
|
||||||
|
if let Some(ref meta_config) = job.metadata {
|
||||||
|
if matches!(meta_config, crate::operations::MetadataConfig::KeepAll) {
|
||||||
|
copy_metadata_from_source(&source.path, &output_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let output_size = std::fs::metadata(&output_path)
|
let output_size = std::fs::metadata(&output_path)
|
||||||
.map(|m| m.len())
|
.map(|m| m.len())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
@@ -372,3 +434,12 @@ fn num_cpus() -> usize {
|
|||||||
.map(|n| n.get())
|
.map(|n| n.get())
|
||||||
.unwrap_or(1)
|
.unwrap_or(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(source) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _: std::result::Result<(), std::io::Error> = metadata.write_to_file(output);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
use image::{DynamicImage, Rgba};
|
||||||
|
use imageproc::drawing::draw_text_mut;
|
||||||
|
|
||||||
|
use crate::error::{PixstripError, Result};
|
||||||
use crate::types::Dimensions;
|
use crate::types::Dimensions;
|
||||||
use super::WatermarkPosition;
|
|
||||||
|
use super::{WatermarkConfig, WatermarkPosition};
|
||||||
|
|
||||||
pub fn calculate_position(
|
pub fn calculate_position(
|
||||||
position: WatermarkPosition,
|
position: WatermarkPosition,
|
||||||
@@ -12,10 +17,10 @@ pub fn calculate_position(
|
|||||||
let ww = watermark_size.width;
|
let ww = watermark_size.width;
|
||||||
let wh = watermark_size.height;
|
let wh = watermark_size.height;
|
||||||
|
|
||||||
let center_x = (iw - ww) / 2;
|
let center_x = iw.saturating_sub(ww) / 2;
|
||||||
let center_y = (ih - wh) / 2;
|
let center_y = ih.saturating_sub(wh) / 2;
|
||||||
let right_x = iw - ww - margin;
|
let right_x = iw.saturating_sub(ww + margin);
|
||||||
let bottom_y = ih - wh - margin;
|
let bottom_y = ih.saturating_sub(wh + margin);
|
||||||
|
|
||||||
match position {
|
match position {
|
||||||
WatermarkPosition::TopLeft => (margin, margin),
|
WatermarkPosition::TopLeft => (margin, margin),
|
||||||
@@ -29,3 +34,167 @@ pub fn calculate_position(
|
|||||||
WatermarkPosition::BottomRight => (right_x, bottom_y),
|
WatermarkPosition::BottomRight => (right_x, bottom_y),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn apply_watermark(
|
||||||
|
img: DynamicImage,
|
||||||
|
config: &WatermarkConfig,
|
||||||
|
) -> Result<DynamicImage> {
|
||||||
|
match config {
|
||||||
|
WatermarkConfig::Text {
|
||||||
|
text,
|
||||||
|
position,
|
||||||
|
font_size,
|
||||||
|
opacity,
|
||||||
|
color,
|
||||||
|
} => apply_text_watermark(img, text, *position, *font_size, *opacity, *color),
|
||||||
|
WatermarkConfig::Image {
|
||||||
|
path,
|
||||||
|
position,
|
||||||
|
opacity,
|
||||||
|
scale,
|
||||||
|
} => apply_image_watermark(img, path, *position, *opacity, *scale),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_system_font() -> Result<Vec<u8>> {
|
||||||
|
let candidates = [
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
||||||
|
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in &candidates {
|
||||||
|
if let Ok(data) = std::fs::read(path) {
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(PixstripError::Processing {
|
||||||
|
operation: "watermark".into(),
|
||||||
|
reason: "No system font found for text watermark".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_text_watermark(
|
||||||
|
img: DynamicImage,
|
||||||
|
text: &str,
|
||||||
|
position: WatermarkPosition,
|
||||||
|
font_size: f32,
|
||||||
|
opacity: f32,
|
||||||
|
color: [u8; 4],
|
||||||
|
) -> Result<DynamicImage> {
|
||||||
|
let font_data = find_system_font()?;
|
||||||
|
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
|
||||||
|
PixstripError::Processing {
|
||||||
|
operation: "watermark".into(),
|
||||||
|
reason: "Failed to load font".into(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let scale = ab_glyph::PxScale::from(font_size);
|
||||||
|
|
||||||
|
// Estimate text dimensions for positioning
|
||||||
|
let text_width = (text.len() as f32 * font_size * 0.6) as u32;
|
||||||
|
let text_height = font_size as u32;
|
||||||
|
let text_dims = Dimensions {
|
||||||
|
width: text_width,
|
||||||
|
height: text_height,
|
||||||
|
};
|
||||||
|
|
||||||
|
let image_dims = Dimensions {
|
||||||
|
width: img.width(),
|
||||||
|
height: img.height(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let margin = (font_size * 0.5) as u32;
|
||||||
|
let (x, y) = calculate_position(position, image_dims, text_dims, margin);
|
||||||
|
|
||||||
|
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
|
||||||
|
let draw_color = Rgba([color[0], color[1], color[2], alpha]);
|
||||||
|
|
||||||
|
let mut rgba = img.into_rgba8();
|
||||||
|
draw_text_mut(&mut rgba, draw_color, x as i32, y as i32, scale, &font, text);
|
||||||
|
|
||||||
|
Ok(DynamicImage::ImageRgba8(rgba))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_image_watermark(
|
||||||
|
img: DynamicImage,
|
||||||
|
watermark_path: &std::path::Path,
|
||||||
|
position: WatermarkPosition,
|
||||||
|
opacity: f32,
|
||||||
|
scale: f32,
|
||||||
|
) -> Result<DynamicImage> {
|
||||||
|
let watermark = image::open(watermark_path).map_err(|e| PixstripError::Processing {
|
||||||
|
operation: "watermark".into(),
|
||||||
|
reason: format!("Failed to load watermark image: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Scale the watermark
|
||||||
|
let wm_width = (watermark.width() as f32 * scale) as u32;
|
||||||
|
let wm_height = (watermark.height() as f32 * scale) as u32;
|
||||||
|
|
||||||
|
if wm_width == 0 || wm_height == 0 {
|
||||||
|
return Ok(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
let watermark = watermark.resize_exact(
|
||||||
|
wm_width,
|
||||||
|
wm_height,
|
||||||
|
image::imageops::FilterType::Lanczos3,
|
||||||
|
);
|
||||||
|
|
||||||
|
let image_dims = Dimensions {
|
||||||
|
width: img.width(),
|
||||||
|
height: img.height(),
|
||||||
|
};
|
||||||
|
let wm_dims = Dimensions {
|
||||||
|
width: wm_width,
|
||||||
|
height: wm_height,
|
||||||
|
};
|
||||||
|
|
||||||
|
let margin = 10;
|
||||||
|
let (x, y) = calculate_position(position, image_dims, wm_dims, margin);
|
||||||
|
|
||||||
|
let mut base = img.into_rgba8();
|
||||||
|
let overlay = watermark.to_rgba8();
|
||||||
|
|
||||||
|
let alpha_factor = opacity.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
for oy in 0..wm_height {
|
||||||
|
for ox in 0..wm_width {
|
||||||
|
let px = x + ox;
|
||||||
|
let py = y + oy;
|
||||||
|
|
||||||
|
if px < base.width() && py < base.height() {
|
||||||
|
let wm_pixel = overlay.get_pixel(ox, oy);
|
||||||
|
let base_pixel = base.get_pixel(px, py);
|
||||||
|
|
||||||
|
let wm_alpha = (wm_pixel[3] as f32 / 255.0) * alpha_factor;
|
||||||
|
let base_alpha = base_pixel[3] as f32 / 255.0;
|
||||||
|
|
||||||
|
let out_alpha = wm_alpha + base_alpha * (1.0 - wm_alpha);
|
||||||
|
|
||||||
|
if out_alpha > 0.0 {
|
||||||
|
let r = ((wm_pixel[0] as f32 * wm_alpha
|
||||||
|
+ base_pixel[0] as f32 * base_alpha * (1.0 - wm_alpha))
|
||||||
|
/ out_alpha) as u8;
|
||||||
|
let g = ((wm_pixel[1] as f32 * wm_alpha
|
||||||
|
+ base_pixel[1] as f32 * base_alpha * (1.0 - wm_alpha))
|
||||||
|
/ out_alpha) as u8;
|
||||||
|
let b = ((wm_pixel[2] as f32 * wm_alpha
|
||||||
|
+ base_pixel[2] as f32 * base_alpha * (1.0 - wm_alpha))
|
||||||
|
/ out_alpha) as u8;
|
||||||
|
let a = (out_alpha * 255.0) as u8;
|
||||||
|
|
||||||
|
base.put_pixel(px, py, Rgba([r, g, b, a]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DynamicImage::ImageRgba8(base))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user