Files
watermark-wizard/src-tauri/src/lib.rs
Julian Freeman a5f1b165fd fix wateramrk
2026-01-18 23:55:14 -04:00

304 lines
10 KiB
Rust

use std::fs;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::env;
#[derive(serde::Serialize, Clone)]
struct ImageItem {
path: String,
name: String,
thumbnail: String,
}
fn get_cache_dir() -> std::path::PathBuf {
let mut path = env::temp_dir();
path.push("watermark-wizard-thumbs");
if !path.exists() {
let _ = fs::create_dir_all(&path);
}
path
}
fn generate_thumbnail(original_path: &Path) -> Option<String> {
let cache_dir = get_cache_dir();
// Generate simple hash for filename
let mut hasher = DefaultHasher::new();
original_path.hash(&mut hasher);
let hash = hasher.finish();
let file_name = format!("{}.jpg", hash);
let thumb_path = cache_dir.join(file_name);
// Return if exists
if thumb_path.exists() {
return Some(thumb_path.to_string_lossy().to_string());
}
// Generate
// Use image reader to be faster? open is fine.
if let Ok(img) = image::open(original_path) {
// Resize to height 200, preserve ratio
let thumb = img.thumbnail(u32::MAX, 200);
// Save as jpeg with quality 80
let mut file = fs::File::create(&thumb_path).ok()?;
// thumb.write_to(&mut file, image::ImageOutputFormat::Jpeg(80)).ok()?;
// write_to might be slow due to encoding, but parallel execution helps.
// save directly
thumb.save_with_format(&thumb_path, image::ImageFormat::Jpeg).ok()?;
return Some(thumb_path.to_string_lossy().to_string());
}
None
}
#[tauri::command]
async fn scan_dir(path: String) -> Result<Vec<ImageItem>, String> {
let entries = fs::read_dir(&path).map_err(|e| e.to_string())?;
// Collect valid paths first to avoid holding fs locks or iterators during parallel proc
let mut valid_paths = Vec::new();
for entry in entries {
if let Ok(entry) = entry {
let p = entry.path();
if p.is_file() {
if let Some(ext) = p.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if ["png", "jpg", "jpeg", "webp"].contains(&ext_str.as_str()) {
valid_paths.push(p);
}
}
}
}
}
// Process in parallel
let mut images: Vec<ImageItem> = valid_paths.par_iter().filter_map(|path| {
let name = path.file_name()?.to_string_lossy().to_string();
let path_str = path.to_string_lossy().to_string();
// Generate thumbnail
let thumb = generate_thumbnail(path).unwrap_or_else(|| path_str.clone());
Some(ImageItem {
path: path_str,
name,
thumbnail: thumb,
})
}).collect();
// Sort by name
images.sort_by(|a, b| a.name.cmp(&b.name));
Ok(images)
}
use image::GenericImageView;
use image::Pixel;
use rayon::prelude::*;
use std::path::Path;
use imageproc::drawing::draw_text_mut;
use ab_glyph::{FontRef, PxScale};
// Embed the font to ensure it's always available without path issues
const FONT_DATA: &[u8] = include_bytes!("../assets/fonts/Roboto-Regular.ttf");
#[derive(serde::Serialize)]
struct ZcaResult {
x: f64,
y: f64,
zone: String,
}
#[derive(serde::Deserialize)]
struct ExportImageTask {
path: String,
}
#[derive(serde::Deserialize)]
struct WatermarkSettings {
#[serde(rename = "type")]
_w_type: String, // 'text' (image is deprecated for now per user request, but keeping struct flexible)
text: String, // Was 'source'
color: String, // Hex code e.g. "#FFFFFF"
opacity: f64,
scale: f64, // Font size relative to image height (e.g., 0.05 = 5% of height)
manual_override: bool,
manual_position: ManualPosition,
}
#[derive(serde::Deserialize)]
struct ManualPosition {
x: f64,
y: f64,
}
fn parse_hex_color(hex: &str) -> image::Rgba<u8> {
let hex = hex.trim_start_matches('#');
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(255);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(255);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(255);
image::Rgba([r, g, b, 255])
}
#[tauri::command]
async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings, output_dir: String) -> Result<String, String> {
let font = FontRef::try_from_slice(FONT_DATA).map_err(|e| format!("Font error: {}", e))?;
let base_color = parse_hex_color(&watermark.color);
// Calculate final color with opacity
// imageproc draws with the exact color given. To support opacity, we need to handle it.
// However, draw_text_mut blends. If we provide an Rgba with alpha < 255, it should blend.
let alpha = (watermark.opacity * 255.0) as u8;
let text_color = image::Rgba([base_color[0], base_color[1], base_color[2], alpha]);
let results: Vec<Result<(), String>> = images.par_iter().map(|task| {
let input_path = Path::new(&task.path);
let img_result = image::open(input_path);
if let Ok(dynamic_img) = img_result {
let mut base_img = dynamic_img.to_rgba8();
let (width, height) = base_img.dimensions();
// 1. Calculate Font Scale
// watermark.scale is percentage of image height. e.g. 0.05
let mut scale_px = height as f32 * watermark.scale as f32;
// 2. Measure Text
let scaled_font = PxScale::from(scale_px);
let (t_width, t_height) = imageproc::drawing::text_size(scaled_font, &font, &watermark.text);
// 3. Ensure it fits width (Padding 5%)
let max_width = (width as f32 * 0.95) as u32;
if t_width > max_width {
let ratio = max_width as f32 / t_width as f32;
scale_px *= ratio;
// Re-measure isn't strictly necessary for center calc if we assume linear scaling,
// but let's be safe for variable width fonts? Actually text_size scales linearly.
}
let final_scale = PxScale::from(scale_px);
let (final_t_width, final_t_height) = imageproc::drawing::text_size(final_scale, &font, &watermark.text);
// 4. Determine Position (Center based)
let (mut pos_x_pct, mut pos_y_pct) = if watermark.manual_override {
(watermark.manual_position.x, watermark.manual_position.y)
} else {
match calculate_zca_internal(&dynamic_img) {
Ok(res) => (res.x, res.y),
Err(_) => (0.5, 0.96), // Default fallback lowered
}
};
// Calculate top-left coordinate for draw_text_mut (it expects top-left, not center)
let center_x = width as f64 * pos_x_pct;
let center_y = height as f64 * pos_y_pct;
let mut x = (center_x - (final_t_width as f64 / 2.0)) as i32;
let mut y = (center_y - (final_t_height as f64 / 2.0)) as i32;
// 5. Boundary Clamping (Ensure text stays inside image with padding)
let padding = (height as f64 * 0.01).max(5.0) as i32; // 1% padding
let max_x = (width as i32 - final_t_width as i32 - padding).max(padding);
let max_y = (height as i32 - final_t_height as i32 - padding).max(padding);
x = x.clamp(padding, max_x);
y = y.clamp(padding, max_y);
// 6. Draw
draw_text_mut(
&mut base_img,
text_color,
x,
y,
final_scale,
&font,
&watermark.text,
);
// Save
let file_name = input_path.file_name().unwrap_or_default();
let output_path = Path::new(&output_dir).join(file_name);
base_img.save(output_path).map_err(|e| e.to_string())?;
Ok(())
} else {
Err(format!("Failed to open {}", task.path))
}
}).collect();
let failures: Vec<String> = results.into_iter().filter_map(|r| r.err()).collect();
if failures.is_empty() {
Ok("All images processed successfully".to_string())
} else {
Err(format!("Completed with errors: {:?}", failures))
}
}
// Helper to reuse logic (adapted from command)
fn calculate_zca_internal(img: &image::DynamicImage) -> Result<ZcaResult, String> {
let (width, height) = img.dimensions();
let bottom_start_y = (height as f64 * 0.8) as u32;
let zone_height = height - bottom_start_y;
let zone_width = width / 3;
let zones = [
("Left", 0, bottom_start_y),
("Center", zone_width, bottom_start_y),
("Right", zone_width * 2, bottom_start_y),
];
let mut min_std_dev = f64::MAX;
let mut best_zone = "Center";
let mut best_pos = (0.5, 0.95);
for (name, start_x, start_y) in zones.iter() {
let mut luma_values = Vec::with_capacity((zone_width * zone_height) as usize);
for y in *start_y..height {
for x in *start_x..(*start_x + zone_width) {
if x >= width { continue; }
let pixel = img.get_pixel(x, y);
let rgb = pixel.to_rgb();
let luma = 0.299 * rgb[0] as f64 + 0.587 * rgb[1] as f64 + 0.114 * rgb[2] as f64;
luma_values.push(luma);
}
}
let count = luma_values.len() as f64;
if count == 0.0 { continue; }
let mean = luma_values.iter().sum::<f64>() / count;
let variance = luma_values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / count;
let std_dev = variance.sqrt();
if std_dev < min_std_dev {
min_std_dev = std_dev;
best_zone = name;
let center_x_px = *start_x as f64 + (zone_width as f64 / 2.0);
let center_y_px = *start_y as f64 + (zone_height as f64 * 0.75);
best_pos = (center_x_px / width as f64, center_y_px / height as f64);
}
}
Ok(ZcaResult {
x: best_pos.0,
y: best_pos.1,
zone: best_zone.to_string(),
})
}
#[tauri::command]
fn get_zca_suggestion(path: String) -> Result<ZcaResult, String> {
let img = image::open(&path).map_err(|e| e.to_string())?;
calculate_zca_internal(&img)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![scan_dir, get_zca_suggestion, export_batch])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}