Compare commits
2 Commits
84ae92d1e3
...
0cf429fff2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cf429fff2 | ||
|
|
d25f87abe0 |
@@ -110,6 +110,7 @@ struct ZcaResult {
|
|||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct ExportImageTask {
|
struct ExportImageTask {
|
||||||
path: String,
|
path: String,
|
||||||
|
output_filename: Option<String>,
|
||||||
manual_position: Option<ManualPosition>,
|
manual_position: Option<ManualPosition>,
|
||||||
scale: Option<f64>,
|
scale: Option<f64>,
|
||||||
opacity: Option<f64>,
|
opacity: Option<f64>,
|
||||||
@@ -251,7 +252,12 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
|
|||||||
} // END IF MODE == ADD
|
} // END IF MODE == ADD
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
let file_name = input_path.file_name().unwrap_or_default();
|
// Prioritize explicitly provided output filename (from original path), fall back to input filename
|
||||||
|
let file_name = match &task.output_filename {
|
||||||
|
Some(name) => std::ffi::OsStr::new(name),
|
||||||
|
None => input_path.file_name().unwrap_or_default()
|
||||||
|
};
|
||||||
|
let output_path = Path::new(&output_dir).join(file_name);
|
||||||
let output_path = Path::new(&output_dir).join(file_name);
|
let output_path = Path::new(&output_dir).join(file_name);
|
||||||
|
|
||||||
// Handle format specific saving
|
// Handle format specific saving
|
||||||
|
|||||||
11
src/App.vue
11
src/App.vue
@@ -50,13 +50,20 @@ async function exportBatch() {
|
|||||||
isExporting.value = true;
|
isExporting.value = true;
|
||||||
|
|
||||||
// Map images to include manual settings
|
// Map images to include manual settings
|
||||||
const exportTasks = store.images.map(img => ({
|
const exportTasks = store.images.map(img => {
|
||||||
|
// Extract filename from originalPath to ensure export uses original name
|
||||||
|
// Handles both Windows (\) and Unix (/) separators
|
||||||
|
const originalName = img.originalPath.split(/[/\\]/).pop() || "image.png";
|
||||||
|
|
||||||
|
return {
|
||||||
path: img.path,
|
path: img.path,
|
||||||
|
output_filename: originalName,
|
||||||
manual_position: img.manualPosition || null,
|
manual_position: img.manualPosition || null,
|
||||||
scale: img.scale || null,
|
scale: img.scale || null,
|
||||||
opacity: img.opacity || null,
|
opacity: img.opacity || null,
|
||||||
color: img.color || null
|
color: img.color || null
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Pass dummy globals for rust struct compatibility
|
// Pass dummy globals for rust struct compatibility
|
||||||
// The backend struct fields are named _manual_override and _manual_position
|
// The backend struct fields are named _manual_override and _manual_position
|
||||||
|
|||||||
@@ -181,22 +181,37 @@ const applyAll = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Auto Detect Controls -->
|
<!-- Auto Detect Controls -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">自动检测水印</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@click="store.detectAllWatermarks()"
|
@click="store.detectCurrentWatermark()"
|
||||||
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-gray-700 hover:bg-gray-600 text-white py-2 rounded flex items-center justify-center gap-2 text-sm transition-colors"
|
||||||
|
:disabled="store.isDetecting || store.selectedIndex < 0"
|
||||||
>
|
>
|
||||||
<Sparkles class="w-4 h-4" /> 自动检测
|
<div v-if="store.isDetecting" class="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||||
|
<Sparkles v-else class="w-4 h-4" />
|
||||||
|
当前
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="store.detectAllWatermarks()"
|
||||||
|
class="flex-1 bg-gray-700 hover:bg-gray-600 text-white py-2 rounded flex items-center justify-center gap-2 text-sm transition-colors"
|
||||||
|
:disabled="store.isDetecting || store.images.length === 0"
|
||||||
|
>
|
||||||
|
<div v-if="store.isDetecting" class="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||||
|
<Sparkles v-else 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="清空遮罩"
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-gray-400">涂抹想要移除的水印,AI 将自动填充背景。</p>
|
<p class="text-xs text-gray-400">涂抹想要移除的水印,AI 将自动填充背景。</p>
|
||||||
|
|
||||||
@@ -215,19 +230,38 @@ 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 flex flex-col gap-1">
|
||||||
AI 修复功能已就绪。
|
<span>AI 修复功能已就绪。</span>
|
||||||
|
<span v-if="store.isProcessing" class="text-yellow-300 animate-pulse">
|
||||||
|
正在处理: {{ store.progress.current }} / {{ store.progress.total }}
|
||||||
|
</span>
|
||||||
|
<span v-if="store.isDetecting" class="text-blue-300 animate-pulse">
|
||||||
|
正在检测: {{ store.progress.current }} / {{ store.progress.total }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Execution Controls -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
@click="store.selectedIndex >= 0 && store.processInpainting(store.selectedIndex)"
|
@click="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"
|
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"
|
:disabled="store.isProcessing || store.selectedIndex < 0"
|
||||||
>
|
>
|
||||||
<Eraser class="w-5 h-5" />
|
<div v-if="store.isProcessing" class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||||
执行移除
|
<Eraser v-else class="w-5 h-5" />
|
||||||
|
执行移除 (当前)
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="store.processAllInpainting()"
|
||||||
|
class="w-full bg-red-800 hover:bg-red-700 text-white py-2 rounded text-sm font-medium shadow transition-colors flex items-center justify-center gap-2"
|
||||||
|
:disabled="store.isProcessing || store.images.length === 0"
|
||||||
|
>
|
||||||
|
<Eraser class="w-4 h-4" />
|
||||||
|
执行移除 (全部有遮罩)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="store.selectedImage && store.selectedImage.path !== store.selectedImage.originalPath"
|
v-if="store.selectedImage && store.selectedImage.path !== store.selectedImage.originalPath"
|
||||||
@click="store.selectedIndex >= 0 && store.restoreImage(store.selectedIndex)"
|
@click="store.selectedIndex >= 0 && store.restoreImage(store.selectedIndex)"
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
opacity: 0.5 // visual only
|
opacity: 0.5 // visual only
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isDetecting = ref(false);
|
||||||
|
const isProcessing = ref(false);
|
||||||
|
const progress = ref({ current: 0, total: 0 });
|
||||||
|
|
||||||
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];
|
||||||
@@ -108,19 +112,16 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectAllWatermarks() {
|
// Helper to run detection on a single image object
|
||||||
if (images.value.length === 0) return;
|
async function detectWatermarkForImage(img: ImageItem) {
|
||||||
|
|
||||||
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 {
|
||||||
|
// Clear existing mask first
|
||||||
|
img.maskStrokes = [];
|
||||||
|
|
||||||
// @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 });
|
||||||
|
|
||||||
if (result.rects && result.rects.length > 0) {
|
if (result.rects && result.rects.length > 0) {
|
||||||
if (!img.maskStrokes) img.maskStrokes = [];
|
|
||||||
|
|
||||||
result.rects.forEach(r => {
|
result.rects.forEach(r => {
|
||||||
img.maskStrokes!.push({
|
img.maskStrokes!.push({
|
||||||
type: 'rect',
|
type: 'rect',
|
||||||
@@ -131,9 +132,37 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Detection failed for ${img.name}`, e);
|
console.error(`Detection failed for ${img.name}`, e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectCurrentWatermark() {
|
||||||
|
if (selectedIndex.value < 0 || !images.value[selectedIndex.value]) return;
|
||||||
|
isDetecting.value = true;
|
||||||
|
progress.value = { current: 0, total: 1 };
|
||||||
|
try {
|
||||||
|
await detectWatermarkForImage(images.value[selectedIndex.value]);
|
||||||
|
progress.value.current = 1;
|
||||||
|
} finally {
|
||||||
|
isDetecting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectAllWatermarks() {
|
||||||
|
if (images.value.length === 0) return;
|
||||||
|
isDetecting.value = true;
|
||||||
|
progress.value = { current: 0, total: images.value.length };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const batchSize = 5;
|
||||||
|
for (let i = 0; i < images.value.length; i += batchSize) {
|
||||||
|
const batch = images.value.slice(i, i + batchSize).map(async (img) => {
|
||||||
|
await detectWatermarkForImage(img);
|
||||||
|
progress.value.current++;
|
||||||
});
|
});
|
||||||
await Promise.all(batch);
|
await Promise.all(batch);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
isDetecting.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recalcAllWatermarks() {
|
async function recalcAllWatermarks() {
|
||||||
@@ -153,9 +182,6 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
baseScale: baseScale
|
baseScale: baseScale
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find index again just in case sort changed? No, we rely on reference or index.
|
|
||||||
// Img is reference. But setter needs index.
|
|
||||||
// Let's use image reference finding.
|
|
||||||
const idx = images.value.indexOf(img);
|
const idx = images.value.indexOf(img);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
setImageManualPosition(idx, result.x, result.y);
|
setImageManualPosition(idx, result.x, result.y);
|
||||||
@@ -169,9 +195,9 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processInpainting(index: number) {
|
// Internal helper for single image inpainting logic
|
||||||
const img = images.value[index];
|
async function runInpaintingForImage(img: ImageItem) {
|
||||||
if (!img || !img.maskStrokes || img.maskStrokes.length === 0) return;
|
if (!img.maskStrokes || img.maskStrokes.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newPath = await invoke<string>("run_inpainting", {
|
const newPath = await invoke<string>("run_inpainting", {
|
||||||
@@ -179,20 +205,47 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
strokes: img.maskStrokes
|
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;
|
img.path = newPath;
|
||||||
// Clear mask after success
|
|
||||||
img.maskStrokes = [];
|
img.maskStrokes = [];
|
||||||
|
|
||||||
// Force UI refresh (thumbnail) - might be needed
|
|
||||||
// Generate new thumb?
|
|
||||||
// Since path changed, converting src should trigger reload.
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Inpainting failed", e);
|
console.error("Inpainting failed", e);
|
||||||
alert("Inpainting failed: " + e);
|
throw e; // Propagate error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processInpainting(index: number) {
|
||||||
|
const img = images.value[index];
|
||||||
|
if (!img) return;
|
||||||
|
|
||||||
|
isProcessing.value = true;
|
||||||
|
progress.value = { current: 0, total: 1 };
|
||||||
|
try {
|
||||||
|
await runInpaintingForImage(img);
|
||||||
|
progress.value.current = 1;
|
||||||
|
} catch (e) {
|
||||||
|
alert("处理失败: " + e);
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processAllInpainting() {
|
||||||
|
const candidates = images.value.filter(img => img.maskStrokes && img.maskStrokes.length > 0);
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
|
||||||
|
isProcessing.value = true;
|
||||||
|
progress.value = { current: 0, total: candidates.length };
|
||||||
|
try {
|
||||||
|
// Sequential processing to avoid freezing UI or overloading backend
|
||||||
|
for (const img of candidates) {
|
||||||
|
await runInpaintingForImage(img);
|
||||||
|
progress.value.current++;
|
||||||
|
}
|
||||||
|
alert("批量处理完成!");
|
||||||
|
} catch (e) {
|
||||||
|
alert("批量处理部分失败: " + e);
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +294,9 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
editMode,
|
editMode,
|
||||||
watermarkSettings,
|
watermarkSettings,
|
||||||
brushSettings,
|
brushSettings,
|
||||||
|
isDetecting,
|
||||||
|
isProcessing,
|
||||||
|
progress,
|
||||||
setImages,
|
setImages,
|
||||||
selectImage,
|
selectImage,
|
||||||
updateWatermarkSettings,
|
updateWatermarkSettings,
|
||||||
@@ -249,9 +305,11 @@ export const useGalleryStore = defineStore("gallery", () => {
|
|||||||
applySettingsToAll,
|
applySettingsToAll,
|
||||||
addMaskStroke,
|
addMaskStroke,
|
||||||
clearMask,
|
clearMask,
|
||||||
|
detectCurrentWatermark,
|
||||||
detectAllWatermarks,
|
detectAllWatermarks,
|
||||||
recalcAllWatermarks,
|
recalcAllWatermarks,
|
||||||
processInpainting,
|
processInpainting,
|
||||||
|
processAllInpainting,
|
||||||
restoreImage
|
restoreImage
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user