diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c99c561..977af33 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -335,11 +335,125 @@ fn get_zca_suggestion(path: String) -> Result { calculate_zca_internal(&img) } +#[derive(serde::Serialize)] +struct DetectionResult { + rects: Vec, +} + +#[derive(serde::Serialize, Clone)] +struct Rect { + x: f64, + y: f64, + width: f64, + height: f64, +} + +#[tauri::command] +async fn detect_watermark(path: String) -> Result { + let img = image::open(&path).map_err(|e| e.to_string())?; + let (width, height) = img.dimensions(); + let gray = img.to_luma8(); + + // Heuristic: + // 1. Scan Top 20% and Bottom 20% + // 2. Look for high brightness pixels (> 230) + // 3. Grid based clustering + + let cell_size = 10; + let grid_w = (width + cell_size - 1) / cell_size; + let grid_h = (height + cell_size - 1) / cell_size; + let mut grid = vec![false; (grid_w * grid_h) as usize]; + + let top_limit = (height as f64 * 0.2) as u32; + let bottom_start = (height as f64 * 0.8) as u32; + + for y in 0..height { + // Skip middle section + if y > top_limit && y < bottom_start { + continue; + } + + for x in 0..width { + let p = gray.get_pixel(x, y); + if p[0] > 230 { // High brightness threshold + // Check local contrast/edges? + // For now, simple brightness is a good proxy for "white text" + // Mark grid cell + let gx = x / cell_size; + let gy = y / cell_size; + grid[(gy * grid_w + gx) as usize] = true; + } + } + } + + // Connected Components on Grid (Simple merging) + let mut rects = Vec::new(); + let mut visited = vec![false; grid.len()]; + + for gy in 0..grid_h { + for gx in 0..grid_w { + let idx = (gy * grid_w + gx) as usize; + if grid[idx] && !visited[idx] { + // Start a new component + // Simple Flood Fill or just greedy expansion + // Let's do a simple greedy expansion for rectangles + + let mut min_gx = gx; + let mut max_gx = gx; + let mut min_gy = gy; + let mut max_gy = gy; + + let mut stack = vec![(gx, gy)]; + visited[idx] = true; + + while let Some((cx, cy)) = stack.pop() { + if cx < min_gx { min_gx = cx; } + if cx > max_gx { max_gx = cx; } + if cy < min_gy { min_gy = cy; } + if cy > max_gy { max_gy = cy; } + + // Neighbors + let neighbors = [ + (cx.wrapping_sub(1), cy), (cx + 1, cy), + (cx, cy.wrapping_sub(1)), (cx, cy + 1) + ]; + + for (nx, ny) in neighbors { + if nx < grid_w && ny < grid_h { + let nidx = (ny * grid_w + nx) as usize; + if grid[nidx] && !visited[nidx] { + visited[nidx] = true; + stack.push((nx, ny)); + } + } + } + } + + // Convert grid rect to normalized image rect + // Add padding (1 cell) + let px = (min_gx * cell_size) as f64; + let py = (min_gy * cell_size) as f64; + let pw = ((max_gx - min_gx + 1) * cell_size) as f64; + let ph = ((max_gy - min_gy + 1) * cell_size) as f64; + + rects.push(Rect { + x: px / width as f64, + y: py / height as f64, + width: pw / width as f64, + height: ph / height as f64, + }); + } + } + } + + Ok(DetectionResult { rects }) +} + #[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]) + .invoke_handler(tauri::generate_handler![scan_dir, get_zca_suggestion, export_batch, detect_watermark]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/components/HeroView.vue b/src/components/HeroView.vue index 8896cc1..c745d5b 100644 --- a/src/components/HeroView.vue +++ b/src/components/HeroView.vue @@ -31,9 +31,6 @@ const calculateLayout = () => { (parentW - 64) / natW, // 64px = 2rem padding * 2 sides (p-8) (parentH - 64) / natH ); - - // Prevent scaling up if image is smaller than screen? - // Usually hero view scales up to fit. Let's stick to contain logic (can scale up). const finalW = Math.floor(natW * scale); const finalH = Math.floor(natH * scale); @@ -77,15 +74,80 @@ const effectiveScale = computed(() => store.selectedImage?.scale ?? store.waterm const effectiveOpacity = computed(() => store.selectedImage?.opacity ?? store.watermarkSettings.opacity); const effectiveColor = computed(() => store.selectedImage?.color ?? store.watermarkSettings.color); +const canvasRef = ref(null); +const isDrawing = ref(false); +const currentPath = ref<{x: number, y: number}[]>([]); + +// Redraw when strokes change or layout changes +watch( + () => store.selectedImage?.maskStrokes, + () => { + nextTick(redrawCanvas); + }, + { deep: true } +); + +watch(imageRect, () => { + nextTick(redrawCanvas); +}); + +const redrawCanvas = () => { + if (!canvasRef.value || !store.selectedImage || imageRect.value.width === 0) return; + const ctx = canvasRef.value.getContext('2d'); + if (!ctx) return; + + const w = imageRect.value.width; + const h = imageRect.value.height; + + ctx.clearRect(0, 0, w, h); + + if (!store.selectedImage.maskStrokes) return; + + ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; + ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)'; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + store.selectedImage.maskStrokes.forEach(stroke => { + if (stroke.type === 'rect' && stroke.rect) { + ctx.fillRect( + stroke.rect.x * w, + stroke.rect.y * h, + stroke.rect.w * w, + stroke.rect.h * h + ); + } else if (stroke.type === 'path' && stroke.points) { + const lw = (stroke.width || (store.brushSettings.size / w)) * w; + ctx.lineWidth = lw; + + if (stroke.points.length > 0) { + ctx.beginPath(); + ctx.moveTo(stroke.points[0].x * w, stroke.points[0].y * h); + for (let i = 1; i < stroke.points.length; i++) { + ctx.lineTo(stroke.points[i].x * w, stroke.points[i].y * h); + } + ctx.stroke(); + } + } + }); +}; + const onMouseDown = (e: MouseEvent) => { + if (store.editMode === 'remove') { + startDrawing(e); + return; + } e.preventDefault(); isDragging.value = true; dragStart.value = { x: e.clientX, y: e.clientY }; }; const onMouseMove = (e: MouseEvent) => { - // Need exact dimensions for drag calculation - // Use imageRect for calculation as it matches the render size + if (store.editMode === 'remove') { + draw(e); + return; + } + if (!isDragging.value || !store.selectedImage || imageRect.value.width === 0) return; const rect = imageRect.value; @@ -111,10 +173,59 @@ const onMouseMove = (e: MouseEvent) => { const onMouseUp = () => { isDragging.value = false; + stopDrawing(); }; const onMouseLeave = () => { isDragging.value = false; + stopDrawing(); +}; + +// --- Drawing Logic --- +const startDrawing = (e: MouseEvent) => { + if (!canvasRef.value) return; + isDrawing.value = true; + currentPath.value = []; + draw(e); +}; + +const draw = (e: MouseEvent) => { + if (!isDrawing.value || !canvasRef.value) return; + const ctx = canvasRef.value.getContext('2d'); + if (!ctx) return; + + const rect = canvasRef.value.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Normalize + const nx = x / imageRect.value.width; + const ny = y / imageRect.value.height; + + currentPath.value.push({ x: nx, y: ny }); + + // Live feedback (Paint on top of existing) + ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; + ctx.beginPath(); + ctx.arc(x, y, store.brushSettings.size / 2, 0, Math.PI * 2); + ctx.fill(); + + // Connect dots for smoothness if we have enough points + // (Optional optimization: draw line from last point) +}; + +const stopDrawing = () => { + if (!isDrawing.value) return; + isDrawing.value = false; + + if (currentPath.value.length > 0 && store.selectedIndex >= 0) { + const normWidth = store.brushSettings.size / imageRect.value.width; + store.addMaskStroke(store.selectedIndex, { + type: 'path', + points: [...currentPath.value], + width: normWidth + }); + } }; @@ -151,9 +262,9 @@ const onMouseLeave = () => { @error="(e) => console.error('Hero Image Load Error:', e)" /> - +
@@ -177,6 +289,16 @@ const onMouseLeave = () => {
+ + +

No image selected

diff --git a/src/components/SettingsPanel.vue b/src/components/SettingsPanel.vue index d768d75..381dd43 100644 --- a/src/components/SettingsPanel.vue +++ b/src/components/SettingsPanel.vue @@ -1,6 +1,6 @@ diff --git a/src/stores/gallery.ts b/src/stores/gallery.ts index 272d691..38313f3 100644 --- a/src/stores/gallery.ts +++ b/src/stores/gallery.ts @@ -2,6 +2,13 @@ import { defineStore } from "pinia"; import { ref, computed } from "vue"; import { invoke } from "@tauri-apps/api/core"; +export interface MaskStroke { + type: 'path' | 'rect'; + points?: {x: number, y: number}[]; // Normalized + rect?: {x: number, y: number, w: number, h: number}; // Normalized + width?: number; // Normalized brush width for paths +} + export interface ImageItem { path: string; thumbnail: string; @@ -13,6 +20,7 @@ export interface ImageItem { scale?: number; opacity?: number; color?: string; + maskStrokes?: MaskStroke[]; } export interface WatermarkSettings { @@ -26,6 +34,10 @@ export interface WatermarkSettings { export const useGalleryStore = defineStore("gallery", () => { const images = ref([]); const selectedIndex = ref(-1); + + // 'add' = Add Watermark, 'remove' = Remove Watermark (Inpainting) + const editMode = ref<'add' | 'remove'>('add'); + const watermarkSettings = ref({ type: 'text', text: 'Watermark', @@ -33,6 +45,11 @@ export const useGalleryStore = defineStore("gallery", () => { opacity: 1.0, scale: 0.03, }); + + const brushSettings = ref({ + size: 20, // screen pixels + opacity: 0.5 // visual only + }); const selectedImage = computed(() => { if (selectedIndex.value >= 0 && selectedIndex.value < images.value.length) { @@ -63,6 +80,48 @@ export const useGalleryStore = defineStore("gallery", () => { } } + function addMaskStroke(index: number, stroke: MaskStroke) { + if (images.value[index]) { + if (!images.value[index].maskStrokes) { + images.value[index].maskStrokes = []; + } + images.value[index].maskStrokes!.push(stroke); + } + } + + function clearMask(index: number) { + if (images.value[index]) { + images.value[index].maskStrokes = []; + } + } + + async function detectWatermark(index: number) { + const img = images.value[index]; + if (!img) return; + + try { + // @ts-ignore + const result = await invoke<{rects: {x: number, y: number, width: number, height: number}[]}>("detect_watermark", { path: img.path }); + + if (result.rects && result.rects.length > 0) { + // Append to existing strokes? Or replace? + // User said "first automatically detect... but allow manual paint". + // Usually detection is a starting point. Let's append. + // (If user wants to restart, they can Clear first). + if (!img.maskStrokes) img.maskStrokes = []; + + result.rects.forEach(r => { + img.maskStrokes!.push({ + type: 'rect', + rect: { x: r.x, y: r.y, w: r.width, h: r.height } + }); + }); + } + } catch (e) { + console.error("Detection failed", e); + } + } + // Applies the settings from the CURRENT image (or global if not overridden) to ALL images // Strategy: Update Global Settings to match current view, and clear individual overrides so everyone follows global. function applySettingsToAll() { @@ -105,12 +164,17 @@ export const useGalleryStore = defineStore("gallery", () => { images, selectedIndex, selectedImage, + editMode, watermarkSettings, + brushSettings, setImages, selectImage, updateWatermarkSettings, setImageManualPosition, setImageSetting, - applySettingsToAll + applySettingsToAll, + addMaskStroke, + clearMask, + detectWatermark }; });