Compare commits

..

3 Commits

Author SHA1 Message Date
Julian Freeman
713f0885dc fix apply 2026-01-19 08:58:45 -04:00
Julian Freeman
e1f2c8efc8 apply custom text 2026-01-19 08:41:45 -04:00
Julian Freeman
16bb3e5135 remove 2026-01-19 07:48:32 -04:00
6 changed files with 652 additions and 139 deletions

Binary file not shown.

View File

@@ -170,8 +170,8 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
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;
// 3. Ensure it fits width (Padding 15%)
let max_width = (width as f32 * 0.85) as u32;
if t_width > max_width {
let ratio = max_width as f32 / t_width as f32;
scale_px *= ratio;
@@ -277,56 +277,94 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
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;
// Greedy Layered Search
// Priority: Bottom -> Up
let y_levels = [0.97, 0.94, 0.91, 0.88];
let x_cols = [1.0/6.0, 3.0/6.0, 5.0/6.0]; // Left, Center, Right centers
let col_names = ["Left", "Center", "Right"];
let zones = [
("Left", 0, bottom_start_y),
("Center", zone_width, bottom_start_y),
("Right", zone_width * 2, bottom_start_y),
];
// Box Size for analysis (approx watermark size)
let box_w = (width as f64 * 0.30) as u32;
let box_h = (height as f64 * 0.05) as u32;
let half_box_w = box_w / 2;
let half_box_h = box_h / 2;
let mut min_std_dev = f64::MAX;
let mut best_zone = "Center";
let mut best_pos = (0.5, 0.97);
let mut global_best_score = f64::MAX;
let mut global_best_result = ZcaResult { x: 0.5, y: 0.97, zone: "Center".to_string() };
for (name, start_x, start_y) in zones.iter() {
let mut luma_values = Vec::with_capacity((zone_width * zone_height) as usize);
for &y_pct in y_levels.iter() {
let mut row_best_score = f64::MAX;
let mut row_best_idx = 1; // Default Center
let mut row_stats = Vec::new(); // (mean, std_dev)
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);
for (col_idx, &x_pct) in x_cols.iter().enumerate() {
let cx = (width as f64 * x_pct) as u32;
let cy = (height as f64 * y_pct) as u32;
let start_x = if cx > half_box_w { cx - half_box_w } else { 0 };
let start_y = if cy > half_box_h { cy - half_box_h } else { 0 };
let end_x = (start_x + box_w).min(width);
let end_y = (start_y + box_h).min(height);
let mut luma_values = Vec::with_capacity((box_w * box_h) as usize);
for y in start_y..end_y {
for x in start_x..end_x {
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 {
row_stats.push((0.0, f64::MAX));
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();
row_stats.push((mean, std_dev));
// For choosing "Best in Row", we strictly prefer Flatness (StdDev)
if std_dev < row_best_score {
row_best_score = std_dev;
row_best_idx = col_idx;
}
// Update Global Best (fallback)
if std_dev < global_best_score {
global_best_score = std_dev;
global_best_result = ZcaResult {
x: x_pct,
y: y_pct,
zone: col_names[col_idx].to_string(),
};
}
}
let count = luma_values.len() as f64;
if count == 0.0 { continue; }
// Analyze the Best Zone in this Row
let (mean, std_dev) = row_stats[row_best_idx];
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();
// Safety Check: Is this zone "White Text"?
// Condition: Mean > 180 (Bright-ish) AND StdDev > 20 (Busy/Text)
let is_unsafe_white_text = mean > 180.0 && std_dev > 20.0;
let is_unsafe_bright = mean > 230.0;
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);
// Position closer to bottom
// 0.8 + 0.2 * 0.85 = 0.97
let center_y_px = *start_y as f64 + (zone_height as f64 * 0.85);
best_pos = (center_x_px / width as f64, center_y_px / height as f64);
if !is_unsafe_white_text && !is_unsafe_bright {
// Safe!
return Ok(ZcaResult {
x: x_cols[row_best_idx],
y: y_pct,
zone: col_names[row_best_idx].to_string(),
});
}
}
Ok(ZcaResult {
x: best_pos.0,
y: best_pos.1,
zone: best_zone.to_string(),
})
Ok(global_best_result)
}
#[tauri::command]
@@ -335,11 +373,188 @@ fn get_zca_suggestion(path: String) -> Result<ZcaResult, String> {
calculate_zca_internal(&img)
}
#[derive(serde::Serialize)]
struct LayoutResult {
x: f64,
y: f64,
scale: f64,
}
#[tauri::command]
async fn layout_watermark(path: String, text: String, base_scale: f64) -> Result<LayoutResult, String> {
let img = image::open(&path).map_err(|e| e.to_string())?;
let (width, height) = img.dimensions();
let font = FontRef::try_from_slice(FONT_DATA).map_err(|e| format!("Font error: {}", e))?;
// 1. Run ZCA to find best zone center (now with dark preference)
let zca = calculate_zca_internal(&img)?;
// 2. Calculate Text Dimensions at Base Scale
let mut scale_val = base_scale;
let mut scale_px = height as f32 * scale_val as f32;
let mut font_scale = PxScale::from(scale_px);
let (mut t_width, mut t_height) = imageproc::drawing::text_size(font_scale, &font, &text);
// 3. Auto-Fit Width (Limit to 85% of image width)
let max_width = (width as f32 * 0.85) as u32;
if t_width > max_width {
let ratio = max_width as f32 / t_width as f32;
scale_val *= ratio as f64;
scale_px *= ratio;
font_scale = PxScale::from(scale_px);
let dims = imageproc::drawing::text_size(font_scale, &font, &text);
t_width = dims.0;
t_height = dims.1;
}
// 4. Smart Clamping
let center_x = zca.x * width as f64;
let center_y = zca.y * height as f64;
// Add safety margin to measured text size (Renderer mismatch buffer)
let safe_t_width = t_width as f64 * 1.05;
let safe_t_height = t_height as f64 * 1.05;
let half_w = safe_t_width / 2.0;
let half_h = safe_t_height / 2.0;
// Increase edge padding to 4%
let padding = width as f64 * 0.04;
let min_x = half_w + padding;
let max_x = width as f64 - half_w - padding;
let final_x = center_x.clamp(min_x, max_x);
let min_y = half_h + padding;
let max_y = height as f64 - half_h - padding;
let final_y = center_y.clamp(min_y, max_y);
Ok(LayoutResult {
x: final_x / width as f64,
y: final_y / height as f64,
scale: scale_val,
})
}
#[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, layout_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,10 +262,10 @@ 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"
class="absolute cursor-move select-none whitespace-nowrap font-sans font-medium"
v-if="store.editMode === 'add' && store.watermarkSettings.text"
class="absolute cursor-move select-none whitespace-nowrap font-medium"
:style="{
left: (position.x * 100) + '%',
top: (position.y * 100) + '%',
@@ -163,10 +274,12 @@ const onMouseLeave = () => {
color: effectiveColor,
/* Scale based on HEIGHT of the IMAGE */
fontSize: (imageRect.height * effectiveScale) + 'px',
fontFamily: 'Roboto, sans-serif',
height: '0px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
zIndex: 10
}"
@mousedown="onMouseDown"
>
@@ -177,6 +290,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, RotateCw } from 'lucide-vue-next';
import { computed } from "vue";
const store = useGalleryStore();
@@ -47,101 +47,180 @@ 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">
<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
</div>
<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="applyAll"
title="Apply Settings to All Images"
class="bg-gray-700 hover:bg-gray-600 p-1.5 rounded text-xs text-blue-300 transition-colors flex items-center gap-1"
@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'"
>
<Copy class="w-3 h-3" /> All
<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>
</h2>
<!-- Text Input -->
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Content</label>
<div class="relative">
<Type class="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
<input
type="text"
v-model="store.watermarkSettings.text"
class="w-full bg-gray-700 text-white pl-10 pr-3 py-2 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Enter text..."
/>
</div>
</div>
<!-- Color Picker -->
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Color</label>
<div class="flex items-center gap-2 bg-gray-700 p-2 rounded border border-gray-600">
<Palette class="w-4 h-4 text-gray-400" />
<input
type="color"
v-model="currentColor"
class="w-8 h-8 rounded cursor-pointer bg-transparent border-none p-0"
/>
<span class="text-xs text-gray-300 font-mono">{{ currentColor }}</span>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 overflow-y-auto p-4 flex flex-col gap-6">
<!-- Controls -->
<div class="space-y-4">
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-400">Size (Scale)</label>
<span class="text-xs text-gray-300">{{ (currentScale * 100).toFixed(1) }}%</span>
</div>
<input
type="range"
min="0.01"
max="0.20"
step="0.001"
v-model.number="currentScale"
class="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<p class="text-[10px] text-gray-500 mt-1">Relative to image height</p>
<!-- 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" />
Watermark
</div>
<button
@click="applyAll"
title="Apply Settings to All Images"
class="bg-gray-700 hover:bg-gray-600 p-1.5 rounded text-xs text-blue-300 transition-colors flex items-center gap-1"
>
<Copy class="w-3 h-3" /> All
</button>
</h2>
<!-- Text Input -->
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Content</label>
<div class="flex gap-2">
<div class="relative flex-1">
<Type class="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
<input
type="text"
v-model="store.watermarkSettings.text"
class="w-full bg-gray-700 text-white pl-10 pr-3 py-2 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Enter text..."
/>
</div>
<button
@click="store.recalcAllWatermarks()"
class="bg-blue-600 hover:bg-blue-500 text-white p-2 rounded flex items-center justify-center transition-colors"
title="Apply & Recalculate Layout for ALL Images"
>
<RotateCw class="w-4 h-4" />
</button>
</div>
</div>
<!-- Color Picker -->
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Color</label>
<div class="flex items-center gap-2 bg-gray-700 p-2 rounded border border-gray-600">
<Palette class="w-4 h-4 text-gray-400" />
<input
type="color"
v-model="currentColor"
class="w-8 h-8 rounded cursor-pointer bg-transparent border-none p-0"
/>
<span class="text-xs text-gray-300 font-mono">{{ currentColor }}</span>
</div>
</div>
<!-- Controls -->
<div class="space-y-4">
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-400">Size (Scale)</label>
<span class="text-xs text-gray-300">{{ (currentScale * 100).toFixed(1) }}%</span>
</div>
<input
type="range"
min="0.01"
max="0.20"
step="0.001"
v-model.number="currentScale"
class="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<p class="text-[10px] text-gray-500 mt-1">Relative to image height</p>
</div>
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-400">Opacity</label>
<span class="text-xs text-gray-300">{{ (currentOpacity * 100).toFixed(0) }}%</span>
</div>
<input
type="range"
min="0.1"
max="1.0"
step="0.01"
v-model.number="currentOpacity"
class="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
<!-- 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">
<div class="p-1 rounded bg-green-500/20 text-green-400">
<CheckSquare class="w-4 h-4" v-if="!store.selectedImage?.manualPosition" />
<div class="w-4 h-4" v-else></div>
</div>
<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>
</div>
</div>
</div>
</div>
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-400">Opacity</label>
<span class="text-xs text-gray-300">{{ (currentOpacity * 100).toFixed(0) }}%</span>
</div>
<input
type="range"
min="0.1"
max="1.0"
step="0.01"
v-model.number="currentOpacity"
class="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</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>
<!-- Placement Mode -->
<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">
<div class="p-1 rounded bg-green-500/20 text-green-400">
<CheckSquare class="w-4 h-4" v-if="!store.selectedImage?.manualPosition" />
<div class="w-4 h-4" v-else></div>
</div>
<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>
<!-- 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>
<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>
</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,72 @@ 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) {
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);
}
}
async function recalcAllWatermarks() {
if (images.value.length === 0) return;
const text = watermarkSettings.value.text;
const baseScale = watermarkSettings.value.scale;
// Process in batches to avoid overwhelming the backend
const batchSize = 5;
for (let i = 0; i < images.value.length; i += batchSize) {
const batch = images.value.slice(i, i + batchSize).map(async (img, batchIdx) => {
const globalIdx = i + batchIdx;
try {
const result = await invoke<{x: number, y: number, scale: number}>("layout_watermark", {
path: img.path,
text: text,
baseScale: baseScale
});
setImageManualPosition(globalIdx, result.x, result.y);
setImageSetting(globalIdx, 'scale', result.scale);
} catch (e) {
console.error(`Layout failed for ${img.name}`, e);
}
});
await Promise.all(batch);
}
}
// 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 +188,18 @@ export const useGalleryStore = defineStore("gallery", () => {
images,
selectedIndex,
selectedImage,
editMode,
watermarkSettings,
brushSettings,
setImages,
selectImage,
updateWatermarkSettings,
setImageManualPosition,
setImageSetting,
applySettingsToAll
applySettingsToAll,
addMaskStroke,
clearMask,
detectWatermark,
recalcAllWatermarks
};
});

View File

@@ -1 +1,8 @@
@import "tailwindcss";
@font-face {
font-family: 'Roboto';
src: url('/fonts/Roboto-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}