Compare commits

...

33 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
Julian Freeman
84ae92d1e3 fix export bug 2026-01-19 13:18:57 -04:00
Julian Freeman
6cb5b7a61f ocr cover fix 2026-01-19 12:58:05 -04:00
Julian Freeman
eb251b5eac ocr detect 2026-01-19 12:42:05 -04:00
Julian Freeman
6439759b04 support restore 2026-01-19 12:18:08 -04:00
Julian Freeman
f96033e421 lama remove 2026-01-19 12:08:36 -04:00
Julian Freeman
64d6e770ad fs permission 2026-01-19 11:15:09 -04:00
Julian Freeman
3205c744cf metadata and icon 2026-01-19 10:59:17 -04:00
Julian Freeman
0b8f060c6f locale 2026-01-19 09:32:12 -04:00
Julian Freeman
2a468518af remove watermarks but sucks 2026-01-19 09:21:38 -04:00
Julian Freeman
713f0885dc fix apply 2026-01-19 08:58:45 -04:00
Julian Freeman
e1f2c8efc8 apply custom text 2026-01-19 08:41:45 -04:00
Julian Freeman
16bb3e5135 remove 2026-01-19 07:48:32 -04:00
Julian Freeman
358aae92dc seperate color 2026-01-19 00:53:11 -04:00
Julian Freeman
fd90ac1df3 seperate size 2026-01-19 00:48:16 -04:00
Julian Freeman
3e5d5aa848 seperate adjust 2026-01-19 00:41:28 -04:00
Julian Freeman
de0ed2bdc2 watermark pos 2026-01-19 00:27:47 -04:00
Julian Freeman
f34021eae1 watermark pos bug2 2026-01-19 00:18:08 -04:00
Julian Freeman
a0822153c1 watermark pos bug 2026-01-19 00:12:04 -04:00
Julian Freeman
54408a5933 add black border 2026-01-19 00:03:14 -04:00
Julian Freeman
a5f1b165fd fix wateramrk 2026-01-18 23:55:14 -04:00
Julian Freeman
0c307c319a fix preview 2026-01-18 23:45:11 -04:00
Julian Freeman
0c5824d85c phase 1 & 2 add text mark 2026-01-18 23:22:52 -04:00
74 changed files with 2911 additions and 122 deletions

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
*.onnx
*.dll

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,7 +1,7 @@
# Tauri + Vue + TypeScript
# Watermark Wizard
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Generated by Gemini
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
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

BIN
app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + Vue + Typescript App</title>
<title>Watermark Wizard</title>
</head>
<body>

View File

@@ -1,7 +1,7 @@
{
"name": "watermark-wizard",
"private": true,
"version": "0.1.0",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,6 +13,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"autoprefixer": "^10.4.23",
"lucide-vue-next": "^0.562.0",
"pinia": "^3.0.4",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",

12
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
autoprefixer:
specifier: ^10.4.23
version: 10.4.23(postcss@8.5.6)
lucide-vue-next:
specifier: ^0.562.0
version: 0.562.0(vue@3.5.26(typescript@5.6.3))
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.6.3)(vue@3.5.26(typescript@5.6.3))
@@ -782,6 +785,11 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
lucide-vue-next@0.562.0:
resolution: {integrity: sha512-LN0BLGKMFulv0lnfK29r14DcngRUhIqdcaL0zXTt2o0oS9odlrjCGaU3/X9hIihOjjN8l8e+Y9G/famcNYaI7Q==}
peerDependencies:
vue: '>=3.0.1'
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1524,6 +1532,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
lucide-vue-next@0.562.0(vue@3.5.26(typescript@5.6.3)):
dependencies:
vue: 3.5.26(typescript@5.6.3)
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5

Binary file not shown.

557
src-tauri/Cargo.lock generated
View File

@@ -2,6 +2,22 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "ab_glyph"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
dependencies = [
"ab_glyph_rasterizer",
"owned_ttf_parser",
]
[[package]]
name = "ab_glyph_rasterizer"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "adler2"
version = "2.0.1"
@@ -65,6 +81,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -187,6 +212,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -451,6 +482,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -474,9 +515,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -487,7 +528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -640,6 +681,16 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.5"
@@ -845,6 +896,16 @@ dependencies = [
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "exr"
version = "1.74.0"
@@ -860,6 +921,12 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fax"
version = "0.2.6"
@@ -921,6 +988,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -928,7 +1004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -942,6 +1018,12 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1175,8 +1257,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1390,6 +1474,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac-sha256"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425"
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -1668,6 +1758,24 @@ dependencies = [
"quick-error",
]
[[package]]
name = "imageproc"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d"
dependencies = [
"ab_glyph",
"approx",
"getrandom 0.2.17",
"image",
"itertools 0.12.1",
"nalgebra",
"num",
"rand 0.8.5",
"rand_distr",
"rayon",
]
[[package]]
name = "imgref"
version = "1.12.0"
@@ -1733,6 +1841,15 @@ dependencies = [
"serde",
]
[[package]]
name = "itertools"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1890,7 +2007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
dependencies = [
"gtk-sys",
"libloading",
"libloading 0.7.4",
"once_cell",
]
@@ -1920,6 +2037,22 @@ dependencies = [
"winapi",
]
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link 0.2.1",
]
[[package]]
name = "libm"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.12"
@@ -1930,6 +2063,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
version = "0.8.1"
@@ -1960,6 +2099,12 @@ dependencies = [
"imgref",
]
[[package]]
name = "lzma-rust2"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69"
[[package]]
name = "mac"
version = "0.1.1"
@@ -1997,6 +2142,16 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matrixmultiply"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -2080,6 +2235,68 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "nalgebra"
version = "0.32.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4"
dependencies = [
"approx",
"matrixmultiply",
"num-complex",
"num-rational",
"num-traits",
"simba",
"typenum",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndarray"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2137,6 +2354,20 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -2147,6 +2378,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
@@ -2173,6 +2413,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -2191,6 +2442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
@@ -2433,12 +2685,90 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ort"
version = "2.0.0-rc.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c"
dependencies = [
"libloading 0.9.0",
"ndarray 0.17.2",
"ort-sys",
"smallvec",
"tracing",
"ureq",
]
[[package]]
name = "ort-sys"
version = "2.0.0-rc.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b"
dependencies = [
"hmac-sha256",
"lzma-rust2",
"ureq",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
dependencies = [
"ttf-parser",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2499,6 +2829,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2696,6 +3035,21 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "portable-atomic"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2953,6 +3307,16 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_distr"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
dependencies = [
"num-traits",
"rand 0.8.5",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -2987,7 +3351,7 @@ dependencies = [
"built",
"cfg-if",
"interpolate_name",
"itertools",
"itertools 0.14.0",
"libc",
"libfuzzer-sys",
"log",
@@ -3027,6 +3391,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.11.0"
@@ -3190,6 +3560,28 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3202,6 +3594,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
[[package]]
name = "safe_arch"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
dependencies = [
"bytemuck",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3211,6 +3612,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3268,6 +3678,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -3483,6 +3916,19 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "simba"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae"
dependencies = [
"approx",
"num-complex",
"num-traits",
"paste",
"wide",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
@@ -3532,6 +3978,17 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "softbuffer"
version = "0.4.8"
@@ -3691,7 +4148,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [
"bitflags 2.10.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -4013,6 +4470,19 @@ dependencies = [
"toml 0.9.11+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
@@ -4334,6 +4804,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4399,6 +4875,36 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "ureq"
version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
dependencies = [
"base64 0.22.1",
"der",
"log",
"native-tls",
"percent-encoding",
"rustls-pki-types",
"socks",
"ureq-proto",
"utf-8",
"webpki-root-certs",
]
[[package]]
name = "ureq-proto"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
dependencies = [
"base64 0.22.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -4459,6 +4965,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4605,9 +5117,13 @@ dependencies = [
[[package]]
name = "watermark-wizard"
version = "0.1.0"
version = "1.0.1"
dependencies = [
"ab_glyph",
"image",
"imageproc",
"ndarray 0.16.1",
"ort",
"rayon",
"serde",
"serde_json",
@@ -4670,6 +5186,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4712,6 +5237,16 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wide"
version = "0.7.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -5313,6 +5848,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

View File

@@ -1,8 +1,8 @@
[package]
name = "watermark-wizard"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
version = "1.0.1"
description = "A Tauri App handles watermarks"
authors = ["Julian"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -21,7 +21,11 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = ["protocol-asset"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
image = "0.25"
image = { version = "0.25", features = ["png", "jpeg", "webp"] }
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", "directml"] }
ndarray = "0.16"

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

133
src-tauri/src/lama.rs Normal file
View File

@@ -0,0 +1,133 @@
use crate::ort_ops;
use image::{Rgba, RgbaImage};
use ort::value::Value;
pub fn run_lama_inpainting(
model_path: &std::path::Path,
input_image: &RgbaImage,
mask_image: &image::GrayImage,
) -> Result<RgbaImage, String> {
// 1. Initialize Session
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);
let resized_img = image::imageops::resize(input_image, target_size.0, target_size.1, image::imageops::FilterType::Triangle);
let resized_mask = image::imageops::resize(mask_image, target_size.0, target_size.1, image::imageops::FilterType::Triangle);
// Flatten Image to Vec<f32> (NCHW: 1, 3, 512, 512)
let channel_stride = (target_size.0 * target_size.1) as usize;
let mut input_data: Vec<f32> = Vec::with_capacity(1 * 3 * channel_stride);
// We need to fill R plane, then G plane, then B plane
let mut r_plane: Vec<f32> = Vec::with_capacity(channel_stride);
let mut g_plane: Vec<f32> = Vec::with_capacity(channel_stride);
let mut b_plane: Vec<f32> = Vec::with_capacity(channel_stride);
for (_x, _y, pixel) in resized_img.enumerate_pixels() {
r_plane.push(pixel[0] as f32 / 255.0f32);
g_plane.push(pixel[1] as f32 / 255.0f32);
b_plane.push(pixel[2] as f32 / 255.0f32);
}
input_data.extend(r_plane);
input_data.extend(g_plane);
input_data.extend(b_plane);
// Flatten Mask to Vec<f32> (NCHW: 1, 1, 512, 512)
let mut mask_data: Vec<f32> = Vec::with_capacity(channel_stride);
for (_x, _y, pixel) in resized_mask.enumerate_pixels() {
let val = if pixel[0] > 127 { 1.0f32 } else { 0.0f32 };
mask_data.push(val);
}
// 3. Inference
// Use (Shape, Data) tuple which implements OwnedTensorArrayData
// Explicitly casting shape to i64 is correct for ORT
let input_shape = vec![1, 3, target_size.1 as i64, target_size.0 as i64];
let input_value = Value::from_array((input_shape, input_data))
.map_err(|e| format!("Failed to create input tensor: {}", e))?;
let mask_shape = vec![1, 1, target_size.1 as i64, target_size.0 as i64];
let mask_value = Value::from_array((mask_shape, mask_data))
.map_err(|e| format!("Failed to create mask tensor: {}", e))?;
let inputs = ort::inputs![
"image" => input_value,
"mask" => mask_value
];
let outputs = session.run(inputs).map_err(|e| format!("Inference failed: {}", e))?;
// Get output tensor
// Just take the first output.
let output_tensor_ref = outputs.values().next()
.ok_or("No output tensor produced by model")?;
let (shape, data) = output_tensor_ref.try_extract_tensor::<f32>()
.map_err(|e| format!("Failed to extract tensor: {}", e))?;
// 4. Post-process
let mut output_img_512 = RgbaImage::new(target_size.0, target_size.1);
if shape.len() < 4 {
return Err(format!("Unexpected output shape: {:?}", shape));
}
let h = 512;
let w = 512;
let channel_stride = (h * w) as usize;
// Safety check on data length
if data.len() < (3 * h * w) as usize {
return Err(format!("Output data size mismatch. Expected {}, got {}", 3*h*w, data.len()));
}
// Auto-detect output range
// If values are already in 0-255 range, multiplying by 255 results in all white image.
let mut max_val = 0.0f32;
// Check a subset of pixels to avoid iterating everything if speed is key, but full scan is safer and fast enough.
for v in data.iter().take(1000) {
if *v > max_val { max_val = *v; }
}
// Heuristic: if max > 2.0, it's likely 0-255. If it's <= 1.0 (or slightly above due to overshoot), it's 0-1.
// LaMa usually outputs -1..1 or 0..1. But some exports differ.
// Let's assume if any value is > 5.0, it is definitely not 0-1 normalized.
let scale_factor = if max_val > 2.0 { 1.0 } else { 255.0 };
for y in 0..h {
for x in 0..w {
let offset = (y * w + x) as usize;
let r_idx = offset;
let g_idx = offset + channel_stride;
let b_idx = offset + 2 * channel_stride;
let r = (data[r_idx] * scale_factor).clamp(0.0, 255.0) as u8;
let g = (data[g_idx] * scale_factor).clamp(0.0, 255.0) as u8;
let b = (data[b_idx] * scale_factor).clamp(0.0, 255.0) as u8;
output_img_512.put_pixel(x, y, Rgba([r, g, b, 255]));
}
}
// Resize back to original
let (orig_w, orig_h) = input_image.dimensions();
let final_inpainted = image::imageops::resize(&output_img_512, orig_w, orig_h, image::imageops::FilterType::Lanczos3);
// 5. Blending
let mut result_image = input_image.clone();
for y in 0..orig_h {
for x in 0..orig_w {
if mask_image.get_pixel(x, y)[0] > 127 {
result_image.put_pixel(x, y, *final_inpainted.get_pixel(x, y));
}
}
}
Ok(result_image)
}

View File

@@ -1,32 +1,94 @@
use std::fs;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::env;
#[derive(serde::Serialize)]
#[derive(serde::Serialize, Clone)]
struct ImageItem {
path: String,
name: String,
thumbnail: String,
}
fn get_cache_dir() -> std::path::PathBuf {
let mut path = env::temp_dir();
path.push("watermark-wizard-thumbs");
if !path.exists() {
let _ = fs::create_dir_all(&path);
}
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();
// Generate simple hash for filename
let mut hasher = DefaultHasher::new();
original_path.hash(&mut hasher);
let hash = hasher.finish();
let file_name = format!("{}.jpg", hash);
let thumb_path = cache_dir.join(file_name);
// Return if exists
if thumb_path.exists() {
return Some(thumb_path.to_string_lossy().to_string());
}
// Generate
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()?;
return Some(thumb_path.to_string_lossy().to_string());
}
None
}
#[tauri::command]
fn scan_dir(path: String) -> Result<Vec<ImageItem>, String> {
let mut images = Vec::new();
let dir = fs::read_dir(&path).map_err(|e| e.to_string())?;
async fn scan_dir(path: String) -> Result<Vec<ImageItem>, String> {
let entries = fs::read_dir(&path).map_err(|e| e.to_string())?;
for entry in dir {
// Collect valid paths first to avoid holding fs locks or iterators during parallel proc
let mut valid_paths = Vec::new();
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
let p = entry.path();
if p.is_file() {
if let Some(ext) = p.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if ["png", "jpg", "jpeg", "webp"].contains(&ext_str.as_str()) {
images.push(ImageItem {
path: path.to_string_lossy().to_string(),
name: path.file_name().unwrap_or_default().to_string_lossy().to_string(),
});
valid_paths.push(p);
}
}
}
}
}
// Process in parallel
let mut images: Vec<ImageItem> = valid_paths.par_iter().filter_map(|path| {
let name = path.file_name()?.to_string_lossy().to_string();
let path_str = path.to_string_lossy().to_string();
// Generate thumbnail
let thumb = generate_thumbnail(path).unwrap_or_else(|| path_str.clone());
Some(ImageItem {
path: path_str,
name,
thumbnail: thumb,
})
}).collect();
// Sort by name
images.sort_by(|a, b| a.name.cmp(&b.name));
Ok(images)
@@ -34,6 +96,18 @@ fn scan_dir(path: String) -> Result<Vec<ImageItem>, String> {
use image::GenericImageView;
use image::Pixel;
use rayon::prelude::*;
use std::path::Path;
use imageproc::drawing::draw_text_mut;
use ab_glyph::{FontRef, PxScale};
use tauri::{AppHandle, Manager};
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");
#[derive(serde::Serialize)]
struct ZcaResult {
@@ -42,28 +116,227 @@ struct ZcaResult {
zone: String,
}
#[derive(serde::Deserialize)]
struct ExportImageTask {
path: String,
output_filename: Option<String>,
manual_position: Option<ManualPosition>,
scale: Option<f64>,
opacity: Option<f64>,
color: Option<String>,
}
#[derive(serde::Deserialize)]
struct WatermarkSettings {
#[serde(rename = "type")]
_w_type: String, // 'text' (image is deprecated for now per user request, but keeping struct flexible)
text: String, // Was 'source'
color: String, // Hex code e.g. "#FFFFFF"
opacity: f64,
scale: f64, // Font size relative to image height (e.g., 0.05 = 5% of height)
// Global manual override is deprecated in favor of per-task control, but kept for struct compatibility if needed
_manual_override: bool,
_manual_position: ManualPosition,
}
#[derive(serde::Deserialize)]
struct ManualPosition {
x: f64,
y: f64,
}
fn parse_hex_color(hex: &str) -> image::Rgba<u8> {
let hex = hex.trim_start_matches('#');
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(255);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(255);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(255);
image::Rgba([r, g, b, 255])
}
#[tauri::command]
fn get_zca_suggestion(path: String) -> Result<ZcaResult, String> {
let img = image::open(&path).map_err(|e| e.to_string())?;
async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings, output_dir: String, mode: String) -> Result<String, String> {
let font = FontRef::try_from_slice(FONT_DATA).map_err(|e| format!("Font error: {}", e))?;
// Note: Settings are now resolved per-task
let results: Vec<Result<(), String>> = images.par_iter().map(|task| {
let input_path = Path::new(&task.path);
// 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();
// ONLY EXECUTE WATERMARK LOGIC IF MODE IS 'ADD'
if mode == "add" {
// Determine effective settings (Task > Global)
let eff_scale = task.scale.unwrap_or(watermark.scale);
let eff_opacity = task.opacity.unwrap_or(watermark.opacity);
let eff_color_hex = task.color.as_ref().unwrap_or(&watermark.color);
// Calculate final color
let base_color = parse_hex_color(eff_color_hex);
let alpha = (eff_opacity * 255.0) as u8;
let text_color = image::Rgba([base_color[0], base_color[1], base_color[2], alpha]);
// 1. Calculate Font Scale based on Image Height
let mut scale_px = height as f32 * eff_scale as f32;
// 2. Measure Text
let scaled_font = PxScale::from(scale_px);
let (t_width, _t_height) = imageproc::drawing::text_size(scaled_font, &font, &watermark.text);
// 3. Ensure it fits width (Padding 15%)
let max_width = (width as f32 * 0.85) as u32;
if t_width > max_width {
let ratio = max_width as f32 / t_width as f32;
scale_px *= ratio;
}
let final_scale = PxScale::from(scale_px);
let (final_t_width, final_t_height) = imageproc::drawing::text_size(final_scale, &font, &watermark.text);
// 4. Determine Position (Task Specific > ZCA)
// If task has manual_position, use it. Otherwise calculate ZCA.
let (pos_x_pct, pos_y_pct) = if let Some(pos) = &task.manual_position {
(pos.x, pos.y)
} else {
match calculate_zca_internal(&dynamic_img) {
Ok(res) => (res.x, res.y),
Err(_) => (0.5, 0.97),
}
};
// Calculate initial top-left based on center
let center_x = width as f64 * pos_x_pct;
let center_y = height as f64 * pos_y_pct;
let mut x = (center_x - (final_t_width as f64 / 2.0)) as i32;
let mut y = (center_y - (final_t_height as f64 / 2.0)) as i32;
// 5. Strict Boundary Clamping
// We ensure the text box (final_t_width, final_t_height) is always inside (0, 0, width, height)
let min_padding = 2; // Absolute minimum pixels from edge
if x < min_padding { x = min_padding; }
if y < min_padding { y = min_padding; }
if x + final_t_width as i32 > width as i32 - min_padding {
x = width as i32 - final_t_width as i32 - min_padding;
}
if y + final_t_height as i32 > height as i32 - min_padding {
y = height as i32 - final_t_height as i32 - min_padding;
}
// Re-clamp just in case of very small images where text is larger than image
x = x.max(0);
y = y.max(0);
// SKIP DRAWING if text is empty (e.g. Remove Mode)
if !watermark.text.trim().is_empty() {
// 6. Draw Stroke (Simple 4-direction offset for black outline)
// Stroke alpha should match text alpha
let stroke_color = image::Rgba([0, 0, 0, text_color[3]]);
for offset in [(-1, -1), (-1, 1), (1, -1), (1, 1)] {
draw_text_mut(
&mut base_img,
stroke_color,
x + offset.0,
y + offset.1,
final_scale,
&font,
&watermark.text,
);
}
// 7. Draw Main Text
draw_text_mut(
&mut base_img,
text_color,
x,
y,
final_scale,
&font,
&watermark.text,
);
}
} // END IF MODE == ADD
// Save
// 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.
let ext = output_path.extension().and_then(|s| s.to_str()).unwrap_or("").to_lowercase();
if ext == "jpg" || ext == "jpeg" {
// Convert to RGB8 (dropping alpha)
// Note: This simply drops alpha. If background was transparent, it becomes black.
// For photos (JPEGs) this is usually fine as they don't have alpha.
let rgb_img = image::DynamicImage::ImageRgba8(base_img).to_rgb8();
rgb_img.save(&output_path).map_err(|e| e.to_string())?;
} else {
// For PNG/WebP etc, keep RGBA
base_img.save(&output_path).map_err(|e| e.to_string())?;
}
Ok(())
} else {
Err(img_result.unwrap_err())
}
}).collect();
let failures: Vec<String> = results.into_iter().filter_map(|r| r.err()).collect();
if failures.is_empty() {
Ok("All images processed successfully".to_string())
} else {
Err(format!("Completed with errors: {:?}", failures))
}
}
// Helper to reuse logic (adapted from command)
fn calculate_zca_internal(img: &image::DynamicImage) -> Result<ZcaResult, String> {
let (width, height) = img.dimensions();
let bottom_start_y = (height as f64 * 0.8) as u32;
let zone_height = height - bottom_start_y;
let zone_width = width / 3;
let zones = [
("Left", 0, bottom_start_y),
("Center", zone_width, bottom_start_y),
("Right", zone_width * 2, bottom_start_y),
];
let mut min_std_dev = f64::MAX;
let mut best_zone = "Center";
let mut best_pos = (0.5, 0.9); // Default center
// Greedy Layered Search
// Priority: Bottom -> Up
let y_levels = [0.97, 0.94, 0.91, 0.88];
let x_cols = [1.0/6.0, 3.0/6.0, 5.0/6.0]; // Left, Center, Right centers
let col_names = ["Left", "Center", "Right"];
for (name, start_x, start_y) in zones.iter() {
let mut luma_values = Vec::with_capacity((zone_width * zone_height) as usize);
for y in *start_y..height {
for x in *start_x..(*start_x + zone_width) {
if x >= width { continue; }
// Box Size for analysis (approx watermark size)
let box_w = (width as f64 * 0.30) as u32;
let box_h = (height as f64 * 0.05) as u32;
let half_box_w = box_w / 2;
let half_box_h = box_h / 2;
let mut global_best_score = f64::MAX;
let mut global_best_result = ZcaResult { x: 0.5, y: 0.97, zone: "Center".to_string() };
for &y_pct in y_levels.iter() {
let mut row_best_score = f64::MAX;
let mut row_best_idx = 1; // Default Center
let mut row_stats = Vec::new(); // (mean, std_dev)
for (col_idx, &x_pct) in x_cols.iter().enumerate() {
let cx = (width as f64 * x_pct) as u32;
let cy = (height as f64 * y_pct) as u32;
let start_x = if cx > half_box_w { cx - half_box_w } else { 0 };
let start_y = if cy > half_box_h { cy - half_box_h } else { 0 };
let end_x = (start_x + box_w).min(width);
let end_y = (start_y + box_h).min(height);
let mut luma_values = Vec::with_capacity((box_w * box_h) as usize);
for y in start_y..end_y {
for x in start_x..end_x {
let pixel = img.get_pixel(x, y);
let rgb = pixel.to_rgb();
let luma = 0.299 * rgb[0] as f64 + 0.587 * rgb[1] as f64 + 0.114 * rgb[2] as f64;
@@ -72,36 +345,426 @@ fn get_zca_suggestion(path: String) -> Result<ZcaResult, String> {
}
let count = luma_values.len() as f64;
if count == 0.0 { continue; }
if count == 0.0 {
row_stats.push((0.0, f64::MAX));
continue;
}
let mean = luma_values.iter().sum::<f64>() / count;
let variance = luma_values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / count;
let std_dev = variance.sqrt();
if std_dev < min_std_dev {
min_std_dev = std_dev;
best_zone = name;
let center_x_px = *start_x as f64 + (zone_width as f64 / 2.0);
// Position closer to bottom (75% of the zone height instead of 50%)
// Zone starts at 80%. Height is 20%. 0.8 + 0.2 * 0.75 = 0.95
let center_y_px = *start_y as f64 + (zone_height as f64 * 0.75);
best_pos = (center_x_px / width as f64, center_y_px / height as f64);
row_stats.push((mean, std_dev));
// For choosing "Best in Row", we strictly prefer Flatness (StdDev)
if std_dev < row_best_score {
row_best_score = std_dev;
row_best_idx = col_idx;
}
// Update Global Best (fallback)
if std_dev < global_best_score {
global_best_score = std_dev;
global_best_result = ZcaResult {
x: x_pct,
y: y_pct,
zone: col_names[col_idx].to_string(),
};
}
}
Ok(ZcaResult {
x: best_pos.0,
y: best_pos.1,
zone: best_zone.to_string(),
// Analyze the Best Zone in this Row
let (mean, std_dev) = row_stats[row_best_idx];
// Safety Check: Is this zone "White Text"?
// Condition: Mean > 180 (Bright-ish) AND StdDev > 20 (Busy/Text)
let is_unsafe_white_text = mean > 180.0 && std_dev > 20.0;
let is_unsafe_bright = mean > 230.0;
if !is_unsafe_white_text && !is_unsafe_bright {
// Safe!
return Ok(ZcaResult {
x: x_cols[row_best_idx],
y: y_pct,
zone: col_names[row_best_idx].to_string(),
});
}
}
Ok(global_best_result)
}
#[tauri::command]
fn get_zca_suggestion(path: String) -> Result<ZcaResult, String> {
let img = load_image_safe(Path::new(&path))?;
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 = 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))?;
// 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(app: AppHandle, path: String) -> Result<DetectionResult, String> {
let img = load_image_safe(Path::new(&path))?.to_rgba8();
// 1. Try OCR Detection
let ocr_model_path = app.path().resource_dir()
.map_err(|e| e.to_string())?
.join("resources")
.join("en_PP-OCRv3_det_infer.onnx");
if ocr_model_path.exists() {
println!("Using OCR model for detection");
match ocr::run_ocr_detection(&ocr_model_path, &img) {
Ok(boxes) => {
let rects = boxes.into_iter().map(|b| Rect {
x: b.x,
y: b.y,
width: b.width,
height: b.height,
}).collect();
return Ok(DetectionResult { rects });
}
Err(e) => {
eprintln!("OCR Detection failed: {}", e);
// Fallthrough to legacy
}
}
} else {
eprintln!("OCR model not found at {:?}", ocr_model_path);
}
// 2. Legacy Detection (Fallback)
println!("Falling back to legacy detection");
let (width, height) = img.dimensions();
let gray = image::DynamicImage::ImageRgba8(img.clone()).to_luma8();
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.15) as u32;
let bottom_start = (height as f64 * 0.75) as u32;
let max_stroke_width = 15;
let contrast_threshold = 40;
let brightness_threshold = 200;
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];
if p < brightness_threshold { continue; }
let mut is_stroke = false;
let mut left_bound = false;
let mut right_bound = false;
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;
}
}
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;
}
}
}
if left_bound && right_bound {
is_stroke = true;
} else {
let mut up_bound = false;
let mut down_bound = false;
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;
}
}
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 gy = y / cell_size;
grid[(gy * grid_w + gx) as usize] = true;
}
}
}
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] {
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; }
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));
}
}
}
}
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 })
}
#[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(app: AppHandle, path: String, strokes: Vec<MaskStroke>) -> Result<String, String> {
let img = load_image_safe(Path::new(&path))?.to_rgba8();
let (width, height) = img.dimensions();
// 1. Create Gray Mask (0 = keep, 255 = remove)
let mut mask = image::GrayImage::new(width, height);
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;
// Draw 255 on mask
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.put_pixel(x as u32, y as u32, image::Luma([255]));
}
}
}
},
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.put_pixel(nx as u32, ny as u32, image::Luma([255]));
}
}
}
}
}
}
}
}
}
// 2. Resolve Model Path
let model_path = app.path().resource_dir()
.map_err(|e| e.to_string())?
.join("resources")
.join("lama_fp32.onnx");
if !model_path.exists() {
return Err("Model file 'lama_fp32.onnx' not found in resources.".to_string());
}
// 3. Run Inference
// This is computationally heavy, maybe run in thread? Tauri async commands are already threaded.
let result_img = lama::run_lama_inpainting(&model_path, &img, &mask)?;
// 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);
result_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)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![scan_dir, get_zca_suggestion])
.invoke_handler(tauri::generate_handler![scan_dir, get_zca_suggestion, export_batch, detect_watermark, layout_watermark, run_inpainting])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

202
src-tauri/src/ocr.rs Normal file
View File

@@ -0,0 +1,202 @@
use crate::ort_ops;
use image::{GenericImageView, Rgba, RgbaImage};
use ort::value::Value;
use std::path::Path;
#[derive(Clone, Debug)]
pub struct DetectedBox {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
pub fn run_ocr_detection(
model_path: &Path,
input_image: &RgbaImage,
) -> Result<Vec<DetectedBox>, String> {
// 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
// Mean: [0.485, 0.456, 0.406], Std: [0.229, 0.224, 0.225]
// 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;
// Resize logic: Limit max side, preserve aspect, ensure divisible by 32
if resize_w > max_side || resize_h > max_side {
let ratio = max_side as f64 / (orig_w.max(orig_h) as f64);
resize_w = (orig_w as f64 * ratio) as u32;
resize_h = (orig_h as f64 * ratio) as u32;
}
// Align to 32
resize_w = (resize_w + 31) / 32 * 32;
resize_h = (resize_h + 31) / 32 * 32;
// Minimum size
resize_w = resize_w.max(32);
resize_h = resize_h.max(32);
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);
let mut r_plane = Vec::with_capacity(channel_stride);
let mut g_plane = Vec::with_capacity(channel_stride);
let mut b_plane = Vec::with_capacity(channel_stride);
let mean = [0.485, 0.456, 0.406];
let std = [0.229, 0.224, 0.225];
for (_x, _y, pixel) in resized.enumerate_pixels() {
let r = pixel[0] as f32 / 255.0;
let g = pixel[1] as f32 / 255.0;
let b = pixel[2] as f32 / 255.0;
r_plane.push((r - mean[0]) / std[0]);
g_plane.push((g - mean[1]) / std[1]);
b_plane.push((b - mean[2]) / std[2]);
}
input_data.extend(r_plane);
input_data.extend(g_plane);
input_data.extend(b_plane);
// 3. Inference
let input_shape = vec![1, 3, resize_h as i64, resize_w as i64];
let input_value = Value::from_array((input_shape, input_data))
.map_err(|e| format!("Failed to create input tensor: {}", e))?;
let inputs = ort::inputs![input_value]; // For PP-OCR, usually just one input "x"
let outputs = session.run(inputs).map_err(|e| format!("Inference failed: {}", e))?;
let output_tensor = outputs.values().next().ok_or("No output")?;
let (shape, data) = output_tensor.try_extract_tensor::<f32>()
.map_err(|e| format!("Failed to extract output: {}", e))?;
// 4. Post-process
// Output shape is [1, 1, H, W] probability map
if shape.len() < 4 {
return Err("Unexpected output shape".to_string());
}
let map_w = shape[3] as u32;
let map_h = shape[2] as u32;
// Create binary map (threshold 0.3)
let threshold = 0.3;
let mut binary_map = vec![false; (map_w * map_h) as usize];
for i in 0..binary_map.len() {
if data[i] > threshold {
binary_map[i] = true;
}
}
// Find Connected Components (Simple Bounding Box finding)
let mut visited = vec![false; binary_map.len()];
let mut boxes = Vec::new();
for y in 0..map_h {
for x in 0..map_w {
let idx = (y * map_w + x) as usize;
if binary_map[idx] && !visited[idx] {
// Flood fill
let mut stack = vec![(x as u32, y as u32)];
visited[idx] = true;
let mut min_x = x;
let mut max_x = x;
let mut min_y = y;
let mut max_y = y;
let mut pixel_count = 0;
while let Some((cx, cy)) = stack.pop() {
pixel_count += 1;
if cx < min_x { min_x = cx; }
if cx > max_x { max_x = cx; }
if cy < min_y { min_y = cy; }
if cy > max_y { max_y = cy; }
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 < map_w && ny < map_h {
let nidx = (ny * map_w + nx) as usize;
if binary_map[nidx] && !visited[nidx] {
visited[nidx] = true;
stack.push((nx, ny));
}
}
}
}
// Filter small noise
if pixel_count < 10 { continue; }
// Calculate Scale Factors
let scale_x = orig_w as f64 / resize_w as f64;
let scale_y = orig_h as f64 / resize_h as f64;
// Map to raw coordinates in map space
let raw_w = (max_x - min_x + 1) as f64;
let raw_h = (max_y - min_y + 1) as f64;
// --- ASPECT RATIO FILTERING ---
// Watermarks are typically horizontal text lines.
// A cross or vertical pillar will have a small width/height ratio.
let aspect_ratio = raw_w / raw_h;
if aspect_ratio < 1.5 {
continue; // Skip vertical or square-ish non-text objects
}
// --- PADDING / DILATION ---
let pad_x = raw_w * 0.15; // 15% horizontal is usually enough
let pad_y = raw_h * 1.00; // Increased to 100% for aggressive vertical coverage
let box_x = (min_x as f64 - pad_x).max(0.0);
let box_y = (min_y as f64 - pad_y).max(0.0);
let box_w = raw_w + 2.0 * pad_x;
let box_h = raw_h + 2.0 * pad_y;
// Convert to Normalized Image Coordinates [0, 1]
boxes.push(DetectedBox {
x: (box_x * scale_x) / orig_w as f64,
y: (box_y * scale_y) / orig_h as f64,
width: (box_w * scale_x) / orig_w as f64,
height: (box_h * scale_y) / orig_h as f64,
});
}
}
}
Ok(boxes)
}

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,13 +12,13 @@
"app": {
"windows": [
{
"title": "watermark-wizard",
"width": 800,
"height": 600
"title": "水印精灵 v1.0.1",
"width": 1650,
"height": 1000
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost",
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost https://asset.localhost blob: data:",
"assetProtocol": {
"enable": true,
"scope": ["**"]
@@ -28,6 +28,7 @@
"bundle": {
"active": true,
"targets": "all",
"resources": ["resources/lama_fp32.onnx", "resources/en_PP-OCRv3_det_infer.onnx"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View File

@@ -1,11 +1,31 @@
<script setup lang="ts">
import HeroView from "./components/HeroView.vue";
import ThumbnailStrip from "./components/ThumbnailStrip.vue";
import SettingsPanel from "./components/SettingsPanel.vue";
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, 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 {
@@ -25,26 +45,108 @@ async function openFolder() {
console.error("Failed to open folder:", e);
}
}
async function exportBatch() {
if (store.images.length === 0) return;
// Only require text if in ADD mode
if (store.editMode === 'add' && !store.watermarkSettings.text) {
alert("请输入水印文字。");
return;
}
try {
const outputDir = await open({
directory: true,
multiple: false,
title: "选择输出目录"
});
if (outputDir && typeof outputDir === 'string') {
isExporting.value = true;
// Map images to include manual settings
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
const rustWatermarkSettings = {
...store.watermarkSettings,
_manual_override: false,
_manual_position: { x: 0.5, y: 0.5 }
};
await invoke('export_batch', {
images: exportTasks,
watermark: rustWatermarkSettings,
outputDir: outputDir,
mode: store.editMode
});
alert("批量导出完成!");
}
} catch (e) {
console.error("Export failed:", e);
alert("导出失败: " + e);
} finally {
isExporting.value = false;
}
}
</script>
<template>
<div class="h-screen w-screen bg-gray-900 text-white overflow-hidden flex flex-col">
<header class="h-12 bg-gray-800 flex items-center justify-between px-4 border-b border-gray-700 shrink-0">
<h1 class="text-sm font-bold tracking-wider">WATERMARK WIZARD</h1>
<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-linear-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">水印精灵</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="openFolder"
class="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded text-sm transition-colors"
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"
>
Open Folder
<FolderOpen class="w-4 h-4" />
打开文件夹
</button>
<button
@click="exportBatch"
:disabled="isExporting"
class="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:cursor-not-allowed text-white px-4 py-2 rounded-md text-sm font-medium transition-all hover:shadow-lg shadow-blue-900/20"
>
<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>
{{ isExporting ? '导出中...' : '批量导出' }}
</button>
</div>
</header>
<main class="flex-1 relative bg-black overflow-hidden">
<div class="flex-1 flex overflow-hidden">
<main class="flex-1 relative bg-black flex flex-col min-w-0">
<div class="flex-1 relative overflow-hidden">
<HeroView />
</main>
<footer class="h-32 bg-gray-800 border-t border-gray-700 shrink-0">
</div>
<footer class="h-32 bg-gray-800 border-t border-gray-700 shrink-0 z-10">
<ThumbnailStrip />
</footer>
</main>
<aside class="w-80 shrink-0 h-full border-l border-gray-700">
<SettingsPanel />
</aside>
</div>
</div>
</template>

View File

@@ -1,40 +1,322 @@
<script setup lang="ts">
import { useGalleryStore } from "../stores/gallery";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
const store = useGalleryStore();
const isDragging = ref(false);
const dragStart = ref({ x: 0, y: 0 });
const imgRef = ref<HTMLImageElement | null>(null);
const parentRef = ref<HTMLElement | null>(null); // The black background container
// These dimensions will exactly match the rendered image size
const imageRect = ref({ width: 0, height: 0 });
let resizeObserver: ResizeObserver | null = null;
const calculateLayout = () => {
if (!imgRef.value || !parentRef.value || !store.selectedImage) return;
// Wait for image natural dimensions to be available
const natW = imgRef.value.naturalWidth;
const natH = imgRef.value.naturalHeight;
if (!natW || !natH) return; // Not loaded yet
const parentW = parentRef.value.clientWidth;
const parentH = parentRef.value.clientHeight;
// Calculate 'contain' fit manually
const scale = Math.min(
(parentW - 64) / natW, // 64px = 2rem padding * 2 sides (p-8)
(parentH - 64) / natH
);
const finalW = Math.floor(natW * scale);
const finalH = Math.floor(natH * scale);
imageRect.value = { width: finalW, height: finalH };
};
onMounted(() => {
// Observe the parent container (window size changes)
if (parentRef.value) {
resizeObserver = new ResizeObserver(() => {
calculateLayout();
});
resizeObserver.observe(parentRef.value);
}
window.addEventListener('resize', calculateLayout);
});
onUnmounted(() => {
if (resizeObserver) resizeObserver.disconnect();
window.removeEventListener('resize', calculateLayout);
});
// Re-calculate when image changes
watch(() => store.selectedImage, () => {
// Reset size until loaded to avoid jump
// imageRect.value = { width: 0, height: 0 };
nextTick(() => calculateLayout());
});
// Use either manual position (if override is true) or ZCA suggestion
const position = computed(() => {
if (store.selectedImage?.manualPosition) {
return store.selectedImage.manualPosition;
}
// Default to bottom center if no ZCA
return store.selectedImage?.zcaSuggestion ? { x: store.selectedImage.zcaSuggestion.x, y: store.selectedImage.zcaSuggestion.y } : { x: 0.5, y: 0.97 };
});
const effectiveScale = computed(() => store.selectedImage?.scale ?? store.watermarkSettings.scale);
const effectiveOpacity = computed(() => store.selectedImage?.opacity ?? store.watermarkSettings.opacity);
const effectiveColor = computed(() => store.selectedImage?.color ?? store.watermarkSettings.color);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const isDrawing = ref(false);
const currentPath = ref<{x: number, y: number}[]>([]);
// Redraw when strokes change or layout changes
watch(
() => store.selectedImage?.maskStrokes,
() => {
nextTick(redrawCanvas);
},
{ deep: true }
);
watch(imageRect, () => {
nextTick(redrawCanvas);
});
const redrawCanvas = () => {
if (!canvasRef.value || !store.selectedImage || imageRect.value.width === 0) return;
const ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
const w = imageRect.value.width;
const h = imageRect.value.height;
ctx.clearRect(0, 0, w, h);
if (!store.selectedImage.maskStrokes) return;
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
store.selectedImage.maskStrokes.forEach(stroke => {
if (stroke.type === 'rect' && stroke.rect) {
ctx.fillRect(
stroke.rect.x * w,
stroke.rect.y * h,
stroke.rect.w * w,
stroke.rect.h * h
);
} else if (stroke.type === 'path' && stroke.points) {
const lw = (stroke.width || (store.brushSettings.size / w)) * w;
ctx.lineWidth = lw;
if (stroke.points.length > 0) {
ctx.beginPath();
ctx.moveTo(stroke.points[0].x * w, stroke.points[0].y * h);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x * w, stroke.points[i].y * h);
}
ctx.stroke();
}
}
});
};
const onMouseDown = (e: MouseEvent) => {
if (store.editMode === 'remove') {
startDrawing(e);
return;
}
e.preventDefault();
isDragging.value = true;
dragStart.value = { x: e.clientX, y: e.clientY };
};
const onMouseMove = (e: MouseEvent) => {
if (store.editMode === 'remove') {
draw(e);
return;
}
if (!isDragging.value || !store.selectedImage || imageRect.value.width === 0) return;
const rect = imageRect.value;
const deltaX = (e.clientX - dragStart.value.x) / rect.width;
const deltaY = (e.clientY - dragStart.value.y) / rect.height;
// Update manual position
let newX = position.value.x + deltaX;
let newY = position.value.y + deltaY;
// Clamp logic
const padding = 0.005;
newX = Math.max(padding, Math.min(1 - padding, newX));
newY = Math.max(padding, Math.min(1 - padding, newY));
// Set ONLY for this image
store.setImageManualPosition(store.selectedIndex, newX, newY);
dragStart.value = { x: e.clientX, y: e.clientY };
};
const onMouseUp = () => {
isDragging.value = false;
stopDrawing();
};
const onMouseLeave = () => {
isDragging.value = false;
stopDrawing();
};
// --- Drawing Logic ---
const startDrawing = (e: MouseEvent) => {
if (!canvasRef.value) return;
isDrawing.value = true;
currentPath.value = [];
draw(e);
};
const draw = (e: MouseEvent) => {
if (!isDrawing.value || !canvasRef.value) return;
const ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
const rect = canvasRef.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Normalize
const nx = x / imageRect.value.width;
const ny = y / imageRect.value.height;
currentPath.value.push({ x: nx, y: ny });
// Live feedback (Paint on top of existing)
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
ctx.beginPath();
ctx.arc(x, y, store.brushSettings.size / 2, 0, Math.PI * 2);
ctx.fill();
// Connect dots for smoothness if we have enough points
// (Optional optimization: draw line from last point)
};
const stopDrawing = () => {
if (!isDrawing.value) return;
isDrawing.value = false;
if (currentPath.value.length > 0 && store.selectedIndex >= 0) {
const normWidth = store.brushSettings.size / imageRect.value.width;
store.addMaskStroke(store.selectedIndex, {
type: 'path',
points: [...currentPath.value],
width: normWidth
});
}
};
</script>
<template>
<div class="w-full h-full flex items-center justify-center bg-black relative p-4 overflow-hidden">
<div v-if="store.selectedImage" class="relative inline-flex justify-center items-center" style="max-width: 100%; max-height: 100%;">
<img
:src="convertFileSrc(store.selectedImage.path)"
class="max-w-full max-h-full w-auto h-auto block shadow-lg"
style="max-height: calc(100vh - 10rem);"
alt="Hero Image"
/>
<!-- Watermark Overlay Placeholder -->
<div
v-if="store.selectedImage.zcaSuggestion"
class="absolute border-2 border-dashed border-green-400 text-green-400 px-4 py-2 bg-black/50 pointer-events-none transition-all duration-500"
ref="parentRef"
class="absolute inset-0 flex items-center justify-center bg-black overflow-hidden"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseLeave"
>
<!--
Dynamic Wrapper:
Dimensions strictly equal to the rendered image size.
This serves as the coordinate system for the watermark.
-->
<div
v-if="store.selectedImage"
class="relative shadow-2xl"
:style="{
left: (store.selectedImage.zcaSuggestion.x * 100) + '%',
top: (store.selectedImage.zcaSuggestion.y * 100) + '%',
transform: 'translate(-50%, -50%)'
width: imageRect.width + 'px',
height: imageRect.height + 'px'
}"
>
Smart Watermark ({{ store.selectedImage.zcaSuggestion.zone }})
</div>
<img
ref="imgRef"
:src="convertFileSrc(store.selectedImage.path)"
class="block w-full h-full select-none pointer-events-none"
alt="Hero Image"
loading="eager"
decoding="sync"
fetchpriority="high"
@load="calculateLayout"
@error="(e) => console.error('Hero Image Load Error:', e)"
/>
<!-- Text Watermark Overlay (Only in Add Mode) -->
<div
v-else
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 border-2 border-dashed border-white text-white px-4 py-2 bg-black/50 pointer-events-none"
v-if="store.editMode === 'add' && store.watermarkSettings.text"
class="absolute cursor-move select-none whitespace-nowrap font-medium"
:style="{
left: (position.x * 100) + '%',
top: (position.y * 100) + '%',
transform: 'translate(-50%, -50%)',
opacity: effectiveOpacity,
color: effectiveColor,
/* Scale based on HEIGHT of the IMAGE */
fontSize: (imageRect.height * effectiveScale) + 'px',
fontFamily: 'Roboto, sans-serif',
height: '0px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10
}"
@mousedown="onMouseDown"
>
Calculating...
<span>
{{ store.watermarkSettings.text }}
</span>
<!-- Selection Ring when dragging -->
<div v-if="isDragging" class="absolute -inset-2 border border-blue-500 rounded-sm"></div>
</div>
<!-- Canvas Overlay (Only in Remove Mode) -->
<canvas
v-show="store.editMode === 'remove'"
ref="canvasRef"
:width="imageRect.width"
:height="imageRect.height"
class="absolute inset-0 z-20 cursor-crosshair touch-none"
@mousedown="onMouseDown"
></canvas>
</div>
<div v-else class="text-gray-500">
No image selected
<div v-else class="text-gray-500 flex flex-col items-center">
<p>未选择图片</p>
</div>
</div>
</template>
<style scoped>
.image-container {
/* Removed container-type: size to prevent layout collapse */
}
span {
text-shadow:
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
1px 1px 0 #000;
}
</style>

View File

@@ -0,0 +1,281 @@
<script setup lang="ts">
import { useGalleryStore } from "../stores/gallery";
import { Settings, CheckSquare, Type, Palette, Copy, Eraser, PlusSquare, Brush, Sparkles, Trash2, RotateCw, RotateCcw } from 'lucide-vue-next';
import { computed } from "vue";
const store = useGalleryStore();
// Computed properties to handle "Get from Image OR Global" and "Set to Image" logic
const currentScale = computed({
get: () => store.selectedImage?.scale ?? store.watermarkSettings.scale,
set: (val) => {
if (store.selectedIndex >= 0) {
store.setImageSetting(store.selectedIndex, 'scale', val);
} else {
store.updateWatermarkSettings({ scale: val });
}
}
});
const currentOpacity = computed({
get: () => store.selectedImage?.opacity ?? store.watermarkSettings.opacity,
set: (val) => {
if (store.selectedIndex >= 0) {
store.setImageSetting(store.selectedIndex, 'opacity', val);
} else {
store.updateWatermarkSettings({ opacity: val });
}
}
});
const currentColor = computed({
get: () => store.selectedImage?.color ?? store.watermarkSettings.color,
set: (val) => {
if (store.selectedIndex >= 0) {
store.setImageSetting(store.selectedIndex, 'color', val);
} else {
store.updateWatermarkSettings({ color: val });
}
}
});
const applyAll = () => {
if (confirm("是否将当前设置(大小、透明度、颜色)应用到所有图片?")) {
store.applySettingsToAll();
}
};
</script>
<template>
<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" /> 添加
</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" /> 移除
</button>
</div>
<!-- Content Area -->
<div class="flex-1 overflow-y-auto p-4 flex flex-col gap-6">
<!-- ADD MODE SETTINGS -->
<div v-if="store.editMode === 'add'" class="flex flex-col gap-6">
<h2 class="text-lg font-bold flex items-center justify-between">
<div class="flex items-center gap-2">
<Settings class="w-5 h-5" />
水印设置
</div>
<button
@click="applyAll"
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"
>
<Copy class="w-3 h-3" /> 全部
</button>
</h2>
<!-- Text Input -->
<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="relative flex-1">
<Type class="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
<input
type="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"
placeholder="输入水印文字..."
/>
</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="应用并重新计算所有图片布局"
>
<RotateCw class="w-4 h-4" />
</button>
</div>
</div>
<!-- Color Picker -->
<div class="flex flex-col gap-2">
<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">
<Palette class="w-4 h-4 text-gray-400" />
<input
type="color"
v-model="currentColor"
class="w-8 h-8 rounded cursor-pointer bg-transparent border-none p-0"
/>
<span class="text-xs text-gray-300 font-mono">{{ currentColor }}</span>
</div>
</div>
<!-- Controls -->
<div class="space-y-4">
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-400">字体大小 (比例)</label>
<span class="text-xs text-gray-300">{{ (currentScale * 100).toFixed(1) }}%</span>
</div>
<input
type="range"
min="0.01"
max="0.20"
step="0.001"
v-model.number="currentScale"
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">基于图片高度的比例</p>
</div>
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-400">不透明度</label>
<span class="text-xs text-gray-300">{{ (currentOpacity * 100).toFixed(0) }}%</span>
</div>
<input
type="range"
min="0.1"
max="1.0"
step="0.01"
v-model.number="currentOpacity"
class="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
<!-- Placement Info -->
<div class="flex flex-col gap-2">
<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="p-1 rounded bg-green-500/20 text-green-400">
<CheckSquare class="w-4 h-4" v-if="!store.selectedImage?.manualPosition" />
<div class="w-4 h-4" v-else></div>
</div>
<div class="flex-1">
<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>手动调整</p>
</div>
</div>
</div>
</div>
<!-- REMOVE MODE SETTINGS -->
<div v-else class="flex flex-col gap-6">
<h2 class="text-lg font-bold flex items-center gap-2 text-red-400">
<Brush class="w-5 h-5" />
魔法橡皮擦
</h2>
<!-- 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">
<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>
<div>
<div class="flex justify-between mb-1">
<label class="text-xs text-gray-400">画笔大小</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 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>
<!-- 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"
@click="store.selectedIndex >= 0 && store.restoreImage(store.selectedIndex)"
class="w-full bg-gray-700 hover:bg-gray-600 text-gray-300 py-2 rounded text-sm transition-colors flex items-center justify-center gap-2"
>
<RotateCcw class="w-4 h-4" />
还原原图
</button>
</div>
</div>
</div>
</template>

View File

@@ -23,13 +23,19 @@ 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)"
>
<div class="h-full w-full bg-gray-800 flex items-center justify-center overflow-hidden rounded border border-gray-600">
<!-- Use actual thumbnail path later -->
<img :src="convertFileSrc(item.path)" class="w-full h-full object-cover" loading="lazy" />
<!-- Use generated thumbnail -->
<img
:src="convertFileSrc(item.thumbnail)"
class="w-full h-full object-cover"
loading="lazy"
decoding="async"
fetchpriority="low"
/>
</div>
</div>
</template>

View File

@@ -2,19 +2,60 @@ import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { invoke } from "@tauri-apps/api/core";
export interface MaskStroke {
type: 'path' | 'rect';
points?: {x: number, y: number}[]; // Normalized
rect?: {x: number, y: number, w: number, h: number}; // Normalized
width?: number; // Normalized brush width for paths
}
export interface ImageItem {
path: string;
thumbnail?: string;
originalPath: string;
thumbnail: string;
name: string;
width?: number;
height?: number;
zcaSuggestion?: { x: number; y: number; zone: string };
manualPosition?: { x: number; y: number };
scale?: number;
opacity?: number;
color?: string;
maskStrokes?: MaskStroke[];
}
export interface WatermarkSettings {
type: 'text';
text: string;
color: string;
opacity: number;
scale: number;
}
export const useGalleryStore = defineStore("gallery", () => {
const images = ref<ImageItem[]>([]);
const selectedIndex = ref<number>(-1);
// 'add' = Add Watermark, 'remove' = Remove Watermark (Inpainting)
const editMode = ref<'add' | 'remove'>('add');
const watermarkSettings = ref<WatermarkSettings>({
type: 'text',
text: '水印',
color: '#FFFFFF',
opacity: 1.0,
scale: 0.03,
});
const brushSettings = ref({
size: 20, // screen pixels
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];
@@ -23,10 +64,218 @@ export const useGalleryStore = defineStore("gallery", () => {
});
function setImages(newImages: ImageItem[]) {
images.value = newImages;
images.value = newImages.map(img => ({
...img,
// Ensure originalPath is set if not already present from backend
originalPath: img.originalPath || img.path
}));
selectedIndex.value = -1;
}
function restoreImage(index: number) {
const img = images.value[index];
if (img) {
img.path = img.originalPath;
img.maskStrokes = []; // Also clear any masks if restoring
}
}
function updateWatermarkSettings(settings: Partial<WatermarkSettings>) {
watermarkSettings.value = { ...watermarkSettings.value, ...settings };
}
function setImageManualPosition(index: number, x: number, y: number) {
if (images.value[index]) {
images.value[index].manualPosition = { x, y };
}
}
function setImageSetting(index: number, setting: 'scale' | 'opacity' | 'color', value: number | string) {
if (images.value[index]) {
// @ts-ignore
images.value[index][setting] = value;
}
}
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 = [];
}
}
// 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 };
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;
}
}
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) => {
try {
const result = await invoke<{x: number, y: number, scale: number}>("layout_watermark", {
path: img.path,
text: text,
baseScale: baseScale
});
const idx = images.value.indexOf(img);
if (idx >= 0) {
setImageManualPosition(idx, result.x, result.y);
setImageSetting(idx, 'scale', result.scale);
}
} catch (e) {
console.error(`Layout failed for ${img.name}`, e);
}
});
await Promise.all(batch);
}
}
// 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", {
path: img.path,
strokes: img.maskStrokes
});
img.path = newPath;
img.maskStrokes = [];
} catch (e) {
console.error("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;
}
}
// Applies the settings from the CURRENT image (or global if not overridden) to ALL images
// Strategy: Update Global Settings to match current view, and clear individual overrides so everyone follows global.
function applySettingsToAll() {
const current = selectedImage.value;
if (!current) return;
const newScale = current.scale ?? watermarkSettings.value.scale;
const newOpacity = current.opacity ?? watermarkSettings.value.opacity;
const newColor = current.color ?? watermarkSettings.value.color;
// 1. Update Global
watermarkSettings.value.scale = newScale;
watermarkSettings.value.opacity = newOpacity;
watermarkSettings.value.color = newColor;
// 2. Clear overrides on ALL images
images.value.forEach(img => {
img.scale = undefined;
img.opacity = undefined;
img.color = undefined;
});
}
async function selectImage(index: number) {
if (index < 0 || index >= images.value.length) return;
selectedIndex.value = index;
@@ -35,8 +284,6 @@ export const useGalleryStore = defineStore("gallery", () => {
if (!img.zcaSuggestion) {
try {
const suggestion = await invoke<{x: number, y: number, zone: string}>("get_zca_suggestion", { path: img.path });
// Update the item in the array
// Note: Directly modifying the object inside ref array is reactive in Vue 3
img.zcaSuggestion = suggestion;
} catch (e) {
console.error("ZCA failed", e);
@@ -44,11 +291,43 @@ 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,
selectedImage,
editMode,
watermarkSettings,
brushSettings,
isDetecting,
isProcessing,
progress,
setImages,
selectImage,
updateWatermarkSettings,
setImageManualPosition,
setImageSetting,
applySettingsToAll,
addMaskStroke,
clearMask,
detectCurrentWatermark,
detectAllWatermarks,
recalcAllWatermarks,
processInpainting,
processAllInpainting,
restoreImage,
nextImage,
prevImage
};
});

View File

@@ -1 +1,28 @@
@import "tailwindcss";
@font-face {
font-family: 'Roboto';
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 */
}