Compare commits

..

11 Commits

Author SHA1 Message Date
Julian Freeman
0541dbfd69 fix image type bug 2026-01-20 22:08:56 -04:00
Julian Freeman
52fb288bed add license and notes 2026-01-19 16:17:10 -04:00
Julian Freeman
2ce9e5471e update version 2026-01-19 15:38:58 -04:00
Julian Freeman
2c89ef42b3 support gpu 2026-01-19 15:35:53 -04:00
Julian Freeman
330a62027c import ocr ares 2026-01-19 15:16:38 -04:00
Julian Freeman
302f106ae6 css 2026-01-19 15:12:59 -04:00
Julian Freeman
3ed264f349 left and right key 2026-01-19 15:00:47 -04:00
Julian Freeman
99c442663a import scrollbar 2026-01-19 14:52:54 -04:00
Julian Freeman
a01277a11c adjust ui 2026-01-19 14:47:44 -04:00
Julian Freeman
0cf429fff2 improve ui 2026-01-19 13:41:51 -04:00
Julian Freeman
d25f87abe0 improve 2026-01-19 13:30:40 -04:00
15 changed files with 542 additions and 114 deletions

202
LICENSE.txt Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,3 +1,7 @@
# Watermark Wizard
Generated by Gemini
Models:
- LaMa: https://huggingface.co/Carve/LaMa-ONNX/blob/main/lama_fp32.onnx
- en_PP-OCRv3_det_infer: https://huggingface.co/SWHL/RapidOCR/blob/main/PP-OCRv4/en_PP-OCRv3_det_infer.onnx

View File

@@ -1,7 +1,7 @@
{
"name": "watermark-wizard",
"private": true,
"version": "0.1.0",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite",

2
src-tauri/Cargo.lock generated
View File

@@ -5117,7 +5117,7 @@ dependencies = [
[[package]]
name = "watermark-wizard"
version = "0.1.0"
version = "1.0.1"
dependencies = [
"ab_glyph",
"image",

View File

@@ -1,8 +1,8 @@
[package]
name = "watermark-wizard"
version = "0.1.0"
version = "1.0.1"
description = "A Tauri App handles watermarks"
authors = ["you"]
authors = ["Julian"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -26,6 +26,6 @@ rayon = "1.10"
tauri-plugin-dialog = "2"
imageproc = "0.25"
ab_glyph = "0.2.23"
ort = { version = "=2.0.0-rc.11", features = ["load-dynamic"] }
ort = { version = "=2.0.0-rc.11", features = ["load-dynamic", "directml"] }
ndarray = "0.16"

View File

@@ -1,5 +1,5 @@
use crate::ort_ops;
use image::{Rgba, RgbaImage};
use ort::session::{Session, builder::GraphOptimizationLevel};
use ort::value::Value;
pub fn run_lama_inpainting(
@@ -8,14 +8,8 @@ pub fn run_lama_inpainting(
mask_image: &image::GrayImage,
) -> Result<RgbaImage, String> {
// 1. Initialize Session
let mut session = Session::builder()
.map_err(|e| format!("Failed to create session builder: {}", e))?
.with_optimization_level(GraphOptimizationLevel::Level3)
.map_err(|e| format!("Failed to set opt level: {}", e))?
.with_intra_threads(4)
.map_err(|e| format!("Failed to set threads: {}", e))?
.commit_from_file(model_path)
.map_err(|e| format!("Failed to load model from {:?}: {}", model_path, e))?;
let mut session = ort_ops::create_session(model_path)
.map_err(|e| format!("Failed to create ORT session for LAMA: {}", e))?;
// 2. Preprocess
let target_size = (512, 512);

View File

@@ -19,6 +19,14 @@ fn get_cache_dir() -> std::path::PathBuf {
path
}
fn load_image_safe(path: &Path) -> Result<image::DynamicImage, String> {
let bytes = fs::read(path).map_err(|e| {
let exists = path.exists();
format!("读取文件失败 (存在: {}) / Failed to read file '{}': {}", exists, path.to_string_lossy(), e)
})?;
image::load_from_memory(&bytes).map_err(|e| format!("图片解码失败 / Failed to decode image '{}': {}", path.to_string_lossy(), e))
}
fn generate_thumbnail(original_path: &Path) -> Option<String> {
let cache_dir = get_cache_dir();
@@ -35,7 +43,7 @@ fn generate_thumbnail(original_path: &Path) -> Option<String> {
}
// Generate
if let Ok(img) = image::open(original_path) {
if let Ok(img) = load_image_safe(original_path) {
let thumb = img.thumbnail(u32::MAX, 200);
let _file = fs::File::create(&thumb_path).ok()?;
thumb.save_with_format(&thumb_path, image::ImageFormat::Jpeg).ok()?;
@@ -94,8 +102,9 @@ use imageproc::drawing::draw_text_mut;
use ab_glyph::{FontRef, PxScale};
use tauri::{AppHandle, Manager};
mod lama;
mod ocr;
pub mod lama;
pub mod ocr;
pub mod ort_ops;
// Embed the font to ensure it's always available without path issues
const FONT_DATA: &[u8] = include_bytes!("../assets/fonts/Roboto-Regular.ttf");
@@ -110,6 +119,7 @@ struct ZcaResult {
#[derive(serde::Deserialize)]
struct ExportImageTask {
path: String,
output_filename: Option<String>,
manual_position: Option<ManualPosition>,
scale: Option<f64>,
opacity: Option<f64>,
@@ -151,9 +161,11 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
let results: Vec<Result<(), String>> = images.par_iter().map(|task| {
let input_path = Path::new(&task.path);
let img_result = image::open(input_path);
if let Ok(dynamic_img) = img_result {
// Use safe loading helper
let img_result = load_image_safe(input_path);
if let Ok(dynamic_img) = &img_result {
let mut base_img = dynamic_img.to_rgba8();
let (width, height) = base_img.dimensions();
@@ -251,8 +263,13 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
} // END IF MODE == ADD
// 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);
// Handle format specific saving
// JPEG does not support Alpha channel. If we save Rgba8 to Jpeg, it might fail or look wrong.
@@ -270,7 +287,7 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
Ok(())
} else {
Err(format!("Failed to open {}", task.path))
Err(img_result.unwrap_err())
}
}).collect();
@@ -379,7 +396,7 @@ fn calculate_zca_internal(img: &image::DynamicImage) -> Result<ZcaResult, String
#[tauri::command]
fn get_zca_suggestion(path: String) -> Result<ZcaResult, String> {
let img = image::open(&path).map_err(|e| e.to_string())?;
let img = load_image_safe(Path::new(&path))?;
calculate_zca_internal(&img)
}
@@ -392,7 +409,7 @@ struct LayoutResult {
#[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 img = load_image_safe(Path::new(&path))?;
let (width, height) = img.dimensions();
let font = FontRef::try_from_slice(FONT_DATA).map_err(|e| format!("Font error: {}", e))?;
@@ -461,7 +478,7 @@ struct Rect {
#[tauri::command]
async fn detect_watermark(app: AppHandle, path: String) -> Result<DetectionResult, String> {
let img = image::open(&path).map_err(|e| e.to_string())?.to_rgba8();
let img = load_image_safe(Path::new(&path))?.to_rgba8();
// 1. Try OCR Detection
let ocr_model_path = app.path().resource_dir()
@@ -655,7 +672,7 @@ enum MaskStroke {
#[tauri::command]
async fn run_inpainting(app: AppHandle, path: String, strokes: Vec<MaskStroke>) -> Result<String, String> {
let img = image::open(&path).map_err(|e| e.to_string())?.to_rgba8();
let img = load_image_safe(Path::new(&path))?.to_rgba8();
let (width, height) = img.dimensions();
// 1. Create Gray Mask (0 = keep, 255 = remove)

View File

@@ -1,5 +1,5 @@
use image::RgbaImage;
use ort::session::{Session, builder::GraphOptimizationLevel};
use crate::ort_ops;
use image::{GenericImageView, Rgba, RgbaImage};
use ort::value::Value;
use std::path::Path;
@@ -15,15 +15,9 @@ pub fn run_ocr_detection(
model_path: &Path,
input_image: &RgbaImage,
) -> Result<Vec<DetectedBox>, String> {
// 1. Load Model
let mut session = Session::builder()
.map_err(|e| format!("Failed to create session: {}", e))?
.with_optimization_level(GraphOptimizationLevel::Level3)
.map_err(|e| format!("Failed to set opt: {}", e))?
.with_intra_threads(4)
.map_err(|e| format!("Failed to set threads: {}", e))?
.commit_from_file(model_path)
.map_err(|e| format!("Failed to load OCR model: {}", e))?;
// 1. Load Model using the shared function
let mut session = ort_ops::create_session(model_path)
.map_err(|e| format!("Failed to create ORT session: {}", e))?;
// 2. Preprocess
// DBNet expects standard normalization: (img - mean) / std
@@ -31,7 +25,26 @@ pub fn run_ocr_detection(
// And usually resized to multiple of 32. Limit max size for speed.
let max_side = 1600; // Increase resolution limit
let (orig_w, orig_h) = input_image.dimensions();
// --- CROP to top 5% and bottom 5% ---
let crop_height = (orig_h as f64 * 0.05).ceil() as u32;
let mut masked_image = RgbaImage::from_pixel(orig_w, orig_h, Rgba([0, 0, 0, 255]));
if crop_height > 0 {
// Copy top part
let top_view = input_image.view(0, 0, orig_w, crop_height).to_image();
image::imageops::replace(&mut masked_image, &top_view, 0, 0);
// Copy bottom part
let bottom_y = orig_h.saturating_sub(crop_height);
let bottom_view = input_image.view(0, bottom_y, orig_w, crop_height).to_image();
image::imageops::replace(&mut masked_image, &bottom_view, 0, bottom_y as i64);
} else {
// If image is very short, just use the original
masked_image = input_image.clone();
}
// --- End CROP ---
let mut resize_w = orig_w;
let mut resize_h = orig_h;
@@ -50,7 +63,7 @@ pub fn run_ocr_detection(
resize_w = resize_w.max(32);
resize_h = resize_h.max(32);
let resized = image::imageops::resize(input_image, resize_w, resize_h, image::imageops::FilterType::Triangle);
let resized = image::imageops::resize(&masked_image, resize_w, resize_h, image::imageops::FilterType::Triangle);
let channel_stride = (resize_w * resize_h) as usize;
let mut input_data = Vec::with_capacity(1 * 3 * channel_stride);
@@ -114,7 +127,7 @@ pub fn run_ocr_detection(
let idx = (y * map_w + x) as usize;
if binary_map[idx] && !visited[idx] {
// Flood fill
let mut stack = vec![(x, y)];
let mut stack = vec![(x as u32, y as u32)];
visited[idx] = true;
let mut min_x = x;

41
src-tauri/src/ort_ops.rs Normal file
View File

@@ -0,0 +1,41 @@
use ort::session::{Session, builder::GraphOptimizationLevel};
use ort::execution_providers::DirectMLExecutionProvider;
use std::path::Path;
/// Attempts to create an ORT session with GPU (DirectML) acceleration.
/// If GPU initialization fails, it falls back to a CPU-only session.
pub fn create_session(model_path: &Path) -> Result<Session, ort::Error> {
// Try to build with DirectML
let dm_provider = DirectMLExecutionProvider::default().build();
let session_builder = Session::builder()?
.with_optimization_level(GraphOptimizationLevel::Level3)?
.with_intra_threads(4)?;
match session_builder.with_execution_providers([dm_provider]) {
Ok(builder_with_dm) => {
println!("Attempting to commit session with DirectML provider...");
match builder_with_dm.commit_from_file(model_path) {
Ok(session) => {
println!("Successfully created ORT session with DirectML GPU acceleration.");
return Ok(session);
},
Err(e) => {
println!("Failed to create session with DirectML: {:?}. Falling back to CPU.", e);
// Fall through to CPU execution
}
}
},
Err(e) => {
println!("Failed to build session with DirectML provider: {:?}. Falling back to CPU.", e);
// Fall through to CPU execution
}
};
// Fallback to CPU
println!("Creating ORT session with CPU provider.");
Session::builder()?
.with_optimization_level(GraphOptimizationLevel::Level3)?
.with_intra_threads(4)?
.commit_from_file(model_path)
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "watermark-wizard",
"version": "0.1.0",
"version": "1.0.1",
"identifier": "top.volan.watermark-wizard",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "水印精灵 v0.1.0",
"title": "水印精灵 v1.0.1",
"width": 1650,
"height": 1000
}

View File

@@ -6,11 +6,27 @@ import { useGalleryStore } from "./stores/gallery";
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { FolderOpen, Download } from 'lucide-vue-next';
import { ref } from "vue";
import { ref, onMounted, onUnmounted } from "vue";
const store = useGalleryStore();
const isExporting = ref(false);
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowRight') {
store.nextImage();
} else if (event.key === 'ArrowLeft') {
store.prevImage();
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown);
});
async function openFolder() {
try {
const selected = await open({
@@ -50,13 +66,20 @@ async function exportBatch() {
isExporting.value = true;
// Map images to include manual settings
const exportTasks = store.images.map(img => ({
path: img.path,
manual_position: img.manualPosition || null,
scale: img.scale || null,
opacity: img.opacity || null,
color: img.color || null
}));
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,
output_filename: originalName,
manual_position: img.manualPosition || null,
scale: img.scale || null,
opacity: img.opacity || null,
color: img.color || null
};
});
// Pass dummy globals for rust struct compatibility
// The backend struct fields are named _manual_override and _manual_position
@@ -87,7 +110,7 @@ async function exportBatch() {
<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">
<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">水印精灵</h1>
<h1 class="text-lg font-bold tracking-wider bg-linear-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">水印精灵</h1>
</div>
<div class="flex items-center gap-3">

View File

@@ -181,21 +181,36 @@ const applyAll = () => {
</h2>
<!-- Auto Detect Controls -->
<div class="flex gap-2">
<button
@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"
>
<Sparkles class="w-4 h-4" /> 自动检测
</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="清空遮罩"
:disabled="store.selectedIndex < 0"
>
<Trash2 class="w-4 h-4" />
</button>
<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">
<button
@click="store.detectCurrentWatermark()"
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"
>
<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
@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="清空当前遮罩"
:disabled="store.selectedIndex < 0"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
<p class="text-xs text-gray-400">涂抹想要移除的水印AI 将自动填充背景</p>
@@ -215,18 +230,41 @@ const applyAll = () => {
/>
</div>
<div class="p-3 bg-red-900/20 border border-red-900/50 rounded text-xs text-red-200">
AI 修复功能已就绪
<div class="p-3 bg-red-900/20 border border-red-900/50 rounded text-xs text-red-200 flex flex-col gap-1">
<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>
<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>
<!-- Execution 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">
<button
@click="store.processInpainting(store.selectedIndex)"
class="flex-1 bg-red-600 hover:bg-red-500 text-white py-2 rounded flex items-center justify-center gap-2 text-sm transition-colors shadow-lg"
:disabled="store.isProcessing || store.selectedIndex < 0"
>
<div v-if="store.isProcessing" class="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<Eraser v-else class="w-4 h-4" />
当前
</button>
<button
@click="store.processAllInpainting()"
class="flex-1 bg-red-800 hover:bg-red-700 text-white py-2 rounded flex items-center justify-center gap-2 text-sm transition-colors shadow"
:disabled="store.isProcessing || store.images.length === 0"
>
<div v-if="store.isProcessing" class="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<Eraser v-else class="w-4 h-4" />
全部
</button>
</div>
</div>
<button
v-if="store.selectedImage && store.selectedImage.path !== store.selectedImage.originalPath"

View File

@@ -23,7 +23,7 @@ const onSelect = (index: number) => {
>
<template #default="{ item, index }">
<div
class="h-full w-[100px] p-2 cursor-pointer transition-colors"
class="h-full w-25 p-2 cursor-pointer transition-colors"
:class="{'bg-blue-600': store.selectedIndex === index, 'hover:bg-gray-700': store.selectedIndex !== index}"
@click="onSelect(index)"
>

View File

@@ -52,6 +52,10 @@ export const useGalleryStore = defineStore("gallery", () => {
opacity: 0.5 // visual only
});
const isDetecting = ref(false);
const isProcessing = ref(false);
const progress = ref({ current: 0, total: 0 });
const selectedImage = computed(() => {
if (selectedIndex.value >= 0 && selectedIndex.value < images.value.length) {
return images.value[selectedIndex.value];
@@ -108,31 +112,56 @@ export const useGalleryStore = defineStore("gallery", () => {
}
}
// Helper to run detection on a single image object
async function detectWatermarkForImage(img: ImageItem) {
try {
// Clear existing mask first
img.maskStrokes = [];
// @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) {
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 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 };
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 {
// @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 for ${img.name}`, e);
}
});
await Promise.all(batch);
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);
}
} finally {
isDetecting.value = false;
}
}
@@ -153,9 +182,6 @@ export const useGalleryStore = defineStore("gallery", () => {
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);
if (idx >= 0) {
setImageManualPosition(idx, result.x, result.y);
@@ -169,9 +195,9 @@ export const useGalleryStore = defineStore("gallery", () => {
}
}
async function processInpainting(index: number) {
const img = images.value[index];
if (!img || !img.maskStrokes || img.maskStrokes.length === 0) return;
// Internal helper for single image inpainting logic
async function runInpaintingForImage(img: ImageItem) {
if (!img.maskStrokes || img.maskStrokes.length === 0) return;
try {
const newPath = await invoke<string>("run_inpainting", {
@@ -179,20 +205,51 @@ export const useGalleryStore = defineStore("gallery", () => {
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);
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 {
// Parallel processing in batches to avoid overwhelming backend
const batchSize = 4;
for (let i = 0; i < candidates.length; i += batchSize) {
const batch = candidates.slice(i, i + batchSize).map(async (img) => {
await runInpaintingForImage(img);
progress.value.current++;
});
await Promise.all(batch);
}
alert("批量处理完成!");
} catch (e) {
alert("批量处理部分失败: " + e);
} finally {
isProcessing.value = false;
}
}
@@ -234,6 +291,18 @@ export const useGalleryStore = defineStore("gallery", () => {
}
}
function nextImage() {
if (images.value.length === 0) return;
const nextIndex = (selectedIndex.value + 1) % images.value.length;
selectImage(nextIndex);
}
function prevImage() {
if (images.value.length === 0) return;
const prevIndex = (selectedIndex.value - 1 + images.value.length) % images.value.length;
selectImage(prevIndex);
}
return {
images,
selectedIndex,
@@ -241,6 +310,9 @@ export const useGalleryStore = defineStore("gallery", () => {
editMode,
watermarkSettings,
brushSettings,
isDetecting,
isProcessing,
progress,
setImages,
selectImage,
updateWatermarkSettings,
@@ -249,9 +321,13 @@ export const useGalleryStore = defineStore("gallery", () => {
applySettingsToAll,
addMaskStroke,
clearMask,
detectCurrentWatermark,
detectAllWatermarks,
recalcAllWatermarks,
processInpainting,
restoreImage
processAllInpainting,
restoreImage,
nextImage,
prevImage
};
});

View File

@@ -5,4 +5,24 @@
src: url('/fonts/Roboto-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background-color: #1f2937; /* gray-800 */
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #4b5563; /* gray-600 */
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background-color: #6b7280; /* gray-500 */
}