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 scaled_font = PxScale::from(scale_px);
|
||||||
let (t_width, _t_height) = imageproc::drawing::text_size(scaled_font, &font, &watermark.text);
|
let (t_width, _t_height) = imageproc::drawing::text_size(scaled_font, &font, &watermark.text);
|
||||||
|
|
||||||
// 3. Ensure it fits width (Padding 5%)
|
// 3. Ensure it fits width (Padding 15%)
|
||||||
let max_width = (width as f32 * 0.95) as u32;
|
let max_width = (width as f32 * 0.85) as u32;
|
||||||
if t_width > max_width {
|
if t_width > max_width {
|
||||||
let ratio = max_width as f32 / t_width as f32;
|
let ratio = max_width as f32 / t_width as f32;
|
||||||
scale_px *= ratio;
|
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> {
|
fn calculate_zca_internal(img: &image::DynamicImage) -> Result<ZcaResult, String> {
|
||||||
let (width, height) = img.dimensions();
|
let (width, height) = img.dimensions();
|
||||||
|
|
||||||
let bottom_start_y = (height as f64 * 0.8) as u32;
|
// Greedy Layered Search
|
||||||
let zone_height = height - bottom_start_y;
|
// Priority: Bottom -> Up
|
||||||
let zone_width = width / 3;
|
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 = [
|
// Box Size for analysis (approx watermark size)
|
||||||
("Left", 0, bottom_start_y),
|
let box_w = (width as f64 * 0.30) as u32;
|
||||||
("Center", zone_width, bottom_start_y),
|
let box_h = (height as f64 * 0.05) as u32;
|
||||||
("Right", zone_width * 2, bottom_start_y),
|
let half_box_w = box_w / 2;
|
||||||
];
|
let half_box_h = box_h / 2;
|
||||||
|
|
||||||
let mut min_std_dev = f64::MAX;
|
let mut global_best_score = f64::MAX;
|
||||||
let mut best_zone = "Center";
|
let mut global_best_result = ZcaResult { x: 0.5, y: 0.97, zone: "Center".to_string() };
|
||||||
let mut best_pos = (0.5, 0.97);
|
|
||||||
|
|
||||||
for (name, start_x, start_y) in zones.iter() {
|
for &y_pct in y_levels.iter() {
|
||||||
let mut luma_values = Vec::with_capacity((zone_width * zone_height) as usize);
|
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 (col_idx, &x_pct) in x_cols.iter().enumerate() {
|
||||||
for x in *start_x..(*start_x + zone_width) {
|
let cx = (width as f64 * x_pct) as u32;
|
||||||
if x >= width { continue; }
|
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 pixel = img.get_pixel(x, y);
|
||||||
let rgb = pixel.to_rgb();
|
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;
|
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;
|
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 mean = luma_values.iter().sum::<f64>() / count;
|
||||||
let variance = luma_values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / count;
|
let variance = luma_values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / count;
|
||||||
let std_dev = variance.sqrt();
|
let std_dev = variance.sqrt();
|
||||||
|
|
||||||
if std_dev < min_std_dev {
|
row_stats.push((mean, std_dev));
|
||||||
min_std_dev = std_dev;
|
|
||||||
best_zone = name;
|
// For choosing "Best in Row", we strictly prefer Flatness (StdDev)
|
||||||
let center_x_px = *start_x as f64 + (zone_width as f64 / 2.0);
|
if std_dev < row_best_score {
|
||||||
// Position closer to bottom
|
row_best_score = std_dev;
|
||||||
// 0.8 + 0.2 * 0.85 = 0.97
|
row_best_idx = col_idx;
|
||||||
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);
|
|
||||||
|
// 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 {
|
// Analyze the Best Zone in this Row
|
||||||
x: best_pos.0,
|
let (mean, std_dev) = row_stats[row_best_idx];
|
||||||
y: best_pos.1,
|
|
||||||
zone: best_zone.to_string(),
|
// 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]
|
#[tauri::command]
|
||||||
@@ -335,11 +373,188 @@ fn get_zca_suggestion(path: String) -> Result<ZcaResult, String> {
|
|||||||
calculate_zca_internal(&img)
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ const calculateLayout = () => {
|
|||||||
(parentH - 64) / natH
|
(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 finalW = Math.floor(natW * scale);
|
||||||
const finalH = Math.floor(natH * 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 effectiveOpacity = computed(() => store.selectedImage?.opacity ?? store.watermarkSettings.opacity);
|
||||||
const effectiveColor = computed(() => store.selectedImage?.color ?? store.watermarkSettings.color);
|
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) => {
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
|
if (store.editMode === 'remove') {
|
||||||
|
startDrawing(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDragging.value = true;
|
isDragging.value = true;
|
||||||
dragStart.value = { x: e.clientX, y: e.clientY };
|
dragStart.value = { x: e.clientX, y: e.clientY };
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
// Need exact dimensions for drag calculation
|
if (store.editMode === 'remove') {
|
||||||
// Use imageRect for calculation as it matches the render size
|
draw(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isDragging.value || !store.selectedImage || imageRect.value.width === 0) return;
|
if (!isDragging.value || !store.selectedImage || imageRect.value.width === 0) return;
|
||||||
|
|
||||||
const rect = imageRect.value;
|
const rect = imageRect.value;
|
||||||
@@ -111,10 +173,59 @@ const onMouseMove = (e: MouseEvent) => {
|
|||||||
|
|
||||||
const onMouseUp = () => {
|
const onMouseUp = () => {
|
||||||
isDragging.value = false;
|
isDragging.value = false;
|
||||||
|
stopDrawing();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
isDragging.value = false;
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -151,10 +262,10 @@ const onMouseLeave = () => {
|
|||||||
@error="(e) => console.error('Hero Image Load Error:', e)"
|
@error="(e) => console.error('Hero Image Load Error:', e)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Text Watermark Overlay -->
|
<!-- Text Watermark Overlay (Only in Add Mode) -->
|
||||||
<div
|
<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"
|
class="absolute cursor-move select-none whitespace-nowrap font-medium"
|
||||||
:style="{
|
:style="{
|
||||||
left: (position.x * 100) + '%',
|
left: (position.x * 100) + '%',
|
||||||
top: (position.y * 100) + '%',
|
top: (position.y * 100) + '%',
|
||||||
@@ -163,10 +274,12 @@ const onMouseLeave = () => {
|
|||||||
color: effectiveColor,
|
color: effectiveColor,
|
||||||
/* Scale based on HEIGHT of the IMAGE */
|
/* Scale based on HEIGHT of the IMAGE */
|
||||||
fontSize: (imageRect.height * effectiveScale) + 'px',
|
fontSize: (imageRect.height * effectiveScale) + 'px',
|
||||||
|
fontFamily: 'Roboto, sans-serif',
|
||||||
height: '0px',
|
height: '0px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center'
|
justifyContent: 'center',
|
||||||
|
zIndex: 10
|
||||||
}"
|
}"
|
||||||
@mousedown="onMouseDown"
|
@mousedown="onMouseDown"
|
||||||
>
|
>
|
||||||
@@ -177,6 +290,16 @@ const onMouseLeave = () => {
|
|||||||
<!-- Selection Ring when dragging -->
|
<!-- Selection Ring when dragging -->
|
||||||
<div v-if="isDragging" class="absolute -inset-2 border border-blue-500 rounded-sm"></div>
|
<div v-if="isDragging" class="absolute -inset-2 border border-blue-500 rounded-sm"></div>
|
||||||
</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>
|
||||||
<div v-else class="text-gray-500 flex flex-col items-center">
|
<div v-else class="text-gray-500 flex flex-col items-center">
|
||||||
<p>No image selected</p>
|
<p>No image selected</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useGalleryStore } from "../stores/gallery";
|
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";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const store = useGalleryStore();
|
const store = useGalleryStore();
|
||||||
@@ -47,11 +47,35 @@ const applyAll = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<h2 class="text-lg font-bold flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Settings class="w-5 h-5" />
|
<Settings class="w-5 h-5" />
|
||||||
Settings
|
Watermark
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="applyAll"
|
@click="applyAll"
|
||||||
@@ -65,7 +89,8 @@ const applyAll = () => {
|
|||||||
<!-- Text Input -->
|
<!-- Text Input -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Content</label>
|
<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" />
|
<Type class="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -74,6 +99,14 @@ const applyAll = () => {
|
|||||||
placeholder="Enter text..."
|
placeholder="Enter text..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Color Picker -->
|
<!-- Color Picker -->
|
||||||
@@ -124,7 +157,7 @@ const applyAll = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Placement Mode -->
|
<!-- Placement Info -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">Placement Status</label>
|
<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="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">
|
<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-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-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>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1 italic">
|
</div>
|
||||||
* Drag the watermark on the image to set a manual position for that specific image.
|
</div>
|
||||||
</p>
|
|
||||||
|
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { defineStore } from "pinia";
|
|||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
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 {
|
export interface ImageItem {
|
||||||
path: string;
|
path: string;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
@@ -13,6 +20,7 @@ export interface ImageItem {
|
|||||||
scale?: number;
|
scale?: number;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
maskStrokes?: MaskStroke[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WatermarkSettings {
|
export interface WatermarkSettings {
|
||||||
@@ -26,6 +34,10 @@ export interface WatermarkSettings {
|
|||||||
export const useGalleryStore = defineStore("gallery", () => {
|
export const useGalleryStore = defineStore("gallery", () => {
|
||||||
const images = ref<ImageItem[]>([]);
|
const images = ref<ImageItem[]>([]);
|
||||||
const selectedIndex = ref<number>(-1);
|
const selectedIndex = ref<number>(-1);
|
||||||
|
|
||||||
|
// 'add' = Add Watermark, 'remove' = Remove Watermark (Inpainting)
|
||||||
|
const editMode = ref<'add' | 'remove'>('add');
|
||||||
|
|
||||||
const watermarkSettings = ref<WatermarkSettings>({
|
const watermarkSettings = ref<WatermarkSettings>({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: 'Watermark',
|
text: 'Watermark',
|
||||||
@@ -34,6 +46,11 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
scale: 0.03,
|
scale: 0.03,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const brushSettings = ref({
|
||||||
|
size: 20, // screen pixels
|
||||||
|
opacity: 0.5 // visual only
|
||||||
|
});
|
||||||
|
|
||||||
const selectedImage = computed(() => {
|
const selectedImage = computed(() => {
|
||||||
if (selectedIndex.value >= 0 && selectedIndex.value < images.value.length) {
|
if (selectedIndex.value >= 0 && selectedIndex.value < images.value.length) {
|
||||||
return images.value[selectedIndex.value];
|
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
|
// 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.
|
// Strategy: Update Global Settings to match current view, and clear individual overrides so everyone follows global.
|
||||||
function applySettingsToAll() {
|
function applySettingsToAll() {
|
||||||
@@ -105,12 +188,18 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
images,
|
images,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
selectedImage,
|
selectedImage,
|
||||||
|
editMode,
|
||||||
watermarkSettings,
|
watermarkSettings,
|
||||||
|
brushSettings,
|
||||||
setImages,
|
setImages,
|
||||||
selectImage,
|
selectImage,
|
||||||
updateWatermarkSettings,
|
updateWatermarkSettings,
|
||||||
setImageManualPosition,
|
setImageManualPosition,
|
||||||
setImageSetting,
|
setImageSetting,
|
||||||
applySettingsToAll
|
applySettingsToAll,
|
||||||
|
addMaskStroke,
|
||||||
|
clearMask,
|
||||||
|
detectWatermark,
|
||||||
|
recalcAllWatermarks
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
@import "tailwindcss";
|
@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