Compare commits
2 Commits
713f0885dc
...
0b8f060c6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b8f060c6f | ||
|
|
2a468518af |
@@ -455,31 +455,101 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
|
|||||||
let (width, height) = img.dimensions();
|
let (width, height) = img.dimensions();
|
||||||
let gray = img.to_luma8();
|
let gray = img.to_luma8();
|
||||||
|
|
||||||
// Heuristic:
|
// "Stroke Detection" Algorithm
|
||||||
// 1. Scan Top 20% and Bottom 20%
|
// Distinguishes "Text" (Thin White Strokes) from "Solid White Areas" (Walls, Sky)
|
||||||
// 2. Look for high brightness pixels (> 230)
|
// Logic: A white text pixel must be "sandwiched" by dark pixels within a short distance.
|
||||||
// 3. Grid based clustering
|
|
||||||
|
|
||||||
let cell_size = 10;
|
let cell_size = 10;
|
||||||
let grid_w = (width + cell_size - 1) / cell_size;
|
let grid_w = (width + cell_size - 1) / cell_size;
|
||||||
let grid_h = (height + 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 mut grid = vec![false; (grid_w * grid_h) as usize];
|
||||||
|
|
||||||
let top_limit = (height as f64 * 0.2) as u32;
|
// Focus Areas: Top 15%, Bottom 25%
|
||||||
let bottom_start = (height as f64 * 0.8) as u32;
|
let top_limit = (height as f64 * 0.15) as u32;
|
||||||
|
let bottom_start = (height as f64 * 0.75) as u32;
|
||||||
|
|
||||||
for y in 0..height {
|
let max_stroke_width = 15; // Max pixels for a text stroke thickness
|
||||||
// Skip middle section
|
let contrast_threshold = 40; // How much darker the background must be
|
||||||
if y > top_limit && y < bottom_start {
|
let brightness_threshold = 200; // Text must be at least this white
|
||||||
continue;
|
|
||||||
|
for y in 1..height-1 {
|
||||||
|
if y > top_limit && y < bottom_start { continue; }
|
||||||
|
|
||||||
|
for x in 1..width-1 {
|
||||||
|
let p = gray.get_pixel(x, y)[0];
|
||||||
|
|
||||||
|
// 1. Must be Bright
|
||||||
|
if p < brightness_threshold { continue; }
|
||||||
|
|
||||||
|
// 2. Stroke Check
|
||||||
|
// We check for "Vertical Stroke" (Dark - Bright - Dark vertically)
|
||||||
|
// OR "Horizontal Stroke" (Dark - Bright - Dark horizontally)
|
||||||
|
|
||||||
|
let mut is_stroke = false;
|
||||||
|
|
||||||
|
// Check Horizontal Stroke (Vertical boundaries? No, Vertical Stroke has Left/Right boundaries?
|
||||||
|
// Terminology: "Vertical Stroke" is like 'I'. It has Left/Right boundaries.
|
||||||
|
// "Horizontal Stroke" is like '-', It has Up/Down boundaries.
|
||||||
|
|
||||||
|
// Let's check Left/Right boundaries (Vertical Stroke)
|
||||||
|
let mut left_bound = false;
|
||||||
|
let mut right_bound = false;
|
||||||
|
|
||||||
|
// Search Left
|
||||||
|
for k in 1..=max_stroke_width {
|
||||||
|
if x < k { break; }
|
||||||
|
let neighbor = gray.get_pixel(x - k, y)[0];
|
||||||
|
if p > neighbor && (p - neighbor) > contrast_threshold {
|
||||||
|
left_bound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Search Right
|
||||||
|
if left_bound {
|
||||||
|
for k in 1..=max_stroke_width {
|
||||||
|
if x + k >= width { break; }
|
||||||
|
let neighbor = gray.get_pixel(x + k, y)[0];
|
||||||
|
if p > neighbor && (p - neighbor) > contrast_threshold {
|
||||||
|
right_bound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for x in 0..width {
|
if left_bound && right_bound {
|
||||||
let p = gray.get_pixel(x, y);
|
is_stroke = true;
|
||||||
if p[0] > 230 { // High brightness threshold
|
} else {
|
||||||
// Check local contrast/edges?
|
// Check Up/Down boundaries (Horizontal Stroke)
|
||||||
// For now, simple brightness is a good proxy for "white text"
|
let mut up_bound = false;
|
||||||
// Mark grid cell
|
let mut down_bound = false;
|
||||||
|
|
||||||
|
// Search Up
|
||||||
|
for k in 1..=max_stroke_width {
|
||||||
|
if y < k { break; }
|
||||||
|
let neighbor = gray.get_pixel(x, y - k)[0];
|
||||||
|
if p > neighbor && (p - neighbor) > contrast_threshold {
|
||||||
|
up_bound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Search Down
|
||||||
|
if up_bound {
|
||||||
|
for k in 1..=max_stroke_width {
|
||||||
|
if y + k >= height { break; }
|
||||||
|
let neighbor = gray.get_pixel(x, y + k)[0];
|
||||||
|
if p > neighbor && (p - neighbor) > contrast_threshold {
|
||||||
|
down_bound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if up_bound && down_bound {
|
||||||
|
is_stroke = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_stroke {
|
||||||
let gx = x / cell_size;
|
let gx = x / cell_size;
|
||||||
let gy = y / cell_size;
|
let gy = y / cell_size;
|
||||||
grid[(gy * grid_w + gx) as usize] = true;
|
grid[(gy * grid_w + gx) as usize] = true;
|
||||||
@@ -496,9 +566,6 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
|
|||||||
let idx = (gy * grid_w + gx) as usize;
|
let idx = (gy * grid_w + gx) as usize;
|
||||||
if grid[idx] && !visited[idx] {
|
if grid[idx] && !visited[idx] {
|
||||||
// Start a new component
|
// 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 min_gx = gx;
|
||||||
let mut max_gx = gx;
|
let mut max_gx = gx;
|
||||||
let mut min_gy = gy;
|
let mut min_gy = gy;
|
||||||
@@ -550,11 +617,178 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
|
|||||||
Ok(DetectionResult { rects })
|
Ok(DetectionResult { rects })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct StrokePoint {
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct StrokeRect {
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
w: f64,
|
||||||
|
h: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
enum MaskStroke {
|
||||||
|
#[serde(rename = "path")]
|
||||||
|
Path {
|
||||||
|
points: Vec<StrokePoint>,
|
||||||
|
width: f64,
|
||||||
|
},
|
||||||
|
#[serde(rename = "rect")]
|
||||||
|
Rect {
|
||||||
|
rect: StrokeRect,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn run_inpainting(path: String, strokes: Vec<MaskStroke>) -> Result<String, String> {
|
||||||
|
let img = image::open(&path).map_err(|e| e.to_string())?.to_rgba8();
|
||||||
|
let (width, height) = img.dimensions();
|
||||||
|
|
||||||
|
// 1. Create Mask
|
||||||
|
let mut mask = vec![false; (width * height) as usize];
|
||||||
|
|
||||||
|
for stroke in strokes {
|
||||||
|
match stroke {
|
||||||
|
MaskStroke::Rect { rect } => {
|
||||||
|
let x1 = (rect.x * width as f64) as i32;
|
||||||
|
let y1 = (rect.y * height as f64) as i32;
|
||||||
|
let w = (rect.w * width as f64) as i32;
|
||||||
|
let h = (rect.h * height as f64) as i32;
|
||||||
|
|
||||||
|
for y in y1..(y1 + h) {
|
||||||
|
for x in x1..(x1 + w) {
|
||||||
|
if x >= 0 && x < width as i32 && y >= 0 && y < height as i32 {
|
||||||
|
mask[(y as u32 * width + x as u32) as usize] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MaskStroke::Path { points, width: stroke_w_pct } => {
|
||||||
|
if points.is_empty() { continue; }
|
||||||
|
let r = (stroke_w_pct * width as f64 / 2.0).ceil() as i32;
|
||||||
|
let r2 = r * r;
|
||||||
|
|
||||||
|
for i in 0..points.len() - 1 {
|
||||||
|
let p1 = &points[i];
|
||||||
|
let p2 = &points[i+1];
|
||||||
|
|
||||||
|
let x1 = p1.x * width as f64;
|
||||||
|
let y1 = p1.y * height as f64;
|
||||||
|
let x2 = p2.x * width as f64;
|
||||||
|
let y2 = p2.y * height as f64;
|
||||||
|
|
||||||
|
// Simple line interpolation
|
||||||
|
let dist = ((x2-x1).powi(2) + (y2-y1).powi(2)).sqrt();
|
||||||
|
let steps = dist.max(1.0) as i32;
|
||||||
|
|
||||||
|
for s in 0..=steps {
|
||||||
|
let t = s as f64 / steps as f64;
|
||||||
|
let cx = (x1 + (x2 - x1) * t) as i32;
|
||||||
|
let cy = (y1 + (y2 - y1) * t) as i32;
|
||||||
|
|
||||||
|
// Draw circle at cx, cy
|
||||||
|
for dy in -r..=r {
|
||||||
|
for dx in -r..=r {
|
||||||
|
if dx*dx + dy*dy <= r2 {
|
||||||
|
let nx = cx + dx;
|
||||||
|
let ny = cy + dy;
|
||||||
|
if nx >= 0 && nx < width as i32 && ny >= 0 && ny < height as i32 {
|
||||||
|
mask[(ny as u32 * width + nx as u32) as usize] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Diffusion Inpainting (Simple)
|
||||||
|
// Iteratively replace masked pixels with average of non-masked neighbors
|
||||||
|
// To make it converge, we update 'mask' as we go (treating filled pixels as valid source)
|
||||||
|
// But standard diffusion uses double buffering.
|
||||||
|
// For "Removing Text", simple inward filling works well.
|
||||||
|
|
||||||
|
let iterations = 30;
|
||||||
|
let mut current_img = img.clone();
|
||||||
|
let mut next_img = img.clone();
|
||||||
|
|
||||||
|
// Convert mask to a distance map-like state?
|
||||||
|
// Or just simple neighbor average.
|
||||||
|
|
||||||
|
for _ in 0..iterations {
|
||||||
|
let mut changed = false;
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let idx = (y * width + x) as usize;
|
||||||
|
if mask[idx] {
|
||||||
|
// It's a hole. Find valid neighbors.
|
||||||
|
// Valid = Not in ORIGINAL mask (so we pull from original image)
|
||||||
|
// OR processed in previous iteration?
|
||||||
|
// Simple logic: Pull from 'current_img'.
|
||||||
|
|
||||||
|
let mut sum_r = 0u32;
|
||||||
|
let mut sum_g = 0u32;
|
||||||
|
let mut sum_b = 0u32;
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
// Check 4 neighbors
|
||||||
|
let neighbors = [
|
||||||
|
(x.wrapping_sub(1), y), (x + 1, y),
|
||||||
|
(x, y.wrapping_sub(1)), (x, y + 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (nx, ny) in neighbors {
|
||||||
|
if nx < width && ny < height {
|
||||||
|
// Weighted check: If neighbor is ALSO masked, it contributes less?
|
||||||
|
// Or just take everything.
|
||||||
|
let pixel = current_img.get_pixel(nx, ny);
|
||||||
|
sum_r += pixel[0] as u32;
|
||||||
|
sum_g += pixel[1] as u32;
|
||||||
|
sum_b += pixel[2] as u32;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
let avg = image::Rgba([
|
||||||
|
(sum_r / count) as u8,
|
||||||
|
(sum_g / count) as u8,
|
||||||
|
(sum_b / count) as u8,
|
||||||
|
255
|
||||||
|
]);
|
||||||
|
next_img.put_pixel(x, y, avg);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current_img = next_img.clone();
|
||||||
|
if !changed { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp
|
||||||
|
let cache_dir = get_cache_dir();
|
||||||
|
let file_name = format!("inpainted_{}.png", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis());
|
||||||
|
let out_path = cache_dir.join(file_name);
|
||||||
|
|
||||||
|
current_img.save(&out_path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(out_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[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, detect_watermark, layout_watermark])
|
.invoke_handler(tauri::generate_handler![scan_dir, get_zca_suggestion, export_batch, detect_watermark, layout_watermark, run_inpainting])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/App.vue
14
src/App.vue
@@ -33,7 +33,7 @@ async function openFolder() {
|
|||||||
async function exportBatch() {
|
async function exportBatch() {
|
||||||
if (store.images.length === 0) return;
|
if (store.images.length === 0) return;
|
||||||
if (!store.watermarkSettings.text) {
|
if (!store.watermarkSettings.text) {
|
||||||
alert("Please enter watermark text.");
|
alert("请输入水印文字。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ async function exportBatch() {
|
|||||||
const outputDir = await open({
|
const outputDir = await open({
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
title: "Select Output Directory"
|
title: "选择输出目录"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (outputDir && typeof outputDir === 'string') {
|
if (outputDir && typeof outputDir === 'string') {
|
||||||
@@ -69,11 +69,11 @@ async function exportBatch() {
|
|||||||
watermark: rustWatermarkSettings,
|
watermark: rustWatermarkSettings,
|
||||||
outputDir: outputDir
|
outputDir: outputDir
|
||||||
});
|
});
|
||||||
alert("Batch export completed!");
|
alert("批量导出完成!");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Export failed:", e);
|
console.error("Export failed:", e);
|
||||||
alert("Export failed: " + e);
|
alert("导出失败: " + e);
|
||||||
} finally {
|
} finally {
|
||||||
isExporting.value = false;
|
isExporting.value = false;
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ async function exportBatch() {
|
|||||||
<div class="h-screen w-screen bg-gray-900 text-white overflow-hidden flex flex-col">
|
<div class="h-screen w-screen bg-gray-900 text-white overflow-hidden flex flex-col">
|
||||||
<header class="h-14 bg-gray-800 flex items-center justify-between px-6 border-b border-gray-700 shrink-0 shadow-md z-10">
|
<header class="h-14 bg-gray-800 flex items-center justify-between px-6 border-b border-gray-700 shrink-0 shadow-md z-10">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<h1 class="text-lg font-bold tracking-wider bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">WATERMARK WIZARD</h1>
|
<h1 class="text-lg font-bold tracking-wider bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">水印精灵</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -93,7 +93,7 @@ async function exportBatch() {
|
|||||||
class="flex items-center gap-2 bg-gray-700 hover:bg-gray-600 text-gray-200 px-4 py-2 rounded-md text-sm font-medium transition-all hover:shadow-lg"
|
class="flex items-center gap-2 bg-gray-700 hover:bg-gray-600 text-gray-200 px-4 py-2 rounded-md text-sm font-medium transition-all hover:shadow-lg"
|
||||||
>
|
>
|
||||||
<FolderOpen class="w-4 h-4" />
|
<FolderOpen class="w-4 h-4" />
|
||||||
Open Folder
|
打开文件夹
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -103,7 +103,7 @@ async function exportBatch() {
|
|||||||
>
|
>
|
||||||
<Download class="w-4 h-4" v-if="!isExporting" />
|
<Download class="w-4 h-4" v-if="!isExporting" />
|
||||||
<div v-else class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
<div v-else class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||||
{{ isExporting ? 'Exporting...' : 'Export Batch' }}
|
{{ isExporting ? '导出中...' : '批量导出' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ const stopDrawing = () => {
|
|||||||
></canvas>
|
></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>未选择图片</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const currentColor = computed({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const applyAll = () => {
|
const applyAll = () => {
|
||||||
if (confirm("Apply current settings (Size, Opacity, Color) to ALL images?")) {
|
if (confirm("是否将当前设置(大小、透明度、颜色)应用到所有图片?")) {
|
||||||
store.applySettingsToAll();
|
store.applySettingsToAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -56,14 +56,14 @@ const applyAll = () => {
|
|||||||
class="flex-1 py-3 text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
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'"
|
: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
|
<PlusSquare class="w-4 h-4" /> 添加
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="store.editMode = 'remove'"
|
@click="store.editMode = 'remove'"
|
||||||
class="flex-1 py-3 text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
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'"
|
: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
|
<Eraser class="w-4 h-4" /> 移除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,20 +75,20 @@ const applyAll = () => {
|
|||||||
<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" />
|
||||||
Watermark
|
水印设置
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="applyAll"
|
@click="applyAll"
|
||||||
title="Apply Settings to All Images"
|
title="应用设置到所有图片"
|
||||||
class="bg-gray-700 hover:bg-gray-600 p-1.5 rounded text-xs text-blue-300 transition-colors flex items-center gap-1"
|
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
|
<Copy class="w-3 h-3" /> 全部
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- 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">水印内容</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="relative flex-1">
|
<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" />
|
||||||
@@ -96,13 +96,13 @@ const applyAll = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
v-model="store.watermarkSettings.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"
|
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..."
|
placeholder="输入水印文字..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="store.recalcAllWatermarks()"
|
@click="store.recalcAllWatermarks()"
|
||||||
class="bg-blue-600 hover:bg-blue-500 text-white p-2 rounded flex items-center justify-center transition-colors"
|
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"
|
title="应用并重新计算所有图片布局"
|
||||||
>
|
>
|
||||||
<RotateCw class="w-4 h-4" />
|
<RotateCw class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -111,7 +111,7 @@ const applyAll = () => {
|
|||||||
|
|
||||||
<!-- Color Picker -->
|
<!-- Color Picker -->
|
||||||
<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">Color</label>
|
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">字体颜色</label>
|
||||||
<div class="flex items-center gap-2 bg-gray-700 p-2 rounded border border-gray-600">
|
<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" />
|
<Palette class="w-4 h-4 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
@@ -127,7 +127,7 @@ const applyAll = () => {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between mb-1">
|
<div class="flex justify-between mb-1">
|
||||||
<label class="text-xs text-gray-400">Size (Scale)</label>
|
<label class="text-xs text-gray-400">字体大小 (比例)</label>
|
||||||
<span class="text-xs text-gray-300">{{ (currentScale * 100).toFixed(1) }}%</span>
|
<span class="text-xs text-gray-300">{{ (currentScale * 100).toFixed(1) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -138,12 +138,12 @@ const applyAll = () => {
|
|||||||
v-model.number="currentScale"
|
v-model.number="currentScale"
|
||||||
class="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
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>
|
<p class="text-[10px] text-gray-500 mt-1">基于图片高度的比例</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between mb-1">
|
<div class="flex justify-between mb-1">
|
||||||
<label class="text-xs text-gray-400">Opacity</label>
|
<label class="text-xs text-gray-400">不透明度</label>
|
||||||
<span class="text-xs text-gray-300">{{ (currentOpacity * 100).toFixed(0) }}%</span>
|
<span class="text-xs text-gray-300">{{ (currentOpacity * 100).toFixed(0) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -159,15 +159,15 @@ const applyAll = () => {
|
|||||||
|
|
||||||
<!-- Placement Info -->
|
<!-- 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">位置状态</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">
|
||||||
<div class="p-1 rounded bg-green-500/20 text-green-400">
|
<div class="p-1 rounded bg-green-500/20 text-green-400">
|
||||||
<CheckSquare class="w-4 h-4" v-if="!store.selectedImage?.manualPosition" />
|
<CheckSquare class="w-4 h-4" v-if="!store.selectedImage?.manualPosition" />
|
||||||
<div class="w-4 h-4" v-else></div>
|
<div class="w-4 h-4" v-else></div>
|
||||||
</div>
|
</div>
|
||||||
<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">自动 (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>手动调整</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,33 +177,32 @@ const applyAll = () => {
|
|||||||
<div v-else class="flex flex-col gap-6">
|
<div v-else class="flex flex-col gap-6">
|
||||||
<h2 class="text-lg font-bold flex items-center gap-2 text-red-400">
|
<h2 class="text-lg font-bold flex items-center gap-2 text-red-400">
|
||||||
<Brush class="w-5 h-5" />
|
<Brush class="w-5 h-5" />
|
||||||
Magic Eraser
|
魔法橡皮擦
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Auto Detect Controls -->
|
<!-- Auto Detect Controls -->
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@click="store.selectedIndex >= 0 && store.detectWatermark(store.selectedIndex)"
|
@click="store.detectAllWatermarks()"
|
||||||
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"
|
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
|
<Sparkles class="w-4 h-4" /> 自动检测
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="store.selectedIndex >= 0 && store.clearMask(store.selectedIndex)"
|
@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"
|
class="bg-gray-700 hover:bg-gray-600 text-gray-300 px-3 rounded flex items-center justify-center transition-colors"
|
||||||
title="Clear Mask"
|
title="清空遮罩"
|
||||||
:disabled="store.selectedIndex < 0"
|
:disabled="store.selectedIndex < 0"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
<Trash2 class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-gray-400">Paint over the watermark you want to remove. The AI will fill in the background.</p>
|
<p class="text-xs text-gray-400">涂抹想要移除的水印,AI 将自动填充背景。</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between mb-1">
|
<div class="flex justify-between mb-1">
|
||||||
<label class="text-xs text-gray-400">Brush Size</label>
|
<label class="text-xs text-gray-400">画笔大小</label>
|
||||||
<span class="text-xs text-gray-300">{{ store.brushSettings.size }}px</span>
|
<span class="text-xs text-gray-300">{{ store.brushSettings.size }}px</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -217,8 +216,17 @@ const applyAll = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-3 bg-red-900/20 border border-red-900/50 rounded text-xs text-red-200">
|
<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.
|
AI 修复功能已就绪。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="store.selectedIndex >= 0 && store.processInpainting(store.selectedIndex)"
|
||||||
|
class="w-full bg-red-600 hover:bg-red-500 text-white py-3 rounded font-bold shadow-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
:disabled="store.selectedIndex < 0"
|
||||||
|
>
|
||||||
|
<Eraser class="w-5 h-5" />
|
||||||
|
执行移除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
|
|
||||||
const watermarkSettings = ref<WatermarkSettings>({
|
const watermarkSettings = ref<WatermarkSettings>({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: 'Watermark',
|
text: '水印',
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
scale: 0.03,
|
scale: 0.03,
|
||||||
@@ -95,10 +95,12 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectWatermark(index: number) {
|
async function detectAllWatermarks() {
|
||||||
const img = images.value[index];
|
if (images.value.length === 0) return;
|
||||||
if (!img) return;
|
|
||||||
|
|
||||||
|
const batchSize = 5;
|
||||||
|
for (let i = 0; i < images.value.length; i += batchSize) {
|
||||||
|
const batch = images.value.slice(i, i + batchSize).map(async (img) => {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const result = await invoke<{rects: {x: number, y: number, width: number, height: number}[]}>("detect_watermark", { path: img.path });
|
const result = await invoke<{rects: {x: number, y: number, width: number, height: number}[]}>("detect_watermark", { path: img.path });
|
||||||
@@ -114,7 +116,10 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Detection failed", e);
|
console.error(`Detection failed for ${img.name}`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(batch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,8 +132,7 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
// Process in batches to avoid overwhelming the backend
|
// Process in batches to avoid overwhelming the backend
|
||||||
const batchSize = 5;
|
const batchSize = 5;
|
||||||
for (let i = 0; i < images.value.length; i += batchSize) {
|
for (let i = 0; i < images.value.length; i += batchSize) {
|
||||||
const batch = images.value.slice(i, i + batchSize).map(async (img, batchIdx) => {
|
const batch = images.value.slice(i, i + batchSize).map(async (img) => {
|
||||||
const globalIdx = i + batchIdx;
|
|
||||||
try {
|
try {
|
||||||
const result = await invoke<{x: number, y: number, scale: number}>("layout_watermark", {
|
const result = await invoke<{x: number, y: number, scale: number}>("layout_watermark", {
|
||||||
path: img.path,
|
path: img.path,
|
||||||
@@ -136,8 +140,14 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
baseScale: baseScale
|
baseScale: baseScale
|
||||||
});
|
});
|
||||||
|
|
||||||
setImageManualPosition(globalIdx, result.x, result.y);
|
// Find index again just in case sort changed? No, we rely on reference or index.
|
||||||
setImageSetting(globalIdx, 'scale', result.scale);
|
// Img is reference. But setter needs index.
|
||||||
|
// Let's use image reference finding.
|
||||||
|
const idx = images.value.indexOf(img);
|
||||||
|
if (idx >= 0) {
|
||||||
|
setImageManualPosition(idx, result.x, result.y);
|
||||||
|
setImageSetting(idx, 'scale', result.scale);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Layout failed for ${img.name}`, e);
|
console.error(`Layout failed for ${img.name}`, e);
|
||||||
}
|
}
|
||||||
@@ -146,6 +156,33 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function processInpainting(index: number) {
|
||||||
|
const img = images.value[index];
|
||||||
|
if (!img || !img.maskStrokes || img.maskStrokes.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newPath = await invoke<string>("run_inpainting", {
|
||||||
|
path: img.path,
|
||||||
|
strokes: img.maskStrokes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the image path to point to the processed version
|
||||||
|
// NOTE: This updates the "Source" of the image in the store.
|
||||||
|
// In a real app, maybe we keep original? But for "Wizard", modifying is the goal.
|
||||||
|
img.path = newPath;
|
||||||
|
// Clear mask after success
|
||||||
|
img.maskStrokes = [];
|
||||||
|
|
||||||
|
// Force UI refresh (thumbnail) - might be needed
|
||||||
|
// Generate new thumb?
|
||||||
|
// Since path changed, converting src should trigger reload.
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Inpainting failed", e);
|
||||||
|
alert("Inpainting failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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() {
|
||||||
@@ -199,7 +236,8 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
applySettingsToAll,
|
applySettingsToAll,
|
||||||
addMaskStroke,
|
addMaskStroke,
|
||||||
clearMask,
|
clearMask,
|
||||||
detectWatermark,
|
detectAllWatermarks,
|
||||||
recalcAllWatermarks
|
recalcAllWatermarks,
|
||||||
|
processInpainting
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user