diff --git a/package.json b/package.json index 3979246..2f0fe13 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "autoprefixer": "^10.4.23", + "lucide-vue-next": "^0.562.0", "pinia": "^3.0.4", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70d04cc..f08ed19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: autoprefixer: specifier: ^10.4.23 version: 10.4.23(postcss@8.5.6) + lucide-vue-next: + specifier: ^0.562.0 + version: 0.562.0(vue@3.5.26(typescript@5.6.3)) pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.6.3)(vue@3.5.26(typescript@5.6.3)) @@ -782,6 +785,11 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + lucide-vue-next@0.562.0: + resolution: {integrity: sha512-LN0BLGKMFulv0lnfK29r14DcngRUhIqdcaL0zXTt2o0oS9odlrjCGaU3/X9hIihOjjN8l8e+Y9G/famcNYaI7Q==} + peerDependencies: + vue: '>=3.0.1' + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1524,6 +1532,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + lucide-vue-next@0.562.0(vue@3.5.26(typescript@5.6.3)): + dependencies: + vue: 3.5.26(typescript@5.6.3) + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3c6e1f8..35c8410 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" @@ -65,6 +81,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -1175,8 +1200,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1668,6 +1695,24 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imageproc" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image", + "itertools 0.12.1", + "nalgebra", + "num", + "rand 0.8.5", + "rand_distr", + "rayon", +] + [[package]] name = "imgref" version = "1.12.0" @@ -1733,6 +1778,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1920,6 +1974,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libredox" version = "0.1.12" @@ -1997,6 +2057,16 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -2080,6 +2150,21 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2137,6 +2222,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2147,6 +2246,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2173,6 +2281,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -2191,6 +2310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2439,6 +2559,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "pango" version = "0.18.3" @@ -2953,6 +3082,16 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2987,7 +3126,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -3027,6 +3166,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.11.0" @@ -3202,6 +3347,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3483,6 +3637,19 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -4334,6 +4501,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "typeid" version = "1.0.3" @@ -4607,7 +4780,9 @@ dependencies = [ name = "watermark-wizard" version = "0.1.0" dependencies = [ + "ab_glyph", "image", + "imageproc", "rayon", "serde", "serde_json", @@ -4712,6 +4887,16 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cde5623..ef8021d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,7 +21,9 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["protocol-asset"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -image = "0.25" +image = { version = "0.25", features = ["png", "jpeg", "webp"] } rayon = "1.10" tauri-plugin-dialog = "2" +imageproc = "0.25" +ab_glyph = "0.2.23" diff --git a/src-tauri/assets/fonts/Roboto-Regular.ttf b/src-tauri/assets/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..810df57 Binary files /dev/null and b/src-tauri/assets/fonts/Roboto-Regular.ttf differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e1c5264..723e94e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,6 +34,13 @@ fn scan_dir(path: String) -> Result, String> { use image::GenericImageView; use image::Pixel; +use rayon::prelude::*; +use std::path::Path; +use imageproc::drawing::draw_text_mut; +use ab_glyph::{FontRef, PxScale, Font}; + +// 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 { @@ -42,13 +49,132 @@ struct ZcaResult { 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 { + 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] -fn get_zca_suggestion(path: String) -> Result { - let img = image::open(&path).map_err(|e| e.to_string())?; +async fn export_batch(images: Vec, watermark: WatermarkSettings, output_dir: String) -> Result { + 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> = 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 + let (pos_x_pct, 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.95), + } + }; + + // 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 x = (center_x - (final_t_width as f64 / 2.0)) as i32; + let y = (center_y - (final_t_height as f64 / 2.0)) as i32; + + // Clamp to be visible? imageproc handles out of bounds by clipping. + + // 5. 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 = 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 { 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), @@ -57,10 +183,11 @@ fn get_zca_suggestion(path: String) -> Result { let mut min_std_dev = f64::MAX; let mut best_zone = "Center"; - let mut best_pos = (0.5, 0.9); // Default 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; } @@ -73,7 +200,7 @@ fn get_zca_suggestion(path: String) -> Result { let count = luma_values.len() as f64; if count == 0.0 { continue; } - + let mean = luma_values.iter().sum::() / count; let variance = luma_values.iter().map(|v| (v - mean).powi(2)).sum::() / count; let std_dev = variance.sqrt(); @@ -82,8 +209,6 @@ fn get_zca_suggestion(path: String) -> Result { min_std_dev = std_dev; best_zone = name; let center_x_px = *start_x as f64 + (zone_width as f64 / 2.0); - // Position closer to bottom (75% of the zone height instead of 50%) - // Zone starts at 80%. Height is 20%. 0.8 + 0.2 * 0.75 = 0.95 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); } @@ -96,12 +221,17 @@ fn get_zca_suggestion(path: String) -> Result { }) } +#[tauri::command] +fn get_zca_suggestion(path: String) -> Result { + 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]) + .invoke_handler(tauri::generate_handler![scan_dir, get_zca_suggestion, export_batch]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/App.vue b/src/App.vue index e5be7d6..a94649b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,11 +1,15 @@ diff --git a/src/components/HeroView.vue b/src/components/HeroView.vue index d6e9223..58656d9 100644 --- a/src/components/HeroView.vue +++ b/src/components/HeroView.vue @@ -1,40 +1,147 @@ + + diff --git a/src/components/SettingsPanel.vue b/src/components/SettingsPanel.vue new file mode 100644 index 0000000..eba9d0f --- /dev/null +++ b/src/components/SettingsPanel.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/stores/gallery.ts b/src/stores/gallery.ts index 349127b..edc63ec 100644 --- a/src/stores/gallery.ts +++ b/src/stores/gallery.ts @@ -11,9 +11,28 @@ export interface ImageItem { zcaSuggestion?: { x: number; y: number; zone: string }; } +export interface WatermarkSettings { + type: 'text'; // Fixed to text + text: string; + color: string; // Hex + opacity: number; // 0-1 + scale: number; // 0.01 - 0.5 (relative to image height) + manual_override: boolean; + manual_position: { x: number, y: number }; +} + export const useGalleryStore = defineStore("gallery", () => { const images = ref([]); const selectedIndex = ref(-1); + const watermarkSettings = ref({ + type: 'text', + text: 'Watermark', + color: '#FFFFFF', + opacity: 0.8, + scale: 0.05, // 5% of height default + manual_override: false, + manual_position: { x: 0.5, y: 0.9 } + }); const selectedImage = computed(() => { if (selectedIndex.value >= 0 && selectedIndex.value < images.value.length) { @@ -26,6 +45,10 @@ export const useGalleryStore = defineStore("gallery", () => { images.value = newImages; selectedIndex.value = -1; } + + function updateWatermarkSettings(settings: Partial) { + watermarkSettings.value = { ...watermarkSettings.value, ...settings }; + } async function selectImage(index: number) { if (index < 0 || index >= images.value.length) return; @@ -35,8 +58,6 @@ export const useGalleryStore = defineStore("gallery", () => { if (!img.zcaSuggestion) { try { const suggestion = await invoke<{x: number, y: number, zone: string}>("get_zca_suggestion", { path: img.path }); - // Update the item in the array - // Note: Directly modifying the object inside ref array is reactive in Vue 3 img.zcaSuggestion = suggestion; } catch (e) { console.error("ZCA failed", e); @@ -48,7 +69,9 @@ export const useGalleryStore = defineStore("gallery", () => { images, selectedIndex, selectedImage, + watermarkSettings, setImages, selectImage, + updateWatermarkSettings }; });