This commit is contained in:
Julian Freeman
2026-01-19 07:48:32 -04:00
parent 358aae92dc
commit 16bb3e5135
4 changed files with 466 additions and 96 deletions

View File

@@ -335,11 +335,125 @@ fn get_zca_suggestion(path: String) -> Result<ZcaResult, String> {
calculate_zca_internal(&img)
}
#[derive(serde::Serialize)]
struct DetectionResult {
rects: Vec<Rect>,
}
#[derive(serde::Serialize, Clone)]
struct Rect {
x: f64,
y: f64,
width: f64,
height: f64,
}
#[tauri::command]
async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
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");
}

View File

@@ -32,9 +32,6 @@ const calculateLayout = () => {
(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<HTMLCanvasElement | null>(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
});
}
};
</script>
@@ -151,9 +262,9 @@ const onMouseLeave = () => {
@error="(e) => console.error('Hero Image Load Error:', e)"
/>
<!-- Text Watermark Overlay -->
<!-- Text Watermark Overlay (Only in Add Mode) -->
<div
v-if="store.watermarkSettings.text"
v-if="store.editMode === 'add' && store.watermarkSettings.text"
class="absolute cursor-move select-none whitespace-nowrap font-sans font-medium"
:style="{
left: (position.x * 100) + '%',
@@ -166,7 +277,8 @@ const onMouseLeave = () => {
height: '0px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
zIndex: 10
}"
@mousedown="onMouseDown"
>
@@ -177,6 +289,16 @@ const onMouseLeave = () => {
<!-- Selection Ring when dragging -->
<div v-if="isDragging" class="absolute -inset-2 border border-blue-500 rounded-sm"></div>
</div>
<!-- Canvas Overlay (Only in Remove Mode) -->
<canvas
v-show="store.editMode === 'remove'"
ref="canvasRef"
:width="imageRect.width"
:height="imageRect.height"
class="absolute inset-0 z-20 cursor-crosshair touch-none"
@mousedown="onMouseDown"
></canvas>
</div>
<div v-else class="text-gray-500 flex flex-col items-center">
<p>No image selected</p>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useGalleryStore } from "../stores/gallery";
import { Settings, CheckSquare, Type, Palette, Copy } from 'lucide-vue-next';
import { Settings, CheckSquare, Type, Palette, Copy, Eraser, PlusSquare, Brush, Sparkles, Trash2 } from 'lucide-vue-next';
import { computed } from "vue";
const store = useGalleryStore();
@@ -47,11 +47,35 @@ const applyAll = () => {
</script>
<template>
<div class="h-full bg-gray-800 text-white p-4 flex flex-col gap-6 overflow-y-auto border-l border-gray-700 w-80">
<div class="h-full bg-gray-800 text-white flex flex-col w-80 border-l border-gray-700">
<!-- Mode Switcher Tabs -->
<div class="flex border-b border-gray-700">
<button
@click="store.editMode = 'add'"
class="flex-1 py-3 text-sm font-medium flex items-center justify-center gap-2 transition-colors"
:class="store.editMode === 'add' ? 'bg-gray-700 text-blue-400 border-b-2 border-blue-400' : 'text-gray-400 hover:bg-gray-750'"
>
<PlusSquare class="w-4 h-4" /> Add
</button>
<button
@click="store.editMode = 'remove'"
class="flex-1 py-3 text-sm font-medium flex items-center justify-center gap-2 transition-colors"
:class="store.editMode === 'remove' ? 'bg-gray-700 text-red-400 border-b-2 border-red-400' : 'text-gray-400 hover:bg-gray-750'"
>
<Eraser class="w-4 h-4" /> Remove
</button>
</div>
<!-- Content Area -->
<div class="flex-1 overflow-y-auto p-4 flex flex-col gap-6">
<!-- ADD MODE SETTINGS -->
<div v-if="store.editMode === 'add'" class="flex flex-col gap-6">
<h2 class="text-lg font-bold flex items-center justify-between">
<div class="flex items-center gap-2">
<Settings class="w-5 h-5" />
Settings
Watermark
</div>
<button
@click="applyAll"
@@ -124,7 +148,7 @@ const applyAll = () => {
</div>
</div>
<!-- Placement Mode -->
<!-- Placement Info -->
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Placement Status</label>
<div class="flex items-center gap-2 p-2 rounded bg-gray-700/50 border border-gray-600">
@@ -135,13 +159,59 @@ const applyAll = () => {
<div class="flex-1">
<p class="text-sm font-medium text-gray-200" v-if="!store.selectedImage?.manualPosition">Auto (ZCA)</p>
<p class="text-sm font-medium text-blue-300" v-else>Manual Override</p>
<p class="text-xs text-gray-500" v-if="!store.selectedImage?.manualPosition">Using smart algorithm</p>
<p class="text-xs text-gray-500" v-else>Specific position set</p>
</div>
</div>
<p class="text-xs text-gray-500 mt-1 italic">
* Drag the watermark on the image to set a manual position for that specific image.
</p>
</div>
</div>
<!-- REMOVE MODE SETTINGS -->
<div v-else class="flex flex-col gap-6">
<h2 class="text-lg font-bold flex items-center gap-2 text-red-400">
<Brush class="w-5 h-5" />
Magic Eraser
</h2>
<!-- Auto Detect Controls -->
<div class="flex gap-2">
<button
@click="store.selectedIndex >= 0 && store.detectWatermark(store.selectedIndex)"
class="flex-1 bg-blue-600 hover:bg-blue-500 text-white py-2 rounded flex items-center justify-center gap-2 text-sm transition-colors"
:disabled="store.selectedIndex < 0"
>
<Sparkles class="w-4 h-4" /> Auto Detect
</button>
<button
@click="store.selectedIndex >= 0 && store.clearMask(store.selectedIndex)"
class="bg-gray-700 hover:bg-gray-600 text-gray-300 px-3 rounded flex items-center justify-center transition-colors"
title="Clear Mask"
:disabled="store.selectedIndex < 0"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
<p class="text-xs text-gray-400">Paint over the watermark you want to remove. The AI will fill in the background.</p>
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-400">Brush Size</label>
<span class="text-xs text-gray-300">{{ store.brushSettings.size }}px</span>
</div>
<input
type="range"
min="5"
max="100"
step="1"
v-model.number="store.brushSettings.size"
class="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-red-500"
/>
</div>
<div class="p-3 bg-red-900/20 border border-red-900/50 rounded text-xs text-red-200">
AI Inpainting is not yet connected. Masking preview only.
</div>
</div>
</div>
</div>
</template>

View File

@@ -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<ImageItem[]>([]);
const selectedIndex = ref<number>(-1);
// 'add' = Add Watermark, 'remove' = Remove Watermark (Inpainting)
const editMode = ref<'add' | 'remove'>('add');
const watermarkSettings = ref<WatermarkSettings>({
type: 'text',
text: 'Watermark',
@@ -34,6 +46,11 @@ export const useGalleryStore = defineStore("gallery", () => {
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) {
return images.value[selectedIndex.value];
@@ -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
};
});