Compare commits
3 Commits
358aae92dc
...
713f0885dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713f0885dc | ||
|
|
e1f2c8efc8 | ||
|
|
16bb3e5135 |
BIN
public/fonts/Roboto-Regular.ttf
Normal file
BIN
public/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
@@ -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,26 +277,39 @@ 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; }
|
||||
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;
|
||||
@@ -305,28 +318,53 @@ fn calculate_zca_internal(img: &image::DynamicImage) -> Result<ZcaResult, String
|
||||
}
|
||||
|
||||
let count = luma_values.len() as f64;
|
||||
if count == 0.0 { continue; }
|
||||
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();
|
||||
|
||||
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);
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ZcaResult {
|
||||
x: best_pos.0,
|
||||
y: best_pos.1,
|
||||
zone: best_zone.to_string(),
|
||||
})
|
||||
// Analyze the Best Zone in this Row
|
||||
let (mean, std_dev) = row_stats[row_best_idx];
|
||||
|
||||
// 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 !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(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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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"
|
||||
@@ -65,7 +89,8 @@ const applyAll = () => {
|
||||
<!-- 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">
|
||||
<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"
|
||||
@@ -74,6 +99,14 @@ const applyAll = () => {
|
||||
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 -->
|
||||
@@ -124,7 +157,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 +168,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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('/fonts/Roboto-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
Reference in New Issue
Block a user