mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Compare commits
530 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a340d397c | ||
|
|
b86bd44c65 | ||
|
|
77bfbe1bc9 | ||
|
|
666db4cdd0 | ||
|
|
233427600a | ||
|
|
84c62f2954 | ||
|
|
5e91073476 | ||
|
|
08267cdb48 | ||
|
|
e50b2c802e | ||
|
|
2eea92abdf | ||
|
|
58ae6b9de6 | ||
|
|
b775333d32 | ||
|
|
bad0a8c5df | ||
|
|
ee25643f68 | ||
|
|
a78868adce | ||
|
|
2ccfbaf073 | ||
|
|
565b61d1c2 | ||
|
|
18d3ecb4da | ||
|
|
a02462fff4 | ||
|
|
ad4574e02f | ||
|
|
822ac046e0 | ||
|
|
55fa31b144 | ||
|
|
d17808d9e5 | ||
|
|
5d9f64e43b | ||
|
|
5dc5fd5971 | ||
|
|
0ff551551e | ||
|
|
9032226724 | ||
|
|
7249c9fd4b | ||
|
|
31d94d7ea2 | ||
|
|
b28f148ce8 | ||
|
|
93cd0b54dc | ||
|
|
7b0c6c8bab | ||
|
|
e14afde4b3 | ||
|
|
4b36d60e46 | ||
|
|
6ef6c116e4 | ||
|
|
42f35be9d3 | ||
|
|
d063d48417 | ||
|
|
c9e305397c | ||
|
|
6142b3dc0c | ||
|
|
d5a2bd1e24 | ||
|
|
1f6fc59aa2 | ||
|
|
41101ad5c6 | ||
|
|
b71b3f99dc | ||
|
|
d655fb8008 | ||
|
|
194f2f702c | ||
|
|
fad43ad003 | ||
|
|
b05762b066 | ||
|
|
13b18ac85f | ||
|
|
eb2af454cc | ||
|
|
7bba24c19f | ||
|
|
0bb75fdf77 | ||
|
|
7c7d2e12b5 | ||
|
|
2121054cb9 | ||
|
|
bf0291ec0e | ||
|
|
932d85617c | ||
|
|
6832469889 | ||
|
|
b0f852cc6c | ||
|
|
d1c65a6186 | ||
|
|
6fbea77137 | ||
|
|
17c5583297 | ||
|
|
9150718edb | ||
|
|
50abd85fae | ||
|
|
7b4607bed7 | ||
|
|
6f74186498 | ||
|
|
eb8b95176b | ||
|
|
091d8aba39 | ||
|
|
379e3ce2f6 | ||
|
|
1b7b598f7a | ||
|
|
fd06086a05 | ||
|
|
50c012ae33 | ||
|
|
796acba764 | ||
|
|
3aab0cc916 | ||
|
|
4c2c8c2bc8 | ||
|
|
e44180b832 | ||
|
|
4ff397e9c1 | ||
|
|
633ad2d386 | ||
|
|
1dee7f5cf9 | ||
|
|
b0f0158f98 | ||
|
|
7f2e8a0afb | ||
|
|
7a7517cfb6 | ||
|
|
f0c852ef23 | ||
|
|
839bcbd37f | ||
|
|
ab6a4844f0 | ||
|
|
dad549f65f | ||
|
|
aab1797269 | ||
|
|
cb460fcdb0 | ||
|
|
88e7f671d2 | ||
|
|
07d599810d | ||
|
|
4f3c91b307 | ||
|
|
ad7d372887 | ||
|
|
4e909f3008 | ||
|
|
bd0dfd4ef5 | ||
|
|
c5b597dc89 | ||
|
|
bd4958edc3 | ||
|
|
428a2ce420 | ||
|
|
5636437df2 | ||
|
|
10c0668b02 | ||
|
|
0c67ff85ee | ||
|
|
cde6151c71 | ||
|
|
9ed5319ad2 | ||
|
|
40756b7dd3 | ||
|
|
2a9ceb9e85 | ||
|
|
30077099ec | ||
|
|
fc8240e99e | ||
|
|
4951ff358e | ||
|
|
73f2a34d08 | ||
|
|
394eebe070 | ||
|
|
bc08a45214 | ||
|
|
0c96e8d328 | ||
|
|
859277a7eb | ||
|
|
9e510d64ec | ||
|
|
430ba84cf7 | ||
|
|
0ae2d084f4 | ||
|
|
514846cd4a | ||
|
|
1ebd2c93a0 | ||
|
|
688baef2f0 | ||
|
|
6a17e75782 | ||
|
|
bce6b0e610 | ||
|
|
177b20263d | ||
|
|
65cede7335 | ||
|
|
9719dd4d07 | ||
|
|
7a5f4514f3 | ||
|
|
b44ef9ceaa | ||
|
|
647728b2e1 | ||
|
|
3d348900ac | ||
|
|
32249d1886 | ||
|
|
f842ea990e | ||
|
|
f2e12c0fd3 | ||
|
|
f62b3f62be | ||
|
|
b57a317c82 | ||
|
|
fa063ba1ce | ||
|
|
eb30595d23 | ||
|
|
fd7cb3300d | ||
|
|
f199c9b591 | ||
|
|
255ca4fc93 | ||
|
|
09c1bd78cd | ||
|
|
edbcca9bbd | ||
|
|
8c68298202 | ||
|
|
a80380d1f0 | ||
|
|
f13f22c949 | ||
|
|
07aeeb6c70 | ||
|
|
4317b06049 | ||
|
|
ab85ba54a9 | ||
|
|
837c32c42f | ||
|
|
953117efa1 | ||
|
|
afa5533145 | ||
|
|
102defe29c | ||
|
|
8120716cd8 | ||
|
|
2b239c3747 | ||
|
|
a59c31bc06 | ||
|
|
d30c8e13df | ||
|
|
5d5a2a998a | ||
|
|
e5b557504e | ||
|
|
e43aa5cae4 | ||
|
|
f5d5bffa61 | ||
|
|
7d6b717385 | ||
|
|
d9ce2c56c0 | ||
|
|
914d24b8bf | ||
|
|
1329294981 | ||
|
|
475906a25e | ||
|
|
84b68cff90 | ||
|
|
41759f5e67 | ||
|
|
91cd88f1df | ||
|
|
e5869648fb | ||
|
|
7b139b9b1d | ||
|
|
a552f07448 | ||
|
|
6486107ca2 | ||
|
|
6330c65d41 | ||
|
|
00e6904664 | ||
|
|
39195aa529 | ||
|
|
fc0a834beb | ||
|
|
b044b329fc | ||
|
|
502c29c6bd | ||
|
|
bc9dd317f7 | ||
|
|
61816cf75d | ||
|
|
db7f09797b | ||
|
|
6e64f97e2b | ||
|
|
3f646aa0c9 | ||
|
|
67fb205b43 | ||
|
|
dd89aa49c1 | ||
|
|
3ba5c4c2ab | ||
|
|
7caca0163e | ||
|
|
30fd0470de | ||
|
|
63b087fc80 | ||
|
|
154ae82519 | ||
|
|
c8a179488a | ||
|
|
ca6bb43406 | ||
|
|
a07720a3bf | ||
|
|
bdb4422cbc | ||
|
|
099a71b2cc | ||
|
|
3382d83aee | ||
|
|
7e133e4b9d | ||
|
|
2494fa19a6 | ||
|
|
5359129fad | ||
|
|
4743b3c406 | ||
|
|
32d94be08a | ||
|
|
56143eb170 | ||
|
|
817de3a0ae | ||
|
|
675d49e4ce | ||
|
|
fbb95bc623 | ||
|
|
6b3a11e01a | ||
|
|
40f7f14c1b | ||
|
|
a6e23a7630 | ||
|
|
3fc72d6bc1 | ||
|
|
a3a00bbeed | ||
|
|
74bfd397aa | ||
|
|
5000478991 | ||
|
|
40cd2e23ac | ||
|
|
6efe59bd9e | ||
|
|
83f379df33 | ||
|
|
4d6f4fcf69 | ||
|
|
22ee37b817 | ||
|
|
f09224152a | ||
|
|
df93670598 | ||
|
|
073fb3a94a | ||
|
|
53c4165d82 | ||
|
|
8cd4550189 | ||
|
|
2b2e4fefab | ||
|
|
5f93648297 | ||
|
|
8a628f0bd0 | ||
|
|
b67c8598d6 | ||
|
|
0254c9d0e9 | ||
|
|
ecb512995c | ||
|
|
f8b9fa9b20 | ||
|
|
5d4917c8d9 | ||
|
|
a50309c22e | ||
|
|
f5020e081f | ||
|
|
3c0bfcb226 | ||
|
|
9198a23ba9 | ||
|
|
02bac7edfb | ||
|
|
ea1d1a49c9 | ||
|
|
9a789f8f08 | ||
|
|
1971881537 | ||
|
|
4eb46a8d3e | ||
|
|
36f28b3c65 | ||
|
|
2452cc4df1 | ||
|
|
eda1ce9743 | ||
|
|
e24621a0af | ||
|
|
7173a2b9d6 | ||
|
|
d540b21aac | ||
|
|
9952721e76 | ||
|
|
26e4895807 | ||
|
|
c533a8e7bf | ||
|
|
dc820a456f | ||
|
|
07721af87c | ||
|
|
5093c30c06 | ||
|
|
8c77080ae6 | ||
|
|
bcf72c6bcc | ||
|
|
3849f7eef9 | ||
|
|
7eced1e3e9 | ||
|
|
51b5261f40 | ||
|
|
963f6b1383 | ||
|
|
b75baa1d1a | ||
|
|
6d95e93378 | ||
|
|
7117e0c33e | ||
|
|
d261474f3a | ||
|
|
c09d67d2e4 | ||
|
|
1427dc8e38 | ||
|
|
77a7b90dc7 | ||
|
|
e9d55fe146 | ||
|
|
57f369a6de | ||
|
|
059ebeead7 | ||
|
|
831a9da9d7 | ||
|
|
6000e08640 | ||
|
|
3edc65c106 | ||
|
|
655157434e | ||
|
|
3661b11b70 | ||
|
|
0e73db0669 | ||
|
|
8158441a92 | ||
|
|
5600471093 | ||
|
|
354cf03bbc | ||
|
|
645b7c247d | ||
|
|
5f25a29303 | ||
|
|
906d00106d | ||
|
|
7850131969 | ||
|
|
3d5ec4a9f1 | ||
|
|
1cdbb9a851 | ||
|
|
e224be4b88 | ||
|
|
b9d3a4afce | ||
|
|
aa4aa1a613 | ||
|
|
cc8e1c5049 | ||
|
|
41e649415a | ||
|
|
c8f770a86b | ||
|
|
29bb85359e | ||
|
|
4557da8b63 | ||
|
|
09b75de25b | ||
|
|
415fc5720c | ||
|
|
4dd8ce778e | ||
|
|
f81ff2efe9 | ||
|
|
837bb17b08 | ||
|
|
5ee93a27ee | ||
|
|
2e6aa5fe9f | ||
|
|
c14e066f8f | ||
|
|
c09100c22e | ||
|
|
839ed3bda3 | ||
|
|
1f627774c1 | ||
|
|
3b842355c2 | ||
|
|
dd27411ebf | ||
|
|
388ff7f5b4 | ||
|
|
f76343f389 | ||
|
|
ce5a1ae3d0 | ||
|
|
1d40d7400f | ||
|
|
1bb5d0b072 | ||
|
|
c3932538e1 | ||
|
|
a68141adf4 | ||
|
|
fb8ba4c076 | ||
|
|
4ed3bd9039 | ||
|
|
ba6e2eadba | ||
|
|
1c16392367 | ||
|
|
035ad4b473 | ||
|
|
a7ee883227 | ||
|
|
ddf9e33961 | ||
|
|
4301b3455f | ||
|
|
3d6bb432c4 | ||
|
|
6c03aa1430 | ||
|
|
5376fd8724 | ||
|
|
6dea9a76bc | ||
|
|
d73903e82e | ||
|
|
4862419b61 | ||
|
|
e6e7df7454 | ||
|
|
30f9e3e2ec | ||
|
|
707d0cb8a4 | ||
|
|
56ea7594ce | ||
|
|
389e46c251 | ||
|
|
6db17e682a | ||
|
|
94e0308a12 | ||
|
|
1f9f821576 | ||
|
|
57933dfba6 | ||
|
|
c50bee7757 | ||
|
|
4e3ee843f9 | ||
|
|
7e40f6fcb9 | ||
|
|
7976956b6b | ||
|
|
adce5293d5 | ||
|
|
c2db5eb6df | ||
|
|
f958ecdf18 | ||
|
|
ef0bcc6cf1 | ||
|
|
285428ad3a | ||
|
|
ee18cff3d9 | ||
|
|
1be3235564 | ||
|
|
a92883509a | ||
|
|
ce42d83ce9 | ||
|
|
077cf7b574 | ||
|
|
b99d78bda6 | ||
|
|
39586f4a20 | ||
|
|
4ef750b206 | ||
|
|
9d3d93823d | ||
|
|
45c1113b72 | ||
|
|
e10717dcda | ||
|
|
315ab6f70b | ||
|
|
cf4d654c4b | ||
|
|
569c829709 | ||
|
|
de05b59f29 | ||
|
|
70a282a6c0 | ||
|
|
b10bcf7e78 | ||
|
|
5fb10263f3 | ||
|
|
9e76c9783e | ||
|
|
7770976513 | ||
|
|
dc1f7ab6fe | ||
|
|
32b1d6c561 | ||
|
|
5264e49f2a | ||
|
|
ce3adaf831 | ||
|
|
e2f3e57f5c | ||
|
|
5c2349ff42 | ||
|
|
50eee8c373 | ||
|
|
f89b792535 | ||
|
|
6d0ea2841c | ||
|
|
98678a8698 | ||
|
|
5326fa2970 | ||
|
|
90547670a2 | ||
|
|
4753206c52 | ||
|
|
613aa3b1c3 | ||
|
|
a6b704d4b4 | ||
|
|
227d06c736 | ||
|
|
8508763831 | ||
|
|
136d3153fa | ||
|
|
49bdf77040 | ||
|
|
f4dcd89835 | ||
|
|
139e915711 | ||
|
|
22eda58074 | ||
|
|
fb91cf4df2 | ||
|
|
e0332571da | ||
|
|
2d4bc47746 | ||
|
|
38e766484e | ||
|
|
b5ee4a6408 | ||
|
|
7892df21ec | ||
|
|
188fe407b6 | ||
|
|
600afdcd92 | ||
|
|
994fa4bd43 | ||
|
|
51098f2829 | ||
|
|
795b9e8418 | ||
|
|
9ca2b9dd56 | ||
|
|
d77b6d78b7 | ||
|
|
427e7a36d5 | ||
|
|
c90306cc9b | ||
|
|
5fe0660c64 | ||
|
|
2abb5bf122 | ||
|
|
bb65527469 | ||
|
|
d9a6db3359 | ||
|
|
58cafdb713 | ||
|
|
0594e278b6 | ||
|
|
807425f12a | ||
|
|
aa4b1ccc25 | ||
|
|
58255ec28b | ||
|
|
d62b84693d | ||
|
|
df75c7e68d | ||
|
|
c5c7fdf54f | ||
|
|
49e0deeff3 | ||
|
|
0c20701bef | ||
|
|
faa26651dd | ||
|
|
2eae8a7729 | ||
|
|
dde2b2a960 | ||
|
|
4a9089d3dd | ||
|
|
3244a5f1a1 | ||
|
|
449c1e9d10 | ||
|
|
d0aa916683 | ||
|
|
13433f8cd2 | ||
|
|
8d336320c0 | ||
|
|
d945c58d51 | ||
|
|
acaf122346 | ||
|
|
713759b411 | ||
|
|
c5175bb870 | ||
|
|
e63ef8d031 | ||
|
|
e043537241 | ||
|
|
46126f9950 | ||
|
|
f4eb916914 | ||
|
|
49b9b7a5ea | ||
|
|
9b1a9ee071 | ||
|
|
0b8f137a1b | ||
|
|
6148a12301 | ||
|
|
fadbf21b4f | ||
|
|
c38a06937d | ||
|
|
1a34403b0e | ||
|
|
e4d58d0f60 | ||
|
|
4e4ea85cc3 | ||
|
|
f7a856349a | ||
|
|
15edd7a42c | ||
|
|
46243a236d | ||
|
|
6f382e587a | ||
|
|
bf3d706bf4 | ||
|
|
cdf21e813c | ||
|
|
10f5588e4a | ||
|
|
0ecbdf6f39 | ||
|
|
61101a7ad0 | ||
|
|
6d9be814a5 | ||
|
|
52bf93e430 | ||
|
|
00fade756c | ||
|
|
3c0feb23ba | ||
|
|
3627840fe9 | ||
|
|
bbdc1bba87 | ||
|
|
21a1bc1a01 | ||
|
|
0968698804 | ||
|
|
a5b2e9b0bf | ||
|
|
5a6ff444b9 | ||
|
|
3bb240d3c1 | ||
|
|
ee0d241c75 | ||
|
|
321ff72953 | ||
|
|
412f1e62a1 | ||
|
|
8901b32a55 | ||
|
|
8ab6cc72ad | ||
|
|
52e671638b | ||
|
|
a3070f8d82 | ||
|
|
3fde474583 | ||
|
|
1454991d6d | ||
|
|
4398851bb9 | ||
|
|
5173aa6c20 | ||
|
|
3d98572a62 | ||
|
|
c48095d9c6 | ||
|
|
1e4d1b8f15 | ||
|
|
8c037465ba | ||
|
|
055c1ca0d4 | ||
|
|
27370df93a | ||
|
|
60d23aa238 | ||
|
|
5e441d9c4f | ||
|
|
eb76468280 | ||
|
|
01bbaa31a8 | ||
|
|
bddf023dc4 | ||
|
|
8e69a247ed | ||
|
|
97141b01e1 | ||
|
|
acf610ddff | ||
|
|
a9a6f66035 | ||
|
|
0040863a03 | ||
|
|
4ab86b4ae2 | ||
|
|
b32b4b4042 | ||
|
|
4e552dcf3e | ||
|
|
8f4c02efdc | ||
|
|
b77c596f3a | ||
|
|
181f0b5626 | ||
|
|
480e5d966f | ||
|
|
e8636b949d | ||
|
|
8ea369db47 | ||
|
|
ec9b37eb53 | ||
|
|
b0847f6b87 | ||
|
|
84d10b1f3b | ||
|
|
4fdc97d062 | ||
|
|
5fe5e7ea54 | ||
|
|
7be1a2bd65 | ||
|
|
87842385c6 | ||
|
|
1dc189eb39 | ||
|
|
6120922204 | ||
|
|
ddb30dbb17 | ||
|
|
1e8bd88e28 | ||
|
|
c3a66ecf28 | ||
|
|
1f60160e8b | ||
|
|
7d560bf07a | ||
|
|
47da9949d9 | ||
|
|
68c0a5ba71 | ||
|
|
1aa81c803b | ||
|
|
8f5e134d3e | ||
|
|
ef03a2a917 | ||
|
|
e275968553 | ||
|
|
76d3aa2b5b | ||
|
|
c9a65c7347 | ||
|
|
f542ade628 | ||
|
|
d2c2bfbe6a | ||
|
|
2b6910bd55 | ||
|
|
b1dd733493 | ||
|
|
5dcf0a1e48 | ||
|
|
cf357b57fc | ||
|
|
4e1773833f | ||
|
|
8cf762ffd3 | ||
|
|
d997eaa429 | ||
|
|
8e51f0f19f | ||
|
|
f0e246b4ac | ||
|
|
a232997a79 | ||
|
|
08a449db99 | ||
|
|
0c023c9888 | ||
|
|
0ad92d00b3 | ||
|
|
a726cbea1e | ||
|
|
c53fa8692b | ||
|
|
3118f3b43c |
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,5 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
patreon: PixelPawsAI
|
||||
ko_fi: pixelpawsai
|
||||
custom: ['paypal.me/pixelpawsai']
|
||||
patreon: PixelPawsAI
|
||||
custom: ['paypal.me/pixelpawsai', 'https://afdian.com/a/pixelpawsai']
|
||||
|
||||
24
.github/workflows/backend-tests.yml
vendored
24
.github/workflows/backend-tests.yml
vendored
@@ -47,6 +47,30 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Verify symlink support
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
root = pathlib.Path(tempfile.mkdtemp(prefix="lm-symlink-check-"))
|
||||
target = root / "target"
|
||||
target.mkdir()
|
||||
link = root / "link"
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
raise SystemExit(f"Failed to create directory symlink in CI: {exc}")
|
||||
|
||||
is_link = os.path.islink(link)
|
||||
is_dir = os.path.isdir(link)
|
||||
realpath = os.path.realpath(link)
|
||||
print(f"islink={is_link} isdir={is_dir} realpath={realpath}")
|
||||
if not (is_link and is_dir and realpath == str(target)):
|
||||
raise SystemExit("Directory symlink is not functioning correctly in CI; aborting.")
|
||||
PY
|
||||
|
||||
- name: Run pytest with coverage
|
||||
env:
|
||||
COVERAGE_FILE: coverage/backend/.coverage
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
settings.json
|
||||
path_mappings.yaml
|
||||
output/*
|
||||
@@ -9,3 +10,12 @@ civitai/
|
||||
node_modules/
|
||||
coverage/
|
||||
.coverage
|
||||
model_cache/
|
||||
|
||||
# agent
|
||||
.opencode/
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
vue-widgets/node_modules/
|
||||
vue-widgets/.vite/
|
||||
vue-widgets/dist/
|
||||
|
||||
202
AGENTS.md
202
AGENTS.md
@@ -1,22 +1,192 @@
|
||||
# Repository Guidelines
|
||||
# AGENTS.md
|
||||
|
||||
## Project Structure & Module Organization
|
||||
ComfyUI LoRA Manager pairs a Python backend with browser-side widgets. Backend modules live in <code>py/</code> with HTTP entry points in <code>py/routes/</code>, feature logic in <code>py/services/</code>, shared helpers in <code>py/utils/</code>, and custom nodes in <code>py/nodes/</code>. UI scripts extend ComfyUI from <code>web/comfyui/</code>, while deploy-ready assets remain in <code>static/</code> and <code>templates/</code>. Localization files live in <code>locales/</code>, example workflows in <code>example_workflows/</code>, and interim tests such as <code>test_i18n.py</code> sit beside their source until a dedicated <code>tests/</code> tree lands.
|
||||
This file provides guidance for agentic coding assistants working in this repository.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- <code>pip install -r requirements.txt</code> installs backend dependencies.
|
||||
- <code>python standalone.py --port 8188</code> launches the standalone server for iterative development.
|
||||
- <code>python -m pytest test_i18n.py</code> runs the current regression suite; target new files explicitly, e.g. <code>python -m pytest tests/test_recipes.py</code>.
|
||||
- <code>python scripts/sync_translation_keys.py</code> synchronizes locale keys after UI string updates.
|
||||
## Development Commands
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Follow PEP 8 with four-space indentation and descriptive snake_case file and function names such as <code>settings_manager.py</code>. Classes stay PascalCase, constants in UPPER_SNAKE_CASE, and loggers retrieved via <code>logging.getLogger(__name__)</code>. Prefer explicit type hints and docstrings on public APIs. JavaScript under <code>web/comfyui/</code> uses ES modules with camelCase helpers and the <code>_widget.js</code> suffix for UI components.
|
||||
### Backend Development
|
||||
|
||||
## Testing Guidelines
|
||||
Pytest powers backend tests. Name modules <code>test_<feature>.py</code> and keep them near the code or in a future <code>tests/</code> package. Mock ComfyUI dependencies through helpers in <code>standalone.py</code>, keep filesystem fixtures deterministic, and ensure translations are covered. Run <code>python -m pytest</code> before submitting changes.
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Commits follow the conventional format, e.g. <code>feat(settings): add default model path</code>, and should stay focused on a single concern. Pull requests must outline the problem, summarize the solution, list manual verification steps (server run, targeted pytest), and link related issues. Include screenshots or GIFs for UI or locale updates and call out migration steps such as <code>settings.json</code> adjustments.
|
||||
# Run standalone server (port 8188 by default)
|
||||
python standalone.py --port 8188
|
||||
|
||||
# Run all backend tests
|
||||
pytest
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_recipes.py
|
||||
|
||||
# Run specific test function
|
||||
pytest tests/test_recipes.py::test_function_name
|
||||
|
||||
# Run backend tests with coverage
|
||||
COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||
--cov=py \
|
||||
--cov=standalone \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=html:coverage/backend/html \
|
||||
--cov-report=xml:coverage/backend/coverage.xml \
|
||||
--cov-report=json:coverage/backend/coverage.json
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
npm install
|
||||
|
||||
# Run frontend tests
|
||||
npm test
|
||||
|
||||
# Run frontend tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run frontend tests with coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## Python Code Style
|
||||
|
||||
### Imports
|
||||
|
||||
- Use `from __future__ import annotations` for forward references in type hints
|
||||
- Group imports: standard library, third-party, local (separated by blank lines)
|
||||
- Use absolute imports within `py/` package: `from ..services import X`
|
||||
- Mock ComfyUI dependencies in tests using `tests/conftest.py` patterns
|
||||
|
||||
### Formatting & Types
|
||||
|
||||
- PEP 8 with 4-space indentation
|
||||
- Type hints required for function signatures and class attributes
|
||||
- Use `TYPE_CHECKING` guard for type-checking-only imports
|
||||
- Prefer dataclasses for simple data containers
|
||||
- Use `Optional[T]` for nullable types, `Union[T, None]` only when necessary
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Files: `snake_case.py` (e.g., `model_scanner.py`, `lora_service.py`)
|
||||
- Classes: `PascalCase` (e.g., `ModelScanner`, `LoraService`)
|
||||
- Functions/variables: `snake_case` (e.g., `get_instance`, `model_type`)
|
||||
- Constants: `UPPER_SNAKE_CASE` (e.g., `VALID_LORA_TYPES`)
|
||||
- Private members: `_single_underscore` (protected), `__double_underscore` (name-mangled)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use `logging.getLogger(__name__)` for module-level loggers
|
||||
- Define custom exceptions in `py/services/errors.py`
|
||||
- Use `asyncio.Lock` for thread-safe singleton patterns
|
||||
- Raise specific exceptions with descriptive messages
|
||||
- Log errors at appropriate levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
|
||||
### Async Patterns
|
||||
|
||||
- Use `async def` for I/O-bound operations
|
||||
- Mark async tests with `@pytest.mark.asyncio`
|
||||
- Use `async with` for context managers
|
||||
- Singleton pattern with class-level locks: see `ModelScanner.get_instance()`
|
||||
- Use `aiohttp.web.Response` for HTTP responses
|
||||
|
||||
### Testing Patterns
|
||||
|
||||
- Use `pytest` with `--import-mode=importlib`
|
||||
- Fixtures in `tests/conftest.py` handle ComfyUI mocking
|
||||
- Use `@pytest.mark.no_settings_dir_isolation` for tests needing real paths
|
||||
- Test files: `tests/test_*.py`
|
||||
- Use `tmp_path_factory` for temporary directory isolation
|
||||
|
||||
## JavaScript Code Style
|
||||
|
||||
### Imports & Modules
|
||||
|
||||
- ES modules with `import`/`export`
|
||||
- Use `import { app } from "../../scripts/app.js"` for ComfyUI integration
|
||||
- Export named functions/classes: `export function foo() {}`
|
||||
- Widget files use `*_widget.js` suffix
|
||||
|
||||
### Naming & Formatting
|
||||
|
||||
- camelCase for functions, variables, object properties
|
||||
- PascalCase for classes/constructors
|
||||
- Constants: `UPPER_SNAKE_CASE` (e.g., `CONVERTED_TYPE`)
|
||||
- Files: `snake_case.js` or `kebab-case.js`
|
||||
- 2-space indentation preferred (follow existing file conventions)
|
||||
|
||||
### Widget Development
|
||||
|
||||
- Use `app.registerExtension()` to register ComfyUI extensions
|
||||
- Use `node.addDOMWidget(name, type, element, options)` for custom widgets
|
||||
- Event handlers attached via `addEventListener` or widget callbacks
|
||||
- See `web/comfyui/utils.js` for shared utilities
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Service Layer
|
||||
|
||||
- Use `ServiceRegistry` singleton for dependency injection
|
||||
- Services follow singleton pattern via `get_instance()` class method
|
||||
- Separate scanners (discovery) from services (business logic)
|
||||
- Handlers in `py/routes/handlers/` implement route logic
|
||||
|
||||
### Model Types
|
||||
|
||||
- BaseModelService is abstract base for LoRA, Checkpoint, Embedding services
|
||||
- ModelScanner provides file discovery and hash-based deduplication
|
||||
- Persistent cache in SQLite via `PersistentModelCache`
|
||||
- Metadata sync from CivitAI/CivArchive via `MetadataSyncService`
|
||||
|
||||
### Routes & Handlers
|
||||
|
||||
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, etc.
|
||||
- Handlers are pure functions taking dependencies as parameters
|
||||
- Use `WebSocketManager` for real-time progress updates
|
||||
- Return `aiohttp.web.json_response` or `web.Response`
|
||||
|
||||
### Recipe System
|
||||
|
||||
- Base metadata in `py/recipes/base.py`
|
||||
- Enrichment adds model metadata: `RecipeEnrichmentService`
|
||||
- Parsers for different formats in `py/recipes/parsers/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Always use English for comments (per copilot-instructions.md)
|
||||
- Dual mode: ComfyUI plugin (uses folder_paths) vs standalone (reads settings.json)
|
||||
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||
- Settings auto-saved in user directory or portable mode
|
||||
- WebSocket broadcasts for real-time updates (downloads, scans)
|
||||
- Symlink handling requires normalized paths
|
||||
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
|
||||
- Run `python scripts/sync_translation_keys.py` after UI string updates
|
||||
|
||||
## Frontend UI Architecture
|
||||
|
||||
This project has two distinct UI systems:
|
||||
|
||||
### 1. Standalone Lora Manager Web UI
|
||||
- Location: `./static/` and `./templates/`
|
||||
- Purpose: Full-featured web application for managing LoRA models
|
||||
- Tech stack: Vanilla JS + CSS, served by the standalone server
|
||||
- Development: Uses npm for frontend testing (`npm test`, `npm run test:watch`, etc.)
|
||||
|
||||
### 2. ComfyUI Custom Node Widgets
|
||||
- Location: `./web/comfyui/`
|
||||
- Purpose: Widgets and UI logic that ComfyUI loads as custom node extensions
|
||||
- Tech stack: Vanilla JS + Vue.js widgets (in `./vue-widgets/` and built to `./web/comfyui/vue-widgets/`)
|
||||
- Widget styling: Primary styles in `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
|
||||
- Development: No npm build step for these widgets (Vue widgets use build system)
|
||||
|
||||
### Widget Development Guidelines
|
||||
- Use `app.registerExtension()` to register ComfyUI extensions (ComfyUI integration layer)
|
||||
- Use `node.addDOMWidget()` for custom DOM widgets
|
||||
- Widget styles should follow the patterns in `./web/comfyui/lm_styles.css`
|
||||
- Selected state: `rgba(66, 153, 225, 0.3)` background, `rgba(66, 153, 225, 0.6)` border
|
||||
- Hover state: `rgba(66, 153, 225, 0.2)` background
|
||||
- Color palette matches the Lora Manager accent color (blue #4299e1)
|
||||
- Use oklch() for color values when possible (defined in `./static/css/base.css`)
|
||||
- Vue widget components are in `./vue-widgets/src/components/` and built to `./web/comfyui/vue-widgets/`
|
||||
- When modifying widget styles, check `./web/comfyui/lm_styles.css` for consistency with other ComfyUI widgets
|
||||
|
||||
## Configuration & Localization Tips
|
||||
Copy <code>settings.json.example</code> to <code>settings.json</code> and adapt model directories before running the standalone server. Store reference assets in <code>civitai/</code> or <code>docs/</code> to keep runtime directories deploy-ready. Whenever UI text changes, update every <code>locales/<lang>.json</code> file and rerun the translation sync script so ComfyUI surfaces localized strings.
|
||||
|
||||
211
CLAUDE.md
Normal file
211
CLAUDE.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
ComfyUI LoRA Manager is a comprehensive LoRA management system for ComfyUI that combines a Python backend with browser-based widgets. It provides model organization, downloading from CivitAI/CivArchive, recipe management, and one-click workflow integration.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Install development dependencies (for testing)
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# Run standalone server (port 8188 by default)
|
||||
python standalone.py --port 8188
|
||||
|
||||
# Run backend tests with coverage
|
||||
COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||
--cov=py \
|
||||
--cov=standalone \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=html:coverage/backend/html \
|
||||
--cov-report=xml:coverage/backend/coverage.xml \
|
||||
--cov-report=json:coverage/backend/coverage.json
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_recipes.py
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
npm install
|
||||
|
||||
# Run frontend tests
|
||||
npm test
|
||||
|
||||
# Run frontend tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run frontend tests with coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
### Localization
|
||||
```bash
|
||||
# Sync translation keys after UI string updates
|
||||
python scripts/sync_translation_keys.py
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Structure (Python)
|
||||
|
||||
**Core Entry Points:**
|
||||
- `__init__.py` - ComfyUI plugin entry point, registers nodes and routes
|
||||
- `standalone.py` - Standalone server that mocks ComfyUI dependencies
|
||||
- `py/lora_manager.py` - Main LoraManager class that registers HTTP routes
|
||||
|
||||
**Service Layer** (`py/services/`):
|
||||
- `ServiceRegistry` - Singleton service registry for dependency management
|
||||
- `ModelServiceFactory` - Factory for creating model services (LoRA, Checkpoint, Embedding)
|
||||
- Scanner services (`lora_scanner.py`, `checkpoint_scanner.py`, `embedding_scanner.py`) - Model file discovery and indexing
|
||||
- `model_scanner.py` - Base scanner with hash-based deduplication and metadata extraction
|
||||
- `persistent_model_cache.py` - SQLite-based cache for model metadata
|
||||
- `metadata_sync_service.py` - Syncs metadata from CivitAI/CivArchive APIs
|
||||
- `civitai_client.py` / `civarchive_client.py` - API clients for external services
|
||||
- `downloader.py` / `download_manager.py` - Model download orchestration
|
||||
- `recipe_scanner.py` - Recipe file management and image association
|
||||
- `settings_manager.py` - Application settings with migration support
|
||||
- `websocket_manager.py` - WebSocket broadcasting for real-time updates
|
||||
- `use_cases/` - Business logic orchestration (auto-organize, bulk refresh, downloads)
|
||||
|
||||
**Routes Layer** (`py/routes/`):
|
||||
- Route registrars organize endpoints by domain (models, recipes, previews, example images, updates)
|
||||
- `handlers/` - Request handlers implementing business logic
|
||||
- Routes use aiohttp and integrate with ComfyUI's PromptServer
|
||||
|
||||
**Recipe System** (`py/recipes/`):
|
||||
- `base.py` - Base recipe metadata structure
|
||||
- `enrichment.py` - Enriches recipes with model metadata
|
||||
- `merger.py` - Merges recipe data from multiple sources
|
||||
- `parsers/` - Parsers for different recipe formats (PNG, JSON, workflow)
|
||||
|
||||
**Custom Nodes** (`py/nodes/`):
|
||||
- `lora_loader.py` - LoRA loader nodes with preset support
|
||||
- `save_image.py` - Enhanced save image with pattern-based filenames
|
||||
- `trigger_word_toggle.py` - Toggle trigger words in prompts
|
||||
- `lora_stacker.py` - Stack multiple LoRAs
|
||||
- `prompt.py` - Prompt node with autocomplete
|
||||
- `wanvideo_lora_select.py` - WanVideo-specific LoRA selection
|
||||
|
||||
**Configuration** (`py/config.py`):
|
||||
- Manages folder paths for models, checkpoints, embeddings
|
||||
- Handles symlink mappings for complex directory structures
|
||||
- Auto-saves paths to settings.json in ComfyUI mode
|
||||
|
||||
### Frontend Structure (JavaScript)
|
||||
|
||||
**ComfyUI Widgets** (`web/comfyui/`):
|
||||
- Vanilla JavaScript ES modules extending ComfyUI's LiteGraph-based UI
|
||||
- `loras_widget.js` - Main LoRA selection widget with preview
|
||||
- `loras_widget_events.js` - Event handling for widget interactions
|
||||
- `autocomplete.js` - Autocomplete for trigger words and embeddings
|
||||
- `preview_tooltip.js` - Preview tooltip for model cards
|
||||
- `top_menu_extension.js` - Adds "Launch LoRA Manager" menu item
|
||||
- `trigger_word_highlight.js` - Syntax highlighting for trigger words
|
||||
- `utils.js` - Shared utilities and API helpers
|
||||
|
||||
**Widget Development:**
|
||||
- Widgets use `app.registerExtension` and `getCustomWidgets` hooks
|
||||
- `node.addDOMWidget(name, type, element, options)` embeds HTML in nodes
|
||||
- See `docs/dom_widget_dev_guide.md` for complete DOMWidget development guide
|
||||
|
||||
**Web Source** (`web-src/`):
|
||||
- Modern frontend components (if migrating from static)
|
||||
- `components/` - Reusable UI components
|
||||
- `styles/` - CSS styling
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Dual Mode Operation:**
|
||||
- ComfyUI plugin mode: Integrates with ComfyUI's PromptServer, uses folder_paths
|
||||
- Standalone mode: Mocks ComfyUI dependencies via `standalone.py`, reads paths from settings.json
|
||||
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||
|
||||
**Settings Management:**
|
||||
- Settings stored in user directory (via `platformdirs`) or portable mode (in repo)
|
||||
- Migration system tracks settings schema version
|
||||
- Template in `settings.json.example` defines defaults
|
||||
|
||||
**Model Scanning Flow:**
|
||||
1. Scanner walks folder paths, computes file hashes
|
||||
2. Hash-based deduplication prevents duplicate processing
|
||||
3. Metadata extracted from safetensors headers
|
||||
4. Persistent cache stores results in SQLite
|
||||
5. Background sync fetches CivitAI/CivArchive metadata
|
||||
6. WebSocket broadcasts updates to connected clients
|
||||
|
||||
**Recipe System:**
|
||||
- Recipes store LoRA combinations with parameters
|
||||
- Supports import from workflow JSON, PNG metadata
|
||||
- Images associated with recipes via sibling file detection
|
||||
- Enrichment adds model metadata for display
|
||||
|
||||
**Frontend-Backend Communication:**
|
||||
- REST API for CRUD operations
|
||||
- WebSocket for real-time progress updates (downloads, scans)
|
||||
- API endpoints follow `/loras/*` pattern
|
||||
|
||||
## Code Style
|
||||
|
||||
**Python:**
|
||||
- PEP 8 with 4-space indentation
|
||||
- snake_case for files, functions, variables
|
||||
- PascalCase for classes
|
||||
- Type hints preferred
|
||||
- English comments only (per copilot-instructions.md)
|
||||
- Loggers via `logging.getLogger(__name__)`
|
||||
|
||||
**JavaScript:**
|
||||
- ES modules with camelCase
|
||||
- Files use `*_widget.js` suffix for ComfyUI widgets
|
||||
- Prefer vanilla JS, avoid framework dependencies
|
||||
|
||||
## Testing
|
||||
|
||||
**Backend Tests:**
|
||||
- pytest with `--import-mode=importlib`
|
||||
- Test files: `tests/test_*.py`
|
||||
- Fixtures in `tests/conftest.py`
|
||||
- Mock ComfyUI dependencies using standalone.py patterns
|
||||
- Markers: `@pytest.mark.asyncio` for async tests, `@pytest.mark.no_settings_dir_isolation` for real paths
|
||||
|
||||
**Frontend Tests:**
|
||||
- Vitest with jsdom environment
|
||||
- Test files: `tests/frontend/**/*.test.js`
|
||||
- Setup in `tests/frontend/setup.js`
|
||||
- Coverage via `npm run test:coverage`
|
||||
|
||||
## Important Notes
|
||||
|
||||
**Settings Location:**
|
||||
- ComfyUI mode: Auto-saves folder paths to user settings directory
|
||||
- Standalone mode: Use `settings.json` (copy from `settings.json.example`)
|
||||
- Portable mode: Set `"use_portable_settings": true` in settings.json
|
||||
|
||||
**API Integration:**
|
||||
- CivitAI API key required for downloads (add to settings)
|
||||
- CivArchive API used as fallback for deleted models
|
||||
- Metadata archive database available for offline metadata
|
||||
|
||||
**Symlink Handling:**
|
||||
- Config scans symlinks to map virtual paths to physical locations
|
||||
- Preview validation uses normalized preview root paths
|
||||
- Fingerprinting prevents redundant symlink rescans
|
||||
|
||||
**ComfyUI Node Development:**
|
||||
- Nodes defined in `py/nodes/`, registered in `__init__.py`
|
||||
- Frontend widgets in `web/comfyui/`, matched by node type
|
||||
- Use `WEB_DIRECTORY = "./web/comfyui"` convention
|
||||
|
||||
**Recipe Image Association:**
|
||||
- Recipes scan for sibling images in same directory
|
||||
- Supports repair/migration of recipe image paths
|
||||
- See `py/services/recipe_scanner.py` for implementation details
|
||||
83
README.md
83
README.md
@@ -34,15 +34,44 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.9.6
|
||||
* **Critical Performance Optimization** - Introduced persistent model cache that dramatically accelerates initialization after startup and significantly reduces Python backend memory footprint for improved application performance.
|
||||
* **Cross-Browser Settings Synchronization** - Migrated nearly all settings to the backend, ensuring your preferences sync automatically across all browsers for a seamless multi-browser experience.
|
||||
* **Protected User Settings Location** - Relocated user settings (settings.json) to the user config directory (accessible via the link icon in Settings), preventing accidental deletion during reinstalls or updates.
|
||||
* **Global Context Menu** - Added a new global context menu accessible by right-clicking on empty page areas, providing quick access to global operations with more features coming in future updates.
|
||||
* **Multi-Library Support** - Introduced support for managing multiple libraries, allowing you to easily switch between different model collections (advanced usage, documentation in progress).
|
||||
* **Bug Fixes & Stability Improvements** - Various bug fixes and enhancements for improved stability and reliability.
|
||||
### v0.9.12
|
||||
* **LoRA Randomizer System** - Introduced a comprehensive LoRA randomization system featuring LoRA Pool and LoRA Randomizer nodes for flexible and dynamic generation workflows.
|
||||
* **LoRA Randomizer Template** - Refer to the new "LoRA Randomizer" template workflow for detailed examples of flexible randomization modes, lock & reuse options, and other features.
|
||||
* **Recipe Folders** - Introduced a folder system for the Recipes page, allowing users to freely organize recipes just like they do with models.
|
||||
* **Recipe Bulk Operations** - Added bulk mode support for batch moving, deleting, and setting base models for selected recipes with intuitive controls like click-and-drag selection, drag-to-folder, and Ctrl+A (Select All).
|
||||
* **Prompt Search & Sorting** - Search recipes by prompt content and sort by Recipe Name, Imported Date, or LoRA Count for better browsing.
|
||||
* **Recipe Favorites** - Mark specific recipes as favorites for quick access.
|
||||
* **Video Recipe Support** - Enabled support for video recipes (import via LM extension or URL; video file import not supported).
|
||||
* **Performance Improvements** - Fixed performance issues for dramatically improved startup and loading speed. After first scan, subsequent loads are instant regardless of collection size.
|
||||
* **ComfyUI Nodes 2.0 Support** - Basic support for ComfyUI Nodes 2.0.
|
||||
|
||||
### v0.9.3
|
||||
### v0.9.10
|
||||
* **Smarter Update Matching** - Users can now choose to check and group updates by matching base model only or with no base-model constraint; version lists also support toggling between same-base versions or all versions.
|
||||
* **Flexible Tag Filtering** - The filter panel now supports tag exclusion: click a tag to include, click again to exclude, and click a third time to clear, enabling stronger and more flexible tag filters.
|
||||
* **License Visibility & Controls** - Model detail headers and ComfyUI preview popups now show Civitai license icons. The filter panel gains license include/exclude options, and a new global context menu action, "Refresh license metadata," fetches missing license data.
|
||||
* **Recipe Improvements** - Recipes now allow importing with zero LoRAs, and recipe detail pages show the related checkpoint for easier reference.
|
||||
* **Better ZIP Downloads** - When downloading models packaged in ZIPs, model files are extracted into the target model folder; ZIPs containing multiple model files (e.g., WanVideo high/low LoRA pairs) are added as separate models.
|
||||
* **Template Workflow Update** - Refreshed the "Illustrious Pony Example" template workflow with usage guidance for each LoRA Manager node.
|
||||
* **Bug Fixes & Stability** - General fixes and stability improvements.
|
||||
|
||||
### v0.9.9
|
||||
* **Check for Updates Feature** - Users can now check for updates for all models or selected models in bulk mode. Models with available updates will display an "update available" badge on their model card, and users can filter to show only models with updates.
|
||||
* **Model Versions Management** - Added a new Versions tab in the model modal that centralizes all versions of a model, providing download, delete, and ignore update functions.
|
||||
* **Send Checkpoint to ComfyUI** - Users can now click the send button on a checkpoint card to send the checkpoint directly to the current workflow's checkpoint or diffusion model loader node in ComfyUI.
|
||||
* **Customizable Model Card Display** - Added a new setting that allows users to choose whether to display the model name or filename on model cards.
|
||||
* **New Path Template Placeholders** - Added new path template placeholders: `{model_name}` and `{version_name}` for more flexible organization.
|
||||
* **ComfyUI Auto Path Correction Setting** - Added a new setting within ComfyUI to enable or disable the auto path correction feature.
|
||||
|
||||
### v0.9.8
|
||||
* **Full CivArchive API Support** - Added complete support for the CivArchive API as a fallback metadata source beyond Civitai API. Models deleted from Civitai can now still retrieve metadata through the CivArchive API.
|
||||
* **Download Models from CivArchive** - Added support for downloading models directly from CivArchive, similar to downloading from Civitai. Simply click the Download button and paste the model URL to download the corresponding model.
|
||||
* **Custom Priority Tags** - Introduced Custom Priority Tags feature, allowing users to define custom priority tags. These tags will appear as suggestions when editing tags or during auto organization/download using default paths, providing more precise and controlled folder organization. [Guide](https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide)
|
||||
* **Drag and Drop Tag Reordering** - Added drag and drop functionality to reorder tags in the tags edit mode for improved usability.
|
||||
* **Download Control in Example Images Panel** - Added stop control in the Download Example Images Panel for better download management.
|
||||
* **Prompt (LoraManager) Node with Autocomplete** - Added new Prompt (LoraManager) node with autocomplete feature for adding embeddings.
|
||||
* **Lora Manager Nodes in Subgraphs** - Lora Manager nodes now support being placed within subgraphs for more flexible workflow organization.
|
||||
|
||||
### v0.9.6
|
||||
* **Metadata Archive Database Support** - Added the ability to download and utilize a metadata archive database, enabling access to metadata for models that have been deleted from CivitAI.
|
||||
* **App-Level Proxy Settings** - Introduced support for configuring a global proxy within the application, making it easier to use the manager behind network restrictions.
|
||||
* **Bug Fixes** - Various bug fixes for improved stability and reliability.
|
||||
@@ -62,34 +91,6 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
* **Automatic Filename Conflict Resolution** - Implemented automatic file renaming (`original name + short hash`) to prevent conflicts when downloading or moving models.
|
||||
* **Performance Optimizations & Bug Fixes** - Various performance improvements and bug fixes for a more stable and responsive experience.
|
||||
|
||||
### v0.8.30
|
||||
* **Automatic Model Path Correction** - Added auto-correction for model paths in built-in nodes such as Load Checkpoint, Load Diffusion Model, Load LoRA, and other custom nodes with similar functionality. Workflows containing outdated or incorrect model paths will now be automatically updated to reflect the current location of your models.
|
||||
* **Node UI Enhancements** - Improved node interface for a smoother and more intuitive user experience.
|
||||
* **Bug Fixes** - Addressed various bugs to enhance stability and reliability.
|
||||
|
||||
### v0.8.29
|
||||
* **Enhanced Recipe Imports** - Improved recipe importing with new target folder selection, featuring path input autocomplete and interactive folder tree navigation. Added a "Use Default Path" option when downloading missing LoRAs.
|
||||
* **WanVideo Lora Select Node Update** - Updated the WanVideo Lora Select node with a 'merge_loras' option to match the counterpart node in the WanVideoWrapper node package.
|
||||
* **Autocomplete Conflict Resolution** - Resolved an autocomplete feature conflict in LoRA nodes with pysssss autocomplete.
|
||||
* **Improved Download Functionality** - Enhanced download functionality with resumable downloads and improved error handling.
|
||||
* **Bug Fixes** - Addressed several bugs for improved stability and performance.
|
||||
|
||||
### v0.8.28
|
||||
* **Autocomplete for Node Inputs** - Instantly find and add LoRAs by filename directly in Lora Loader, Lora Stacker, and WanVideo Lora Select nodes. Autocomplete suggestions include preview tooltips and preset weights, allowing you to quickly select LoRAs without opening the LoRA Manager UI.
|
||||
* **Duplicate Notification Control** - Added a switch to duplicates mode, enabling users to turn off duplicate model notifications for a more streamlined experience.
|
||||
* **Download Example Images from Context Menu** - Introduced a new context menu option to download example images for individual models.
|
||||
|
||||
### v0.8.27
|
||||
* **User Experience Enhancements** - Improved the model download target folder selection with path input autocomplete and interactive folder tree navigation, making it easier and faster to choose where models are saved.
|
||||
* **Default Path Option for Downloads** - Added a "Use Default Path" option when downloading models. When enabled, models are automatically organized and stored according to your configured path template settings.
|
||||
* **Advanced Download Path Templates** - Expanded path template settings, allowing users to set individual templates for LoRA, checkpoint, and embedding models for greater flexibility. Introduced the `{author}` placeholder, enabling automatic organization of model files by creator name.
|
||||
* **Bug Fixes & Stability Improvements** - Addressed various bugs and improved overall stability for a smoother experience.
|
||||
|
||||
### v0.8.26
|
||||
* **Creator Search Option** - Added ability to search models by creator name, making it easier to find models from specific authors.
|
||||
* **Enhanced Node Usability** - Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
|
||||
* **Compatibility Fixes** - Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
|
||||
|
||||
[View Update History](./update_logs.md)
|
||||
|
||||
---
|
||||
@@ -147,9 +148,10 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
|
||||
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
|
||||
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.2/lora_manager_portable.7z)
|
||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.8/lora_manager_portable.7z)
|
||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder.
|
||||
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
- Set `"use_portable_settings": true` if you want the configuration to remain inside the repository folder instead of your user settings directory.
|
||||
4. Run run.bat
|
||||
- To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`)
|
||||
|
||||
@@ -230,8 +232,9 @@ You can now run LoRA Manager independently from ComfyUI:
|
||||
```
|
||||
|
||||
2. **For non-ComfyUI users**:
|
||||
- Copy the provided `settings.json.example` file to create a new file named `settings.json`
|
||||
- Edit `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
- Copy the provided `settings.json.example` file to create a new file named `settings.json`. Update the API key, optional language, and folder paths only—the library registry is created automatically when LoRA Manager starts.
|
||||
- Edit `settings.json` to include your correct model folder paths and CivitAI API key (you can leave the defaults until ready to configure them)
|
||||
- Enable portable mode by setting `"use_portable_settings": true` if you prefer LoRA Manager to read and write the `settings.json` located in the project directory.
|
||||
- Install required dependencies: `pip install -r requirements.txt`
|
||||
- Run standalone mode:
|
||||
```bash
|
||||
|
||||
100
__init__.py
100
__init__.py
@@ -1,14 +1,21 @@
|
||||
try: # pragma: no cover - import fallback for pytest collection
|
||||
from .py.lora_manager import LoraManager
|
||||
from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
|
||||
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||
from .py.nodes.lora_stacker import LoraStacker
|
||||
from .py.nodes.save_image import SaveImage
|
||||
from .py.nodes.debug_metadata import DebugMetadata
|
||||
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect
|
||||
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
|
||||
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
|
||||
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
|
||||
from .py.nodes.prompt import PromptLM
|
||||
from .py.nodes.text import TextLM
|
||||
from .py.nodes.lora_stacker import LoraStackerLM
|
||||
from .py.nodes.save_image import SaveImageLM
|
||||
from .py.nodes.debug_metadata import DebugMetadataLM
|
||||
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM
|
||||
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraTextSelectLM
|
||||
from .py.nodes.lora_pool import LoraPoolLM
|
||||
from .py.nodes.lora_randomizer import LoraRandomizerLM
|
||||
from .py.nodes.lora_cycler import LoraCyclerLM
|
||||
from .py.metadata_collector import init as init_metadata_collector
|
||||
except ImportError: # pragma: no cover - allows running under pytest without package install
|
||||
except (
|
||||
ImportError
|
||||
): # pragma: no cover - allows running under pytest without package install
|
||||
import importlib
|
||||
import pathlib
|
||||
import sys
|
||||
@@ -17,33 +24,76 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
|
||||
if str(package_root) not in sys.path:
|
||||
sys.path.append(str(package_root))
|
||||
|
||||
PromptLM = importlib.import_module("py.nodes.prompt").PromptLM
|
||||
TextLM = importlib.import_module("py.nodes.text").TextLM
|
||||
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
||||
LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader
|
||||
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
|
||||
TriggerWordToggle = importlib.import_module("py.nodes.trigger_word_toggle").TriggerWordToggle
|
||||
LoraStacker = importlib.import_module("py.nodes.lora_stacker").LoraStacker
|
||||
SaveImage = importlib.import_module("py.nodes.save_image").SaveImage
|
||||
DebugMetadata = importlib.import_module("py.nodes.debug_metadata").DebugMetadata
|
||||
WanVideoLoraSelect = importlib.import_module("py.nodes.wanvideo_lora_select").WanVideoLoraSelect
|
||||
WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText
|
||||
LoraLoaderLM = importlib.import_module(
|
||||
"py.nodes.lora_loader"
|
||||
).LoraLoaderLM
|
||||
LoraTextLoaderLM = importlib.import_module(
|
||||
"py.nodes.lora_loader"
|
||||
).LoraTextLoaderLM
|
||||
TriggerWordToggleLM = importlib.import_module(
|
||||
"py.nodes.trigger_word_toggle"
|
||||
).TriggerWordToggleLM
|
||||
LoraStackerLM = importlib.import_module("py.nodes.lora_stacker").LoraStackerLM
|
||||
SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM
|
||||
DebugMetadataLM = importlib.import_module("py.nodes.debug_metadata").DebugMetadataLM
|
||||
WanVideoLoraSelectLM = importlib.import_module(
|
||||
"py.nodes.wanvideo_lora_select"
|
||||
).WanVideoLoraSelectLM
|
||||
WanVideoLoraTextSelectLM = importlib.import_module(
|
||||
"py.nodes.wanvideo_lora_select_from_text"
|
||||
).WanVideoLoraTextSelectLM
|
||||
LoraPoolLM = importlib.import_module("py.nodes.lora_pool").LoraPoolLM
|
||||
LoraRandomizerLM = importlib.import_module(
|
||||
"py.nodes.lora_randomizer"
|
||||
).LoraRandomizerLM
|
||||
LoraCyclerLM = importlib.import_module(
|
||||
"py.nodes.lora_cycler"
|
||||
).LoraCyclerLM
|
||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
LoraManagerLoader.NAME: LoraManagerLoader,
|
||||
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||
LoraStacker.NAME: LoraStacker,
|
||||
SaveImage.NAME: SaveImage,
|
||||
DebugMetadata.NAME: DebugMetadata,
|
||||
WanVideoLoraSelect.NAME: WanVideoLoraSelect,
|
||||
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText
|
||||
PromptLM.NAME: PromptLM,
|
||||
TextLM.NAME: TextLM,
|
||||
LoraLoaderLM.NAME: LoraLoaderLM,
|
||||
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
|
||||
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
|
||||
LoraStackerLM.NAME: LoraStackerLM,
|
||||
SaveImageLM.NAME: SaveImageLM,
|
||||
DebugMetadataLM.NAME: DebugMetadataLM,
|
||||
WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM,
|
||||
WanVideoLoraTextSelectLM.NAME: WanVideoLoraTextSelectLM,
|
||||
LoraPoolLM.NAME: LoraPoolLM,
|
||||
LoraRandomizerLM.NAME: LoraRandomizerLM,
|
||||
LoraCyclerLM.NAME: LoraCyclerLM,
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web/comfyui"
|
||||
|
||||
# Check and build Vue widgets if needed (development mode)
|
||||
try:
|
||||
from .py.vue_widget_builder import check_and_build_vue_widgets
|
||||
|
||||
# Auto-build in development, warn only if fails
|
||||
check_and_build_vue_widgets(auto_build=True, warn_only=True)
|
||||
except ImportError:
|
||||
# Fallback for pytest
|
||||
import importlib
|
||||
|
||||
check_and_build_vue_widgets = importlib.import_module(
|
||||
"py.vue_widget_builder"
|
||||
).check_and_build_vue_widgets
|
||||
check_and_build_vue_widgets(auto_build=True, warn_only=True)
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.warning(f"[LoRA Manager] Vue widget build check skipped: {e}")
|
||||
|
||||
# Initialize metadata collector
|
||||
init_metadata_collector()
|
||||
|
||||
# Register routes on import
|
||||
LoraManager.add_routes()
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY']
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
46
docs/custom_priority_tags_format.md
Normal file
46
docs/custom_priority_tags_format.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Custom Priority Tag Format Proposal
|
||||
|
||||
To support user-defined priority tags with flexible aliasing across different model types, the configuration will be stored as editable strings. The format balances readability with enough structure for parsing on both the backend and frontend.
|
||||
|
||||
## Format Overview
|
||||
|
||||
- Each model type is declared on its own line: `model_type: entries`.
|
||||
- Entries are comma-separated and ordered by priority from highest to lowest.
|
||||
- An entry may be a single canonical tag (e.g., `realistic`) or a canonical tag with aliases.
|
||||
- Canonical tags define the final folder name that should be used when matching that entry.
|
||||
- Aliases are enclosed in parentheses and separated by `|` (vertical bar).
|
||||
- All matching is case-insensitive; stored canonical names preserve the user-specified casing for folder creation and UI suggestions.
|
||||
|
||||
### Grammar
|
||||
|
||||
```
|
||||
priority-config := model-config { "\n" model-config }
|
||||
model-config := model-type ":" entry-list
|
||||
model-type := <identifier without spaces>
|
||||
entry-list := entry { "," entry }
|
||||
entry := canonical [ "(" alias { "|" alias } ")" ]
|
||||
canonical := <tag text without parentheses or commas>
|
||||
alias := <tag text without parentheses, commas, or pipes>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
lora: celebrity(celeb|celebrity), stylized, character(char)
|
||||
checkpoint: realistic(realism|realistic), anime(anime-style|toon)
|
||||
embedding: face, celeb(celebrity|celeb)
|
||||
```
|
||||
|
||||
## Parsing Notes
|
||||
|
||||
- Whitespace around separators is ignored to make manual editing more forgiving.
|
||||
- Duplicate canonical tags within the same model type collapse to a single entry; the first definition wins.
|
||||
- Aliases map to their canonical tag. When generating folder names, the canonical form is used.
|
||||
- Tags that do not match any alias or canonical entry fall back to the first tag in the model's tag list, preserving current behavior.
|
||||
|
||||
## Usage
|
||||
|
||||
- **Backend:** Convert each model type's string into an ordered list of canonical tags with alias sets. During path generation, iterate by priority order and match tags against both canonical names and their aliases.
|
||||
- **Frontend:** Surface canonical tags as suggestions, optionally displaying aliases in tooltips or secondary text. Input validation should warn about duplicate aliases within the same model type.
|
||||
|
||||
This format allows users to customize priority tag handling per model type while keeping editing simple and avoiding proliferation of folder names through alias normalization.
|
||||
28
docs/dom-widgets/README.md
Normal file
28
docs/dom-widgets/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# DOM Widgets Documentation
|
||||
|
||||
Documentation for custom DOM widget development in ComfyUI LoRA Manager.
|
||||
|
||||
## Files
|
||||
|
||||
- **[Value Persistence Best Practices](value-persistence-best-practices.md)** - Essential guide for implementing text input DOM widgets that persist values correctly
|
||||
|
||||
## Key Lessons
|
||||
|
||||
### Common Anti-Patterns
|
||||
|
||||
❌ **Don't**: Create internal state variables
|
||||
❌ **Don't**: Use v-model for text inputs
|
||||
❌ **Don't**: Add serializeValue, onSetValue callbacks
|
||||
❌ **Don't**: Watch props.widget.value
|
||||
|
||||
### Best Practices
|
||||
|
||||
✅ **Do**: Use DOM element as single source of truth
|
||||
✅ **Do**: Store DOM reference on widget.inputEl
|
||||
✅ **Do**: Direct getValue/setValue to DOM
|
||||
✅ **Do**: Clean up reference on unmount
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [DOM Widget Development Guide](../dom_widget_dev_guide.md) - Comprehensive guide for building DOM widgets
|
||||
- [ComfyUI Built-in Example](../../../../code/ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts) - Reference implementation
|
||||
225
docs/dom-widgets/value-persistence-best-practices.md
Normal file
225
docs/dom-widgets/value-persistence-best-practices.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# DOM Widget Value Persistence - Best Practices
|
||||
|
||||
## Overview
|
||||
|
||||
DOM widgets require different persistence patterns depending on their complexity. This document covers two patterns:
|
||||
|
||||
1. **Simple Text Widgets**: DOM element as source of truth (e.g., textarea, input)
|
||||
2. **Complex Widgets**: Internal value with `widget.callback` (e.g., LoraPoolWidget, RandomizerWidget)
|
||||
|
||||
## Understanding ComfyUI's Built-in Callback Mechanism
|
||||
|
||||
When `widget.value` is set (e.g., during workflow load), ComfyUI's `domWidget.ts` triggers this flow:
|
||||
|
||||
```typescript
|
||||
// From ComfyUI_frontend/src/scripts/domWidget.ts:146-149
|
||||
set value(v: V) {
|
||||
this.options.setValue?.(v) // 1. Update internal state
|
||||
this.callback?.(this.value) // 2. Notify listeners for UI updates
|
||||
}
|
||||
```
|
||||
|
||||
This means:
|
||||
- `setValue()` handles storing the value
|
||||
- `widget.callback()` is automatically called to notify the UI
|
||||
- You don't need custom callback mechanisms like `onSetValue`
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Simple Text Input Widgets
|
||||
|
||||
For widgets where the value IS the DOM element's text content (textarea, input fields).
|
||||
|
||||
### When to Use
|
||||
|
||||
- Single text input/textarea widgets
|
||||
- Value is a simple string
|
||||
- No complex state management needed
|
||||
|
||||
### Implementation
|
||||
|
||||
**main.ts:**
|
||||
```typescript
|
||||
const widget = node.addDOMWidget(name, type, container, {
|
||||
getValue() {
|
||||
return widget.inputEl?.value ?? ''
|
||||
},
|
||||
setValue(v: string) {
|
||||
if (widget.inputEl) {
|
||||
widget.inputEl.value = v ?? ''
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Vue Component:**
|
||||
```typescript
|
||||
onMounted(() => {
|
||||
if (textareaRef.value) {
|
||||
props.widget.inputEl = textareaRef.value
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (props.widget.inputEl === textareaRef.value) {
|
||||
props.widget.inputEl = undefined
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Why This Works
|
||||
|
||||
- Single source of truth: the DOM element
|
||||
- `getValue()` reads directly from DOM
|
||||
- `setValue()` writes directly to DOM
|
||||
- No sync issues between multiple state variables
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Complex Widgets
|
||||
|
||||
For widgets with structured data (JSON configs, arrays, objects) where the value cannot be stored in a DOM element.
|
||||
|
||||
### When to Use
|
||||
|
||||
- Value is a complex object/array (e.g., `{ loras: [...], settings: {...} }`)
|
||||
- Multiple UI elements contribute to the value
|
||||
- Vue reactive state manages the UI
|
||||
|
||||
### Implementation
|
||||
|
||||
**main.ts:**
|
||||
```typescript
|
||||
let internalValue: MyConfig | undefined
|
||||
|
||||
const widget = node.addDOMWidget(name, type, container, {
|
||||
getValue() {
|
||||
return internalValue
|
||||
},
|
||||
setValue(v: MyConfig) {
|
||||
internalValue = v
|
||||
// NO custom onSetValue needed - widget.callback is called automatically
|
||||
},
|
||||
serialize: true // Ensure value is saved with workflow
|
||||
})
|
||||
```
|
||||
|
||||
**Vue Component:**
|
||||
```typescript
|
||||
const config = ref<MyConfig>(getDefaultConfig())
|
||||
|
||||
onMounted(() => {
|
||||
// Set up callback for UI updates when widget.value changes externally
|
||||
// (e.g., workflow load, undo/redo)
|
||||
props.widget.callback = (newValue: MyConfig) => {
|
||||
if (newValue) {
|
||||
config.value = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// Restore initial value if workflow was already loaded
|
||||
if (props.widget.value) {
|
||||
config.value = props.widget.value
|
||||
}
|
||||
})
|
||||
|
||||
// When UI changes, update widget value
|
||||
function onConfigChange(newConfig: MyConfig) {
|
||||
config.value = newConfig
|
||||
props.widget.value = newConfig // This also triggers callback
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Works
|
||||
|
||||
1. **Clear separation**: `internalValue` stores the data, Vue ref manages the UI
|
||||
2. **Built-in callback**: ComfyUI calls `widget.callback()` automatically after `setValue()`
|
||||
3. **Bidirectional sync**:
|
||||
- External → UI: `setValue()` updates `internalValue`, `callback()` updates Vue ref
|
||||
- UI → External: User interaction updates Vue ref, which updates `widget.value`
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Creating custom callback mechanisms
|
||||
|
||||
```typescript
|
||||
// Wrong - unnecessary complexity
|
||||
setValue(v: MyConfig) {
|
||||
internalValue = v
|
||||
widget.onSetValue?.(v) // Don't add this - use widget.callback instead
|
||||
}
|
||||
```
|
||||
|
||||
Use the built-in `widget.callback` instead.
|
||||
|
||||
### ❌ Using v-model for simple text inputs in DOM widgets
|
||||
|
||||
```html
|
||||
<!-- Wrong - creates sync issues -->
|
||||
<textarea v-model="textValue" />
|
||||
|
||||
<!-- Right for simple text widgets -->
|
||||
<textarea ref="textareaRef" @input="onInput" />
|
||||
```
|
||||
|
||||
### ❌ Watching props.widget.value
|
||||
|
||||
```typescript
|
||||
// Wrong - creates race conditions
|
||||
watch(() => props.widget.value, (newValue) => {
|
||||
config.value = newValue
|
||||
})
|
||||
```
|
||||
|
||||
Use `widget.callback` instead - it's called at the right time in the lifecycle.
|
||||
|
||||
### ❌ Multiple sources of truth
|
||||
|
||||
```typescript
|
||||
// Wrong - who is the source of truth?
|
||||
let internalValue = '' // State 1
|
||||
const textValue = ref('') // State 2
|
||||
const domElement = textarea // State 3
|
||||
props.widget.value // State 4
|
||||
```
|
||||
|
||||
Choose ONE source of truth:
|
||||
- **Simple widgets**: DOM element
|
||||
- **Complex widgets**: `internalValue` (with Vue ref as derived UI state)
|
||||
|
||||
### ❌ Adding serializeValue for simple widgets
|
||||
|
||||
```typescript
|
||||
// Wrong - getValue/setValue handle serialization
|
||||
props.widget.serializeValue = async () => textValue.value
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Widget Type | Source of Truth | Use `widget.callback` | Example |
|
||||
|-------------|-----------------|----------------------|---------|
|
||||
| Simple text input | DOM element (`inputEl`) | Optional | AutocompleteTextWidget |
|
||||
| Complex config | `internalValue` | Yes, for UI sync | LoraPoolWidget |
|
||||
| Vue component widget | Vue ref + `internalValue` | Yes | RandomizerWidget |
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Load workflow - value restores correctly
|
||||
- [ ] Switch workflow - value persists
|
||||
- [ ] Reload page - value persists
|
||||
- [ ] UI interaction - value updates
|
||||
- [ ] Undo/redo - value syncs with UI
|
||||
- [ ] No console errors
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- ComfyUI DOMWidget implementation: `ComfyUI_frontend/src/scripts/domWidget.ts`
|
||||
- Simple text widget example: `ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts`
|
||||
546
docs/dom_widget_dev_guide.md
Normal file
546
docs/dom_widget_dev_guide.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# DOMWidget Development Guide
|
||||
|
||||
This document provides a comprehensive guide for developing custom DOMWidgets in ComfyUI using Vanilla JavaScript. DOMWidgets allow you to embed standard HTML elements (div, video, canvas, input, etc.) into ComfyUI nodes while benefitting from the frontend's automatic layout and zoom management.
|
||||
|
||||
## 1. Core Concepts
|
||||
|
||||
In ComfyUI, a `DOMWidget` extends the default LiteGraph Canvas rendering logic. It maintains an HTML layer on top of the Canvas, making complex interactions and media displays significantly easier to implement than pure Canvas drawing.
|
||||
|
||||
### Key APIs
|
||||
* **`app.registerExtension`**: The entry point for registering extensions.
|
||||
* **`getCustomWidgets`**: A hook for defining new widget types associated with specific input types.
|
||||
* **`node.addDOMWidget`**: The core method to add HTML elements to a node.
|
||||
|
||||
---
|
||||
|
||||
## 2. Basic Structure
|
||||
|
||||
A standard custom DOMWidget extension typically follows this structure:
|
||||
|
||||
```javascript
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "My.Custom.Extension",
|
||||
async getCustomWidgets() {
|
||||
return {
|
||||
// Define a new widget type named "MY_WIDGET_TYPE"
|
||||
MY_WIDGET_TYPE(node, inputName, inputData, app) {
|
||||
// 1. Create the HTML element
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = "Hello <b>DOMWidget</b>!";
|
||||
|
||||
// 2. Setup styles (Optional but recommended)
|
||||
container.style.color = "white";
|
||||
container.style.backgroundColor = "#222";
|
||||
container.style.padding = "5px";
|
||||
|
||||
// 3. Add the DOMWidget and return the result
|
||||
const widget = node.addDOMWidget(inputName, "MY_WIDGET_TYPE", container, {
|
||||
// Configuration options
|
||||
getValue() {
|
||||
return container.innerText;
|
||||
},
|
||||
setValue(v) {
|
||||
container.innerText = v;
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Return in the standard format
|
||||
return { widget };
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ComfyUI Dual Rendering Modes
|
||||
|
||||
ComfyUI frontend supports two rendering modes:
|
||||
|
||||
| Mode | Description | DOM Structure |
|
||||
| :--- | :--- | :--- |
|
||||
| **Canvas Mode** | Traditional rendering where widgets are rendered on top of canvas using absolute positioning | Uses `.dom-widget` class on containers |
|
||||
| **Vue DOM Mode** | New rendering mode where nodes and widgets are rendered as Vue components | Uses `.lg-node-widget` class on containers with dynamic IDs (e.g., `v-1-0`) |
|
||||
|
||||
### Mode Switching
|
||||
|
||||
The frontend switches between modes via `LiteGraph.vueNodesMode` boolean:
|
||||
- `LiteGraph.vueNodesMode = true` → Vue DOM Mode
|
||||
- `LiteGraph.vueNodesMode = false` → Canvas Mode
|
||||
|
||||
**Key Behavior**: Mode switching triggers DOM re-rendering WITHOUT page reload. Widget elements are destroyed and recreated, so any event listeners or references to old DOM elements become invalid.
|
||||
|
||||
### Testing Mode Switches via Chrome DevTools MCP
|
||||
|
||||
```javascript
|
||||
// Trigger render mode change
|
||||
LiteGraph.vueNodesMode = !LiteGraph.vueNodesMode;
|
||||
|
||||
// Force canvas redraw (optional but helps trigger re-render)
|
||||
if (app.canvas) {
|
||||
app.canvas.draw(true, true);
|
||||
}
|
||||
```
|
||||
|
||||
### Development Notes
|
||||
|
||||
When implementing widgets that attach event listeners or maintain external references:
|
||||
1. **Use `node.onRemoved`** to clean up when node is deleted
|
||||
2. **Detect DOM changes** by checking if widget input element is still in document: `document.body.contains(inputElement)`
|
||||
3. **Poll for mode changes** by watching `LiteGraph.vueNodesMode` and re-initializing when it changes
|
||||
4. **Use `loadedGraphNode` hook** for initial setup (guarantees DOM is fully rendered)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3. The `addDOMWidget` API
|
||||
|
||||
```javascript
|
||||
node.addDOMWidget(name, type, element, options)
|
||||
```
|
||||
|
||||
### Parameters
|
||||
1. **`name`**: The internal name of the widget (usually matches the input name).
|
||||
2. **`type`**: The type identifier for the widget.
|
||||
3. **`element`**: The actual HTMLElement to embed.
|
||||
4. **`options`**: (Object) Configuration for lifecycle, sizing, and persistence.
|
||||
|
||||
### Common `options` Fields
|
||||
| Field | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `getValue` | `Function` | Defines how to retrieve the widget's value for serialization. |
|
||||
| `setValue` | `Function` | Defines how to restore the widget's state from workflow data. |
|
||||
| `getMinHeight` | `Function` | Returns the minimum height in pixels. |
|
||||
| `getHeight` | `Function` | Returns the preferred height (supports numbers or percentage strings like `"50%"`). |
|
||||
| `onResize` | `Function` | Callback triggered when the widget is resized. |
|
||||
| `hideOnZoom`| `Boolean` | Whether to hide the DOM element when zoomed out to improve performance (default: `true`). |
|
||||
| `selectOn` | `string[]` | Events on the element that should trigger node selection (default: `['focus', 'click']`). |
|
||||
|
||||
---
|
||||
|
||||
## 4. Size Control
|
||||
|
||||
Custom DOMWidgets must actively inform the parent Node of their size requirements to ensure the Node layout is calculated correctly and connection wires remain aligned.
|
||||
|
||||
### 4.1 Core Mechanism
|
||||
|
||||
Whether in Canvas Mode or Vue Mode, the underlying logic model (`LGraphNode`) calls the widget's `computeLayoutSize` method to determine dimensions. This logic is used to calculate the Node's total size and the position of input/output slots.
|
||||
|
||||
### 4.2 Controlling Height
|
||||
|
||||
It is recommended to use the `options` parameter to define height behavior.
|
||||
|
||||
**Performance Note:** providing `getMinHeight` and `getHeight` via `options` allows the system to skip expensive DOM measurements (`getComputedStyle`) during rendering loop. This significantly improves performance and prevents FPS drops during node resizing.
|
||||
|
||||
**Method 1: Using `options` (Recommended)**
|
||||
|
||||
```javascript
|
||||
const widget = node.addDOMWidget("MyWidget", "custom", element, {
|
||||
// Specify minimum height in pixels
|
||||
getMinHeight: () => 150,
|
||||
|
||||
// Or specify preferred height (pixels or percentage string)
|
||||
// getHeight: () => "50%",
|
||||
});
|
||||
```
|
||||
|
||||
**Method 2: Using CSS Variables**
|
||||
|
||||
You can also set specific CSS variables on the root element:
|
||||
|
||||
```javascript
|
||||
element.style.setProperty("--comfy-widget-min-height", "150px");
|
||||
// or --comfy-widget-height
|
||||
```
|
||||
|
||||
### 4.3 Controlling Width
|
||||
|
||||
By default, a DOMWidget's width automatically stretches to fit the Node's width (which is determined by the Title or other Input Slots).
|
||||
|
||||
If you must **force the Node to be wider** to accommodate your widget, you need to override the widget instance's `computeLayoutSize` method:
|
||||
|
||||
```javascript
|
||||
const widget = node.addDOMWidget("WideWidget", "custom", element);
|
||||
|
||||
// Override the default layout calculation
|
||||
widget.computeLayoutSize = (targetNode) => {
|
||||
return {
|
||||
minHeight: 150, // Must return height
|
||||
minWidth: 300 // Force the Node to be at least 300px wide
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 4.4 Dynamic Resizing
|
||||
|
||||
If your widget's content changes dynamically (e.g., expanding sections, loading images, or CSS changes), the DOM element will resize, but the Canvas-rendered Node background and Slots will not automatically follow. You must manually trigger a synchronization.
|
||||
|
||||
**The Update Sequence:**
|
||||
Whenever the **actual rendering height** of your DOM element changes, execute the following "three-step combo":
|
||||
|
||||
```javascript
|
||||
// 1. Calculate the new optimal size for the node based on current widget requirements
|
||||
const newSize = node.computeSize();
|
||||
|
||||
// 2. Apply the new size to the node model (updates bounding box and slot positions)
|
||||
node.setSize(newSize);
|
||||
|
||||
// 3. Mark the canvas as dirty to trigger a redraw in the next animation frame
|
||||
node.setDirtyCanvas(true, true);
|
||||
```
|
||||
|
||||
**Common Scenarios:**
|
||||
|
||||
| Scenario | Actual Height Change? | Update Required? |
|
||||
| :--- | :--- | :--- |
|
||||
| **Expand/Collapse content** | **Yes** | ✅ **Yes**. Prevents widget from overflowing node boundaries. |
|
||||
| **Image/Video finished loading** | **Yes** | ✅ **Yes**. Initial height might be 0 until the media loads. |
|
||||
| **Changing `minHeight`** | **Maybe** | ❓ **Only if** the change causes the element's actual height to shift. |
|
||||
| **Changing font size/styles** | **Yes** | ✅ **Yes**. Text reflow often changes the total height. |
|
||||
| **User dragging node corner** | **Yes** | ❌ **No**. LiteGraph handles this internally. |
|
||||
|
||||
---
|
||||
|
||||
## 5. State Persistence (Serialization)
|
||||
|
||||
### 5.1 Default Behavior
|
||||
|
||||
DOMWidgets have **serialization enabled** by default (`serialize` property is `true`).
|
||||
* **Saving**: ComfyUI attempts to read the widget's value to save into the Workflow file.
|
||||
* **Loading**: ComfyUI reads the value from the Workflow file and assigns it to the widget.
|
||||
|
||||
### 5.2 Custom Serialization
|
||||
|
||||
To make persistence work effectively (saving internal DOM state and restoring it), you must implement `getValue` and `setValue` in the `options`:
|
||||
|
||||
* **`getValue`**: Returns the state to be saved (Number, String, or Object).
|
||||
* **`setValue`**: Receives the restored value and updates the DOM element.
|
||||
|
||||
**Example:**
|
||||
|
||||
```javascript
|
||||
const inputEl = document.createElement("input");
|
||||
const widget = node.addDOMWidget("MyInput", "custom", inputEl, {
|
||||
// 1. Called during Save
|
||||
getValue: () => {
|
||||
return inputEl.value;
|
||||
},
|
||||
// 2. Called during Load or Copy/Paste
|
||||
setValue: (value) => {
|
||||
inputEl.value = value || "";
|
||||
}
|
||||
});
|
||||
|
||||
// Optional: Listen for changes to update widget.value immediately
|
||||
inputEl.addEventListener("change", () => {
|
||||
widget.value = inputEl.value; // Triggers callbacks
|
||||
});
|
||||
```
|
||||
|
||||
> **⚠️ Important**: For Vue-based DOM widgets with text inputs, follow the [Value Persistence Best Practices](dom-widgets/value-persistence-best-practices.md) to avoid sync issues. Key takeaway: use DOM element as single source of truth, avoid internal state variables and v-model.
|
||||
|
||||
### 5.3 The Restoration Mechanism (`configure`)
|
||||
|
||||
* **`configure(data)`**: When a Workflow is loaded, `LGraphNode` calls its `configure(data)` method.
|
||||
* **`setValue` Chain**: During `configure`, the Node iterates over the saved `widgets_values` array and assigns each value (`widget.value = savedValue`). For DOMWidgets, this assignment triggers the `setValue` callback defined in your options.
|
||||
|
||||
Therefore, `options.setValue` is the critical hook for restoring widget state.
|
||||
|
||||
### 5.4 Disabling Serialization
|
||||
|
||||
If your widget is purely for display (e.g., a real-time monitor or generated chart) and doesn't need to save state, disable serialization to reduce workflow file size.
|
||||
|
||||
**Note**: You cannot set this via `options`. You must modify the widget instance directly.
|
||||
|
||||
```javascript
|
||||
const widget = node.addDOMWidget("DisplayOnly", "custom", element);
|
||||
widget.serialize = false; // Explicitly disable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Lifecycle & Events
|
||||
|
||||
### 6.1 `onResize`
|
||||
|
||||
When the Node size changes (e.g., user drags the corner), the widget can receive a notification via `options`:
|
||||
|
||||
```javascript
|
||||
const widget = node.addDOMWidget("ResizingWidget", "custom", element, {
|
||||
onResize: (w) => {
|
||||
// 'w' is the widget instance
|
||||
// Adjust internal DOM layout here if necessary
|
||||
console.log("Widget resized");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 6.2 Construction & Mounting
|
||||
|
||||
* **Construction**: Occurs immediately when `addDOMWidget` is called.
|
||||
* **Mounting**:
|
||||
* **Canvas Mode**: Appended to `.dom-widget-container` via `DomWidget.vue`.
|
||||
* **Vue Mode**: Appended inside the Node component via `WidgetDOM.vue`.
|
||||
* **Caution**: When `addDOMWidget` returns, the element may not be in the `document.body` yet. If you need to access layout properties like `getBoundingClientRect`, use `setTimeout` or wait for the first `onResize`.
|
||||
|
||||
### 6.3 Cleanup
|
||||
|
||||
If you create external references (like `setInterval` or global event listeners), ensure you clean them up using `node.onRemoved`:
|
||||
|
||||
```javascript
|
||||
node.onRemoved = function() {
|
||||
clearInterval(myInterval);
|
||||
// Call original onRemoved if it existed
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Styling & Best Practices
|
||||
|
||||
### 7.1 Styling
|
||||
Since DOMWidgets are placed in absolute positioned containers or managed by Vue, ensure your container handles sizing gracefully:
|
||||
|
||||
```javascript
|
||||
container.style.width = "100%";
|
||||
container.style.boxSizing = "border-box";
|
||||
```
|
||||
|
||||
### 7.2 Path References
|
||||
When importing `app`, adjust the path based on your extension's folder depth. Typically:
|
||||
`import { app } from "../../scripts/app.js";`
|
||||
|
||||
### 7.3 Security
|
||||
If setting `innerHTML` dynamically, ensure the content is sanitized or trusted to prevent XSS attacks.
|
||||
|
||||
### 7.4 UI Constraints for ComfyUI Custom Node Widgets
|
||||
|
||||
When developing DOMWidgets as internal UI widgets for ComfyUI custom nodes, keep the following constraints in mind:
|
||||
|
||||
#### 7.4.1 Minimize Vertical Space
|
||||
|
||||
ComfyUI nodes are often displayed in a compact graph view with many nodes visible simultaneously. Avoid excessive vertical spacing that could clutter the workspace.
|
||||
|
||||
- Keep layouts compact and efficient
|
||||
- Use appropriate padding and margins (4-8px typically)
|
||||
- Stack related controls vertically but avoid unnecessary spacing
|
||||
|
||||
#### 7.4.2 Avoid Dynamic Height Changes
|
||||
|
||||
Dynamic height changes (expand/collapse sections, showing/hiding content) can cause node layout recalculations and affect connection wire positioning.
|
||||
|
||||
- Prefer static layouts over expandable/collapsible sections
|
||||
- Use tooltips or overlays for additional information instead
|
||||
- If dynamic height is unavoidable, manually trigger layout updates (see Section 4.4)
|
||||
|
||||
#### 7.4.3 Keep UI Simple and Intuitive
|
||||
|
||||
As internal widgets for ComfyUI custom nodes, the UI should be accessible to users without technical implementation details.
|
||||
|
||||
- Use clear, user-friendly terminology (avoid "frontend/backend roll" in favor of "fixed/always randomize")
|
||||
- Focus on user intent rather than implementation details
|
||||
- Avoid complex interactions that may confuse users
|
||||
|
||||
#### 7.4.4 Forward Middle Mouse Events to Canvas
|
||||
|
||||
By default, when a DOM widget receives pointer events (e.g., mouse clicks, drags), these events are captured by the widget and not forwarded to the ComfyUI canvas. This prevents users from panning the workflow using the middle mouse button when the cursor is over a DOM widget.
|
||||
|
||||
To enable workflow panning over your widget, you should forward middle mouse events (button 1) to the canvas using the `forwardMiddleMouseToCanvas` utility function:
|
||||
|
||||
```javascript
|
||||
import { forwardMiddleMouseToCanvas } from "./utils.js";
|
||||
|
||||
// In your widget creation function
|
||||
const container = document.createElement("div");
|
||||
container.style.width = "100%";
|
||||
container.style.height = "100%";
|
||||
// ... other styles ...
|
||||
|
||||
// Forward middle mouse events to canvas for panning
|
||||
forwardMiddleMouseToCanvas(container);
|
||||
|
||||
const widget = node.addDOMWidget(name, type, container, { ... });
|
||||
```
|
||||
|
||||
The `forwardMiddleMouseToCanvas` function:
|
||||
- Forwards `pointerdown` events with button 1 (middle mouse button) to `app.canvas.processMouseDown`
|
||||
- Forwards `pointermove` events while middle mouse button is pressed to `app.canvas.processMouseMove`
|
||||
- Forwards `pointerup` events with button 1 to `app.canvas.processMouseUp`
|
||||
|
||||
This allows users to pan the workflow canvas even when their mouse cursor is hovering over your DOM widget.
|
||||
|
||||
---
|
||||
|
||||
## 8. Event Handling in Vue DOM Render Mode
|
||||
|
||||
ComfyUI frontend supports two rendering modes for nodes:
|
||||
- **Legacy Canvas Mode**: Traditional rendering where widgets are rendered on top of the canvas using absolute positioning
|
||||
- **Vue DOM Render Mode**: New rendering mode where nodes and widgets are rendered as Vue components
|
||||
|
||||
In Vue DOM render mode, event handling works differently. The frontend uses `useCanvasInteractions` composable to manage event forwarding to the canvas. This can cause custom event handlers in your widgets (e.g., mouse wheel for sliders, custom drag operations) to be intercepted by the canvas.
|
||||
|
||||
### 8.1 Wheel Event Handling
|
||||
|
||||
By default in Vue DOM render mode, wheel events on widgets may be forwarded to the canvas for workflow zoom, overriding your custom wheel handlers (e.g., adjusting slider values with mouse wheel).
|
||||
|
||||
To fix this, use the `data-capture-wheel="true"` attribute on elements that should capture wheel events:
|
||||
|
||||
```vue
|
||||
<!-- Vue component template -->
|
||||
<div class="my-slider" data-capture-wheel="true" @wheel="onWheel">
|
||||
<!-- Slider content -->
|
||||
</div>
|
||||
|
||||
<script setup lang="ts">
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault()
|
||||
// Custom wheel handling logic here
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- ComfyUI's `useCanvasInteractions.ts` checks `target?.closest('[data-capture-wheel="true"]')` before forwarding wheel events
|
||||
- If an element (or its ancestor) has this attribute, wheel events are not forwarded to canvas
|
||||
- Your custom `@wheel` handler will work as expected
|
||||
|
||||
**Granular control:**
|
||||
- Apply `data-capture-wheel="true"` to specific interactive elements (e.g., sliders, scrollable areas)
|
||||
- Widget container without this attribute will allow workflow zoom when wheel is used elsewhere
|
||||
- This allows users to both: adjust widget values with wheel, and zoom workflow with wheel in widget's non-interactive areas
|
||||
|
||||
**Example from DualRangeSlider.vue:**
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
class="dual-range-slider"
|
||||
:class="{ disabled, 'is-dragging': dragging !== null }"
|
||||
data-capture-wheel="true"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<!-- Slider tracks and handles -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 8.2 Pointer Event Handling
|
||||
|
||||
In Vue DOM render mode, pointer events (click, drag, etc.) may also be captured by the canvas system. For custom drag operations:
|
||||
|
||||
1. **Use event modifiers to stop propagation:**
|
||||
```vue
|
||||
<div
|
||||
@pointerdown.stop="startDrag"
|
||||
@pointermove.stop="onDrag"
|
||||
@pointerup.stop="stopDrag"
|
||||
>
|
||||
```
|
||||
|
||||
2. **Use pointer capture for reliable drag tracking:**
|
||||
```javascript
|
||||
const startDrag = (event: PointerEvent) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
target.setPointerCapture(event.pointerId)
|
||||
// ... drag initialization
|
||||
}
|
||||
|
||||
const stopDrag = (event: PointerEvent) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
target.releasePointerCapture(event.pointerId)
|
||||
// ... drag cleanup
|
||||
}
|
||||
```
|
||||
|
||||
3. **Use `touch-action: none` CSS for touch devices:**
|
||||
```css
|
||||
.my-draggable {
|
||||
touch-action: none;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Compatibility Checklist
|
||||
|
||||
Ensure your widget works in both rendering modes:
|
||||
|
||||
| Feature | Canvas Mode | Vue DOM Mode | Solution |
|
||||
|---------|-------------|--------------|----------|
|
||||
| Mouse wheel on sliders | Works by default | Needs `data-capture-wheel` | Add `data-capture-wheel="true"` to slider elements |
|
||||
| Custom drag operations | Works with `stopPropagation()` | Needs `stopPropagation()` | Use `.stop` modifier and pointer capture |
|
||||
| Middle mouse panning | Manual forwarding required | Manual forwarding required | Use `forwardMiddleMouseToCanvas()` |
|
||||
| Workflow zoom on widget edges | Works by default | Works by default | No action needed (works by default) |
|
||||
|
||||
### 8.4 Testing Recommendations
|
||||
|
||||
Test your widget in both rendering modes:
|
||||
1. Toggle between Canvas Mode and Vue DOM Mode in ComfyUI settings
|
||||
2. Verify custom interactions (wheel, drag, etc.) work in both modes
|
||||
3. Verify canvas interactions (zoom, pan) still work when cursor is over non-interactive widget areas
|
||||
4. Test with touch devices if applicable
|
||||
|
||||
---
|
||||
|
||||
## 9. Complete Example: Text Counter
|
||||
|
||||
This example implements a simple widget that displays the character count of another text widget in the same node.
|
||||
|
||||
```javascript
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.TextCounter",
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
TEXT_COUNTER(node, inputName) {
|
||||
const el = document.createElement("div");
|
||||
Object.assign(el.style, {
|
||||
background: "#222",
|
||||
border: "1px solid #444",
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
color: "#eee"
|
||||
});
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.innerText = "Characters: 0";
|
||||
el.appendChild(label);
|
||||
|
||||
const widget = node.addDOMWidget(inputName, "TEXT_COUNTER", el, {
|
||||
getValue() { return ""; }, // Nothing to save
|
||||
setValue(v) { }, // Nothing to restore
|
||||
getMinHeight() { return 40; }
|
||||
});
|
||||
|
||||
// Disable serialization for this display-only widget
|
||||
widget.serialize = false;
|
||||
|
||||
// Custom method to update UI
|
||||
widget.updateCount = (text) => {
|
||||
label.innerText = `Characters: ${text.length}`;
|
||||
};
|
||||
|
||||
return { widget };
|
||||
}
|
||||
};
|
||||
},
|
||||
nodeCreated(node) {
|
||||
// Logic to link widgets after the node is initialized
|
||||
if (node.comfyClass === "MyTextNode") {
|
||||
const counterWidget = node.widgets.find(w => w.type === "TEXT_COUNTER");
|
||||
const textWidget = node.widgets.find(w => w.name === "text");
|
||||
|
||||
if (counterWidget && textWidget) {
|
||||
// Hook into the text widget's callback
|
||||
const oldCallback = textWidget.callback;
|
||||
textWidget.callback = function(v) {
|
||||
if (oldCallback) oldCallback.apply(this, arguments);
|
||||
counterWidget.updateCount(v);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -21,7 +21,7 @@ This matrix captures the scenarios that Phase 3 frontend tests should cover for
|
||||
| ID | Feature | Scenario | LoRAs Expectations | Checkpoints Expectations | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| F-01 | Search filter | Typing a query updates `pageState.filters.search`, persists to session, and triggers `resetAndReload` on submit | Validate `SearchManager` writes query and reloads via API stub; confirm LoRA cards pass query downstream | Same as LoRAs | Cover `enter` press and clicking search icon |
|
||||
| F-02 | Tag filter | Selecting a tag chip adds it to filters, applies active styling, and reloads results | Tag stored under `filters.tags`; `FilterManager.applyFilters` persists and triggers `resetAndReload(true)` | Same; ensure base model tag set is scoped to checkpoints dataset | Include removal path |
|
||||
| F-02 | Tag filter | Selecting a tag chip cycles include ➜ exclude ➜ clear, updates storage, and reloads results | Tag state stored under `filters.tags[tagName] = 'include'|'exclude'`; `FilterManager.applyFilters` persists and triggers `resetAndReload(true)` | Same; ensure base model tag set is scoped to checkpoints dataset | Include removal path |
|
||||
| F-03 | Base model filter | Toggling base model checkboxes updates `filters.baseModel`, persists, and reloads | Ensure only LoRA-supported models show; toggle multi-select | Ensure SDXL/Flux base models appear as expected | Capture UI state restored from storage on next init |
|
||||
| F-04 | Favorites-only | Clicking favorites toggle updates session flag and calls `resetAndReload(true)` | Button gains `.active` class and API called | Same | Verify duplicates badge refresh when active |
|
||||
| F-05 | Sort selection | Changing sort select saves preference (legacy + new format) and reloads | Confirm `PageControls.saveSortPreference` invoked with option and API called | Same with checkpoints-specific defaults | Cover `convertLegacySortFormat` branch |
|
||||
|
||||
71
docs/priority_tags_help.md
Normal file
71
docs/priority_tags_help.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Priority Tags Configuration Guide
|
||||
|
||||
This guide explains how to tailor the tag priority order that powers folder naming and tag suggestions in the LoRA Manager. You only need to edit the comma-separated list of entries shown in the **Priority Tags** field for each model type.
|
||||
|
||||
## 1. Pick the Model Type
|
||||
|
||||
In the **Priority Tags** dialog you will find one tab per model type (LoRA, Checkpoint, Embedding). Select the tab you want to update; changes on one tab do not affect the others.
|
||||
|
||||
## 2. Edit the Entry List
|
||||
|
||||
Inside the textarea you will see a line similar to:
|
||||
|
||||
```
|
||||
character, concept, style(toon|toon_style)
|
||||
```
|
||||
|
||||
This entire line is the **entry list**. Replace it with your own ordered list.
|
||||
|
||||
### Entry Rules
|
||||
|
||||
Each entry is separated by a comma, in order from highest to lowest priority:
|
||||
|
||||
- **Canonical tag only:** `realistic`
|
||||
- **Canonical tag with aliases:** `character(char|chars)`
|
||||
|
||||
Aliases live inside `()` and are separated with `|`. The canonical name is what appears in folder names and UI suggestions when any of the aliases are detected. Matching is case-insensitive.
|
||||
|
||||
## Use `{first_tag}` in Path Templates
|
||||
|
||||
When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the model’s own tags:
|
||||
|
||||
- It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name.
|
||||
- If no priority tags are found but the model has tags, the very first model tag is used.
|
||||
- If the model has no tags at all, the folder falls back to `no tags`.
|
||||
|
||||
### Example
|
||||
|
||||
With a template like `/{model_type}/{first_tag}` and the priority entry list `character(char|chars), style(anime|toon)`:
|
||||
|
||||
| Model Tags | Folder Name | Why |
|
||||
| --- | --- | --- |
|
||||
| `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. |
|
||||
| `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. |
|
||||
| `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. |
|
||||
| `[]` | `no tags` | Nothing to match, so the fallback is applied. |
|
||||
|
||||
## 3. Save the Settings
|
||||
|
||||
After editing the entry list, press **Enter** to save. Use **Shift+Enter** whenever you need a new line. Clicking outside the field also saves automatically. A success toast confirms the update.
|
||||
|
||||
## Examples
|
||||
|
||||
| Goal | Entry List |
|
||||
| --- | --- |
|
||||
| Prefer people over styles | `character, portraits, style(anime\|toon)` |
|
||||
| Group sci-fi variants | `sci-fi(scifi\|science_fiction), cyberpunk(cyber\|punk)` |
|
||||
| Alias shorthand tags | `realistic(real\|realisim), photorealistic(photo_real)` |
|
||||
|
||||
## Tips
|
||||
|
||||
- Keep canonical names short and meaningful—they become folder names.
|
||||
- Place the most important categories first; the first match wins.
|
||||
- Avoid duplicate canonical names within the same list; only the first instance is used.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Unexpected folder name?** Check that the canonical name you want is placed before other matches.
|
||||
- **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`.
|
||||
- **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`.
|
||||
|
||||
With these basics you can quickly adapt Priority Tags to match your library’s organization style.
|
||||
69
docs/reference/danbooru_e621_categories.md
Normal file
69
docs/reference/danbooru_e621_categories.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Danbooru/E621 Tag Categories Reference
|
||||
|
||||
Reference for category values used in `danbooru_e621_merged.csv` tag files.
|
||||
|
||||
## Category Value Mapping
|
||||
|
||||
### Danbooru Categories
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| 0 | General |
|
||||
| 1 | Artist |
|
||||
| 2 | *(unused)* |
|
||||
| 3 | Copyright |
|
||||
| 4 | Character |
|
||||
| 5 | Meta |
|
||||
|
||||
### e621 Categories
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| 6 | *(unused)* |
|
||||
| 7 | General |
|
||||
| 8 | Artist |
|
||||
| 9 | Contributor |
|
||||
| 10 | Copyright |
|
||||
| 11 | Character |
|
||||
| 12 | Species |
|
||||
| 13 | *(unused)* |
|
||||
| 14 | Meta |
|
||||
| 15 | Lore |
|
||||
|
||||
## Danbooru Category Colors
|
||||
|
||||
| Description | Normal Color | Hover Color |
|
||||
|-------------|--------------|-------------|
|
||||
| General | #009be6 | #4bb4ff |
|
||||
| Artist | #ff8a8b | #ffc3c3 |
|
||||
| Copyright | #c797ff | #ddc9fb |
|
||||
| Character | #35c64a | #93e49a |
|
||||
| Meta | #ead084 | #f7e7c3 |
|
||||
|
||||
## CSV Column Structure
|
||||
|
||||
Each row in the merged CSV file contains 4 columns:
|
||||
|
||||
| Column | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| 1 | Tag name | `1girl`, `highres`, `solo` |
|
||||
| 2 | Category value (0-15) | `0`, `5`, `7` |
|
||||
| 3 | Post count | `6008644`, `5256195` |
|
||||
| 4 | Aliases (comma-separated, quoted) | `"1girls,sole_female"`, empty string |
|
||||
|
||||
### Sample Data
|
||||
|
||||
```
|
||||
1girl,0,6008644,"1girls,sole_female"
|
||||
highres,5,5256195,"high_res,high_resolution,hires"
|
||||
solo,0,5000954,"alone,female_solo,single,solo_female"
|
||||
long_hair,0,4350743,"/lh,longhair"
|
||||
mammal,12,3437444,"cetancodont,cetancodontamorph,feralmammal"
|
||||
anthro,7,3381927,"adult_anthro,anhtro,antho,anthro_horse"
|
||||
skirt,0,1557883,
|
||||
```
|
||||
|
||||
## Source
|
||||
|
||||
- [PR #312: Add danbooru_e621_merged.csv](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete/pull/312)
|
||||
- [DraconicDragon/dbr-e621-lists-archive](https://github.com/DraconicDragon/dbr-e621-lists-archive)
|
||||
191
docs/technical/model_type_refactoring_todo.md
Normal file
191
docs/technical/model_type_refactoring_todo.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Model Type 字段重构 - 遗留工作清单
|
||||
|
||||
> **状态**: Phase 1-4 已完成 | **创建日期**: 2026-01-30
|
||||
> **相关文件**: `py/utils/models.py`, `py/services/model_query.py`, `py/services/checkpoint_scanner.py`, etc.
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
本次重构旨在解决 `model_type` 字段语义不统一的问题。系统中有两个层面的"类型"概念:
|
||||
|
||||
1. **Scanner Type** (`scanner_type`): 架构层面的大类 - `lora`, `checkpoint`, `embedding`
|
||||
2. **Sub Type** (`sub_type`): 业务层面的细分类型 - `lora`/`locon`/`dora`, `checkpoint`/`diffusion_model`, `embedding`
|
||||
|
||||
重构目标是统一使用 `sub_type` 表示细分类型,保留 `model_type` 作为向后兼容的别名。
|
||||
|
||||
---
|
||||
|
||||
## 已完成工作 ✅
|
||||
|
||||
### Phase 1: 后端字段重命名
|
||||
- [x] `CheckpointMetadata.model_type` → `sub_type`
|
||||
- [x] `EmbeddingMetadata.model_type` → `sub_type`
|
||||
- [x] `model_scanner.py` `_build_cache_entry()` 同时处理 `sub_type` 和 `model_type`
|
||||
|
||||
### Phase 2: 查询逻辑更新
|
||||
- [x] `model_query.py` 新增 `resolve_sub_type()` 和 `normalize_sub_type()`
|
||||
- [x] ~~保持向后兼容的别名 `resolve_civitai_model_type`, `normalize_civitai_model_type`~~ (已在 Phase 5 移除)
|
||||
- [x] `ModelFilterSet.apply()` 更新为使用新的解析函数
|
||||
|
||||
### Phase 3: API 响应更新
|
||||
- [x] `LoraService.format_response()` 返回 `sub_type` ~~+ `model_type`~~ (已移除 `model_type`)
|
||||
- [x] `CheckpointService.format_response()` 返回 `sub_type` ~~+ `model_type`~~ (已移除 `model_type`)
|
||||
- [x] `EmbeddingService.format_response()` 返回 `sub_type` ~~+ `model_type`~~ (已移除 `model_type`)
|
||||
|
||||
### Phase 4: 前端更新
|
||||
- [x] `constants.js` 新增 `MODEL_SUBTYPE_DISPLAY_NAMES`
|
||||
- [x] `MODEL_TYPE_DISPLAY_NAMES` 作为别名保留
|
||||
|
||||
### Phase 5: 清理废弃代码 ✅
|
||||
- [x] 从 `ModelScanner._build_cache_entry()` 中移除 `model_type` 向后兼容代码
|
||||
- [x] 从 `CheckpointScanner` 中移除 `model_type` 兼容处理
|
||||
- [x] 从 `model_query.py` 中移除 `resolve_civitai_model_type` 和 `normalize_civitai_model_type` 别名
|
||||
- [x] 更新前端 `FilterManager.js` 使用 `sub_type` (已在使用 `MODEL_SUBTYPE_DISPLAY_NAMES`)
|
||||
- [x] 更新所有相关测试
|
||||
|
||||
---
|
||||
|
||||
## 遗留工作 ⏳
|
||||
|
||||
### Phase 5: 清理废弃代码 ✅ **已完成**
|
||||
|
||||
所有 Phase 5 的清理工作已完成:
|
||||
|
||||
#### 5.1 移除 `model_type` 字段的向后兼容代码 ✅
|
||||
- 从 `ModelScanner._build_cache_entry()` 中移除了 `model_type` 的设置
|
||||
- 现在只设置 `sub_type` 字段
|
||||
|
||||
#### 5.2 移除 CheckpointScanner 的 model_type 兼容处理 ✅
|
||||
- `adjust_metadata()` 现在只处理 `sub_type`
|
||||
- `adjust_cached_entry()` 现在只设置 `sub_type`
|
||||
|
||||
#### 5.3 移除 model_query 中的向后兼容别名 ✅
|
||||
- 移除了 `resolve_civitai_model_type = resolve_sub_type`
|
||||
- 移除了 `normalize_civitai_model_type = normalize_sub_type`
|
||||
|
||||
#### 5.4 前端清理 ✅
|
||||
- `FilterManager.js` 已经在使用 `MODEL_SUBTYPE_DISPLAY_NAMES` (通过别名 `MODEL_TYPE_DISPLAY_NAMES`)
|
||||
- API list endpoint 现在只返回 `sub_type`,不再返回 `model_type`
|
||||
- `ModelCard.js` 现在设置 `card.dataset.sub_type` (所有模型类型通用)
|
||||
- `CheckpointContextMenu.js` 现在读取 `card.dataset.sub_type`
|
||||
- `MoveManager.js` 现在处理 `cache_entry.sub_type`
|
||||
- `RecipeModal.js` 现在读取 `checkpoint.sub_type`
|
||||
|
||||
---
|
||||
|
||||
## 数据库迁移评估
|
||||
|
||||
### 当前状态
|
||||
- `persistent_model_cache.py` 使用 `civitai_model_type` 列存储 CivitAI 原始类型
|
||||
- 缓存 entry 中的 `sub_type` 在运行期动态计算
|
||||
- 数据库 schema **无需立即修改**
|
||||
|
||||
### 未来可选优化
|
||||
```sql
|
||||
-- 可选:在 models 表中添加 sub_type 列(与 civitai_model_type 保持一致但语义更清晰)
|
||||
ALTER TABLE models ADD COLUMN sub_type TEXT;
|
||||
|
||||
-- 数据迁移
|
||||
UPDATE models SET sub_type = civitai_model_type WHERE sub_type IS NULL;
|
||||
```
|
||||
|
||||
**建议**: 如果决定添加 `sub_type` 列,应与 Phase 5 一起进行。
|
||||
|
||||
---
|
||||
|
||||
## 测试覆盖率
|
||||
|
||||
### 新增/更新测试文件(已全部通过 ✅)
|
||||
|
||||
| 测试文件 | 数量 | 覆盖内容 |
|
||||
|---------|------|---------|
|
||||
| `tests/utils/test_models_sub_type.py` | 7 | Metadata sub_type 字段 |
|
||||
| `tests/services/test_model_query_sub_type.py` | 19 | sub_type 解析和过滤 |
|
||||
| `tests/services/test_checkpoint_scanner_sub_type.py` | 6 | CheckpointScanner sub_type |
|
||||
| `tests/services/test_service_format_response_sub_type.py` | 6 | API 响应 sub_type 包含 |
|
||||
| `tests/services/test_checkpoint_scanner.py` | 1 | Checkpoint 缓存 sub_type |
|
||||
| `tests/services/test_model_scanner.py` | 1 | adjust_cached_entry hook |
|
||||
| `tests/services/test_download_manager.py` | 1 | Checkpoint 下载 sub_type |
|
||||
|
||||
### 需要补充的测试(可选)
|
||||
|
||||
- [ ] 集成测试:验证前端过滤使用 sub_type 字段
|
||||
- [ ] 数据库迁移测试(如果执行可选优化)
|
||||
- [ ] 性能测试:确认 resolve_sub_type 的优先级查找没有显著性能影响
|
||||
|
||||
---
|
||||
|
||||
## 兼容性检查清单
|
||||
|
||||
### 已完成 ✅
|
||||
|
||||
- [x] 前端代码已全部改用 `sub_type` 字段
|
||||
- [x] API list endpoint 已移除 `model_type`,只返回 `sub_type`
|
||||
- [x] 后端 cache entry 已移除 `model_type`,只保留 `sub_type`
|
||||
- [x] 所有测试已更新通过
|
||||
- [x] 文档已更新
|
||||
|
||||
---
|
||||
|
||||
## 相关文件清单
|
||||
|
||||
### 核心文件
|
||||
```
|
||||
py/utils/models.py
|
||||
py/utils/constants.py
|
||||
py/services/model_scanner.py
|
||||
py/services/model_query.py
|
||||
py/services/checkpoint_scanner.py
|
||||
py/services/base_model_service.py
|
||||
py/services/lora_service.py
|
||||
py/services/checkpoint_service.py
|
||||
py/services/embedding_service.py
|
||||
```
|
||||
|
||||
### 前端文件
|
||||
```
|
||||
static/js/utils/constants.js
|
||||
static/js/managers/FilterManager.js
|
||||
static/js/managers/MoveManager.js
|
||||
static/js/components/shared/ModelCard.js
|
||||
static/js/components/ContextMenu/CheckpointContextMenu.js
|
||||
static/js/components/RecipeModal.js
|
||||
```
|
||||
|
||||
### 测试文件
|
||||
```
|
||||
tests/utils/test_models_sub_type.py
|
||||
tests/services/test_model_query_sub_type.py
|
||||
tests/services/test_checkpoint_scanner_sub_type.py
|
||||
tests/services/test_service_format_response_sub_type.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险项 | 影响 | 缓解措施 |
|
||||
|-------|------|---------|
|
||||
| ~~第三方代码依赖 `model_type`~~ | ~~高~~ | ~~保持别名至少 1 个 major 版本~~ ✅ 已完成移除 |
|
||||
| ~~数据库 schema 变更~~ | ~~中~~ | ~~暂缓 schema 变更,仅运行时计算~~ ✅ 无需变更 |
|
||||
| ~~前端过滤失效~~ | ~~中~~ | ~~全面的集成测试覆盖~~ ✅ 测试通过 |
|
||||
| CivitAI API 变化 | 低 | 保持多源解析策略 |
|
||||
|
||||
---
|
||||
|
||||
## 时间线
|
||||
|
||||
- **v1.x**: Phase 1-4 已完成,保持向后兼容
|
||||
- **v2.0 (当前)**: ✅ Phase 5 已完成 - `model_type` 兼容代码已移除
|
||||
- API list endpoint 只返回 `sub_type`
|
||||
- Cache entry 只保留 `sub_type`
|
||||
- 移除了 `resolve_civitai_model_type` 和 `normalize_civitai_model_type` 别名
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 重构期间发现 `civitai_model_type` 数据库列命名尚可,但语义上应理解为存储 CivitAI API 返回的原始类型值
|
||||
- Checkpoint 的 `diffusion_model` sub_type 不能通过 CivitAI API 获取,必须通过文件路径(model root)判断
|
||||
- LoRA 的 sub_type(lora/locon/dora)直接来自 CivitAI API 的 `version_info.model.type`
|
||||
26
docs/testing/coverage_analysis.md
Normal file
26
docs/testing/coverage_analysis.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Backend Test Coverage Notes
|
||||
|
||||
## Pytest Execution
|
||||
- Command: `python -m pytest`
|
||||
- Result: All 283 collected tests passed in the current environment.
|
||||
- Coverage tooling (``pytest-cov``/``coverage``) is unavailable in the offline sandbox, so line-level metrics could not be generated. The earlier attempt to install ``pytest-cov`` failed because the package index cannot be reached from the container.
|
||||
|
||||
## High-Priority Gaps to Address
|
||||
|
||||
### 1. Standalone server bootstrapping
|
||||
* **Source:** [`standalone.py`](../../standalone.py)
|
||||
* **Why it matters:** The standalone entry point wires together the aiohttp application, static asset routes, model-route registration, and configuration validation. None of these behaviours are covered by automated tests, leaving regressions in bootstrapping logic undetected.
|
||||
* **Suggested coverage:** Add integration-style tests that instantiate `StandaloneServer`/`StandaloneLoraManager` with temporary settings and assert that routes (HTTP + websocket) are registered, configuration warnings fire for missing paths, and the mock ComfyUI shims behave as expected.
|
||||
|
||||
### 2. Model service registration factory
|
||||
* **Source:** [`py/services/model_service_factory.py`](../../py/services/model_service_factory.py)
|
||||
* **Why it matters:** The factory coordinates which model services and routes the API exposes, including error handling when unknown model types are requested. No current tests verify registration, memoization of route instances, or the logging path on failures.
|
||||
* **Suggested coverage:** Unit tests that exercise `register_model_type`, `get_route_instance`, error branches in `get_service_class`/`get_route_class`, and `setup_all_routes` when a route setup raises. Use lightweight fakes to confirm the logger is called and state is cleared via `clear_registrations`.
|
||||
|
||||
### 3. Server-side i18n helper
|
||||
* **Source:** [`py/services/server_i18n.py`](../../py/services/server_i18n.py)
|
||||
* **Why it matters:** Template rendering relies on the `ServerI18nManager` to load locale JSON, perform key lookups, and format parameters. The fallback logic (dot-notation lookup, English fallbacks, placeholder substitution) is untested, so malformed locale files or regressions in placeholder handling would slip through.
|
||||
* **Suggested coverage:** Tests that load fixture locale dictionaries, assert `set_locale` fallbacks, verify nested key resolution and placeholder substitution, and ensure missing keys return the original identifier.
|
||||
|
||||
## Next Steps
|
||||
Prioritize creating focused unit tests around these modules, then re-run pytest once coverage tooling is available to confirm the new tests close the identified gaps.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 669 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 669 KiB |
File diff suppressed because one or more lines are too long
BIN
example_workflows/Lora_Cycler.jpg
Normal file
BIN
example_workflows/Lora_Cycler.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 657 KiB |
1
example_workflows/Lora_Cycler.json
Normal file
1
example_workflows/Lora_Cycler.json
Normal file
File diff suppressed because one or more lines are too long
BIN
example_workflows/Lora_Manager_Basic.jpg
Normal file
BIN
example_workflows/Lora_Manager_Basic.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 668 KiB |
1
example_workflows/Lora_Manager_Basic.json
Normal file
1
example_workflows/Lora_Manager_Basic.json
Normal file
File diff suppressed because one or more lines are too long
BIN
example_workflows/Lora_Randomizer.jpg
Normal file
BIN
example_workflows/Lora_Randomizer.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 739 KiB |
1
example_workflows/Lora_Randomizer.json
Normal file
1
example_workflows/Lora_Randomizer.json
Normal file
File diff suppressed because one or more lines are too long
394
locales/de.json
394
locales/de.json
@@ -10,7 +10,8 @@
|
||||
"next": "Weiter",
|
||||
"backToTop": "Nach oben",
|
||||
"settings": "Einstellungen",
|
||||
"help": "Hilfe"
|
||||
"help": "Hilfe",
|
||||
"add": "Hinzufügen"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Wird geladen...",
|
||||
@@ -32,7 +33,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Bytes",
|
||||
@@ -101,7 +102,12 @@
|
||||
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
||||
"toggleBlur": "Unschärfe umschalten",
|
||||
"show": "Anzeigen",
|
||||
"openExampleImages": "Beispielbilder-Ordner öffnen"
|
||||
"openExampleImages": "Beispielbilder-Ordner öffnen",
|
||||
"replacePreview": "Vorschau ersetzen",
|
||||
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Nicht jugendfreie Inhalte",
|
||||
@@ -115,12 +121,20 @@
|
||||
"updateFailed": "Fehler beim Aktualisieren des Favoriten-Status"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Checkpoint an Workflow senden - Funktion wird implementiert"
|
||||
"checkpointNotImplemented": "Checkpoint an Workflow senden - Funktion wird implementiert",
|
||||
"missingPath": "Modellpfad für diese Karte konnte nicht ermittelt werden"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Fehler beim Überprüfen der Beispielbilder",
|
||||
"missingHash": "Fehlende Modell-Hash-Informationen.",
|
||||
"noRemoteImagesAvailable": "Keine Remote-Beispielbilder für dieses Modell auf Civitai verfügbar"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Update",
|
||||
"updateAvailable": "Update verfügbar"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Verwendungsanzahl"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,12 +143,33 @@
|
||||
"missingPath": "Bitte legen Sie einen Speicherort fest, bevor Sie Beispielbilder herunterladen.",
|
||||
"unavailable": "Beispielbild-Downloads sind noch nicht verfügbar. Versuchen Sie es erneut, nachdem die Seite vollständig geladen ist."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Auf Updates prüfen",
|
||||
"loading": "Prüfe auf {type}-Updates...",
|
||||
"success": "{count} Update(s) für {type} gefunden",
|
||||
"none": "Alle {type} sind auf dem neuesten Stand",
|
||||
"error": "Fehler beim Prüfen auf {type}-Updates: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Beispielbild-Ordner bereinigen",
|
||||
"success": "{count} Ordner wurden in den Papierkorb verschoben",
|
||||
"none": "Keine Beispielbild-Ordner mussten bereinigt werden",
|
||||
"partial": "Bereinigung abgeschlossen, {failures} Ordner übersprungen",
|
||||
"error": "Fehler beim Bereinigen der Beispielbild-Ordner: {message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "Recipe-Daten reparieren",
|
||||
"loading": "Recipe-Daten werden repariert...",
|
||||
"success": "{count} Rezepte erfolgreich repariert.",
|
||||
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -144,6 +179,7 @@
|
||||
"recipes": "Rezepte",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"misc": "[TODO: Translate] Misc",
|
||||
"statistics": "Statistiken"
|
||||
},
|
||||
"search": {
|
||||
@@ -152,7 +188,8 @@
|
||||
"loras": "LoRAs suchen...",
|
||||
"recipes": "Rezepte suchen...",
|
||||
"checkpoints": "Checkpoints suchen...",
|
||||
"embeddings": "Embeddings suchen..."
|
||||
"embeddings": "Embeddings suchen...",
|
||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
||||
},
|
||||
"options": "Suchoptionen",
|
||||
"searchIn": "Suchen in:",
|
||||
@@ -164,13 +201,30 @@
|
||||
"creator": "Ersteller",
|
||||
"title": "Rezept-Titel",
|
||||
"loraName": "LoRA-Dateiname",
|
||||
"loraModel": "LoRA-Modellname"
|
||||
"loraModel": "LoRA-Modellname",
|
||||
"prompt": "Prompt"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "Modelle filtern",
|
||||
"presets": "Voreinstellungen",
|
||||
"savePreset": "Aktive Filter als neue Voreinstellung speichern.",
|
||||
"savePresetDisabledActive": "Speichern nicht möglich: Eine Voreinstellung ist bereits aktiv. Ändern Sie die Filter, um eine neue Voreinstellung zu speichern",
|
||||
"savePresetDisabledNoFilters": "Wählen Sie zuerst Filter aus, um als Voreinstellung zu speichern",
|
||||
"savePresetPrompt": "Voreinstellungsname eingeben:",
|
||||
"presetClickTooltip": "Voreinstellung \"{name}\" anwenden",
|
||||
"presetDeleteTooltip": "Voreinstellung löschen",
|
||||
"presetDeleteConfirm": "Voreinstellung \"{name}\" löschen?",
|
||||
"presetDeleteConfirmClick": "Zum Bestätigen erneut klicken",
|
||||
"presetOverwriteConfirm": "Voreinstellung \"{name}\" existiert bereits. Überschreiben?",
|
||||
"presetNamePlaceholder": "Voreinstellungsname...",
|
||||
"baseModel": "Basis-Modell",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
"noTags": "Keine Tags",
|
||||
"clearAll": "Alle Filter löschen"
|
||||
},
|
||||
"theme": {
|
||||
@@ -181,6 +235,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Updates prüfen",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"support": "Unterstützung"
|
||||
}
|
||||
},
|
||||
@@ -192,19 +247,29 @@
|
||||
"label": "Einstellungsordner öffnen",
|
||||
"tooltip": "Den Ordner mit der settings.json öffnen",
|
||||
"success": "Einstellungsordner geöffnet",
|
||||
"failed": "Einstellungsordner konnte nicht geöffnet werden"
|
||||
"failed": "Einstellungsordner konnte nicht geöffnet werden",
|
||||
"copied": "Einstellungspfad in die Zwischenablage kopiert: {{path}}",
|
||||
"clipboardFallback": "Einstellungspfad: {{path}}"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Inhaltsfilterung",
|
||||
"videoSettings": "Video-Einstellungen",
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"folderSettings": "Ordner-Einstellungen",
|
||||
"priorityTags": "Prioritäts-Tags",
|
||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||
"exampleImages": "Beispielbilder",
|
||||
"updateFlags": "Update-Markierungen",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"misc": "Verschiedenes",
|
||||
"metadataArchive": "Metadaten-Archiv-Datenbank",
|
||||
"storageLocation": "Einstellungsort",
|
||||
"proxySettings": "Proxy-Einstellungen"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "Portabler Modus",
|
||||
"locationHelp": "Aktiviere, um settings.json im Repository zu belassen; deaktiviere, um es im Benutzerkonfigurationsordner zu speichern."
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
|
||||
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
|
||||
@@ -215,6 +280,15 @@
|
||||
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
|
||||
"autoplayOnHoverHelp": "Video-Vorschauen nur beim Darüberfahren mit der Maus abspielen"
|
||||
},
|
||||
"autoOrganizeExclusions": {
|
||||
"label": "Auto-Organisierungs-Ausnahmen",
|
||||
"placeholder": "Beispiel: curated/*, */backups/*; *_temp.safetensors",
|
||||
"help": "Dateien überspringen, die mit diesen Wildcard-Mustern übereinstimmen. Mehrere Muster mit Kommas oder Semikolons trennen.",
|
||||
"validation": {
|
||||
"noPatterns": "Geben Sie mindestens ein Muster ein, getrennt durch Kommas oder Semikolons.",
|
||||
"saveFailed": "Fehler beim Speichern der Ausschlüsse: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Anzeige-Dichte",
|
||||
"displayDensityOptions": {
|
||||
@@ -224,21 +298,31 @@
|
||||
},
|
||||
"displayDensityHelp": "Wählen Sie, wie viele Karten pro Zeile angezeigt werden sollen:",
|
||||
"displayDensityDetails": {
|
||||
"default": "Standard: 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "Mittel: 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "Kompakt: 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
|
||||
"showFolderSidebar": "Ordner-Seitenleiste anzeigen",
|
||||
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
|
||||
"cardInfoDisplay": "Karten-Info-Anzeige",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Immer sichtbar",
|
||||
"hover": "Bei Hover anzeigen"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Immer sichtbar: Kopf- und Fußzeilen sind immer sichtbar",
|
||||
"hover": "Bei Hover anzeigen: Kopf- und Fußzeilen erscheinen nur beim Darüberfahren mit der Maus"
|
||||
}
|
||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
||||
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Beispielbilder öffnen",
|
||||
"replacePreview": "Vorschau ersetzen"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Wähle aus, was die Schaltfläche unten rechts auf der Karte ausführt",
|
||||
"modelNameDisplay": "Anzeige des Modellnamens",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Modellname",
|
||||
"fileName": "Dateiname"
|
||||
},
|
||||
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Aktive Bibliothek",
|
||||
@@ -249,10 +333,32 @@
|
||||
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultCheckpointRoot": "Standard-Checkpoint-Stammordner",
|
||||
"defaultCheckpointRootHelp": "Legen Sie den Standard-Checkpoint-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultUnetRoot": "Standard-Diffusion-Modell-Stammordner",
|
||||
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultEmbeddingRoot": "Standard-Embedding-Stammordner",
|
||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"noDefault": "Kein Standard"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Prioritäts-Tags",
|
||||
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "Prioritäts-Tags-Hilfe öffnen",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "Prioritäts-Tags aktualisiert.",
|
||||
"saveError": "Prioritäts-Tags konnten nicht aktualisiert werden.",
|
||||
"loadingSuggestions": "Lade Vorschläge...",
|
||||
"validation": {
|
||||
"missingClosingParen": "Eintrag {index} fehlt eine schließende Klammer.",
|
||||
"missingCanonical": "Eintrag {index} muss einen kanonischen Tag-Namen enthalten.",
|
||||
"duplicateCanonical": "Der kanonische Tag \"{tag}\" kommt mehrfach vor.",
|
||||
"unknown": "Ungültige Prioritäts-Tag-Konfiguration."
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "Download-Pfad-Vorlagen",
|
||||
"help": "Konfigurieren Sie Ordnerstrukturen für verschiedene Modelltypen beim Herunterladen von Civitai.",
|
||||
@@ -300,6 +406,14 @@
|
||||
"download": "Herunterladen",
|
||||
"restartRequired": "Neustart erforderlich"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "Strategie für Update-Markierungen",
|
||||
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
|
||||
"options": {
|
||||
"sameBase": "Updates nach Basismodell abgleichen",
|
||||
"any": "Jede verfügbare Aktualisierung markieren"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
|
||||
@@ -359,12 +473,17 @@
|
||||
"dateAsc": "Älteste",
|
||||
"size": "Dateigröße",
|
||||
"sizeDesc": "Größte",
|
||||
"sizeAsc": "Kleinste"
|
||||
"sizeAsc": "Kleinste",
|
||||
"usage": "Anzahl Nutzung",
|
||||
"usageDesc": "Meiste",
|
||||
"usageAsc": "Wenigste"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Modelliste aktualisieren",
|
||||
"quick": "Schnelle Aktualisierung (inkrementell)",
|
||||
"full": "Vollständiger Neuaufbau (komplett)"
|
||||
"quick": "Änderungen synchronisieren",
|
||||
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
|
||||
"full": "Cache neu aufbauen",
|
||||
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Metadaten von Civitai abrufen",
|
||||
@@ -385,6 +504,13 @@
|
||||
"favorites": {
|
||||
"title": "Nur Favoriten anzeigen",
|
||||
"action": "Favoriten"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Nur Modelle mit verfügbaren Updates anzeigen",
|
||||
"action": "Updates",
|
||||
"menuLabel": "Weitere Update-Optionen anzeigen",
|
||||
"check": "Updates prüfen",
|
||||
"checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -396,6 +522,7 @@
|
||||
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||
"copyAll": "Alle Syntax kopieren",
|
||||
"refreshAll": "Alle Metadaten aktualisieren",
|
||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||
"moveAll": "Alle in Ordner verschieben",
|
||||
"autoOrganize": "Automatisch organisieren",
|
||||
"deleteAll": "Alle Modelle löschen",
|
||||
@@ -412,6 +539,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitai-Daten aktualisieren",
|
||||
"checkUpdates": "Updates prüfen",
|
||||
"relinkCivitai": "Mit Civitai neu verknüpfen",
|
||||
"copySyntax": "LoRA-Syntax kopieren",
|
||||
"copyFilename": "Modell-Dateiname kopieren",
|
||||
@@ -423,6 +551,7 @@
|
||||
"replacePreview": "Vorschau ersetzen",
|
||||
"setContentRating": "Inhaltsbewertung festlegen",
|
||||
"moveToFolder": "In Ordner verschieben",
|
||||
"repairMetadata": "Metadaten reparieren",
|
||||
"excludeModel": "Modell ausschließen",
|
||||
"deleteModel": "Modell löschen",
|
||||
"shareRecipe": "Rezept teilen",
|
||||
@@ -433,6 +562,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA-Rezepte",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Importieren",
|
||||
@@ -490,10 +622,26 @@
|
||||
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "Rezepte sortieren nach...",
|
||||
"name": "Name",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "Datum",
|
||||
"dateDesc": "Neueste",
|
||||
"dateAsc": "Älteste",
|
||||
"lorasCount": "LoRA-Anzahl",
|
||||
"lorasCountDesc": "Meiste",
|
||||
"lorasCountAsc": "Wenigste"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Rezeptliste aktualisieren"
|
||||
},
|
||||
"filteredByLora": "Gefiltert nach LoRA"
|
||||
"filteredByLora": "Gefiltert nach LoRA",
|
||||
"favorites": {
|
||||
"title": "Nur Favoriten anzeigen",
|
||||
"action": "Favoriten"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "{count} Duplikat-Gruppen gefunden",
|
||||
@@ -519,23 +667,54 @@
|
||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "Rezept-Metadaten werden repariert...",
|
||||
"success": "Rezept-Metadaten erfolgreich repariert",
|
||||
"skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich",
|
||||
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
|
||||
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "Checkpoint-Modelle"
|
||||
"title": "Checkpoint-Modelle",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "Embedding-Modelle"
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] VAE & Upscaler Models",
|
||||
"modelTypes": {
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Modell-Stammverzeichnis",
|
||||
"modelRoot": "Stammverzeichnis",
|
||||
"collapseAll": "Alle Ordner einklappen",
|
||||
"pinSidebar": "Sidebar anheften",
|
||||
"unpinSidebar": "Sidebar lösen",
|
||||
"switchToListView": "Zur Listenansicht wechseln",
|
||||
"switchToTreeView": "Zur Baumansicht wechseln",
|
||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar"
|
||||
"recursiveOn": "Unterordner durchsuchen",
|
||||
"recursiveOff": "Nur aktuellen Ordner durchsuchen",
|
||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Statistiken",
|
||||
@@ -610,6 +789,14 @@
|
||||
"downloadedPreview": "Vorschaubild heruntergeladen",
|
||||
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
||||
"finalizing": "Download wird abgeschlossen..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Aktuelle Datei:",
|
||||
"downloading": "Wird heruntergeladen: {name}",
|
||||
"transferred": "Heruntergeladen: {downloaded} / {total}",
|
||||
"transferredSimple": "Heruntergeladen: {downloaded}",
|
||||
"transferredUnknown": "Heruntergeladen: --",
|
||||
"speed": "Geschwindigkeit: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -657,6 +844,12 @@
|
||||
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
||||
"action": "Alle löschen"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Alle {typePlural} auf Updates prüfen?",
|
||||
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
|
||||
"tip": "Du möchtest in Etappen prüfen? Wechsle in den Sammelmodus, wähle die benötigten Modelle aus und nutze anschließend \"Auswahl auf Updates prüfen\".",
|
||||
"action": "Alles prüfen"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Tags zu mehreren Modellen hinzufügen",
|
||||
"description": "Tags hinzufügen zu",
|
||||
@@ -730,7 +923,9 @@
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Dateispeicherort erfolgreich geöffnet",
|
||||
"failed": "Fehler beim Öffnen des Dateispeicherorts"
|
||||
"failed": "Fehler beim Öffnen des Dateispeicherorts",
|
||||
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
|
||||
"clipboardFallback": "Pfad: {{path}}"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Version",
|
||||
@@ -753,11 +948,13 @@
|
||||
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
|
||||
"strengthMin": "Stärke Min",
|
||||
"strengthMax": "Stärke Max",
|
||||
"strengthRange": "Stärkenbereich",
|
||||
"strength": "Stärke",
|
||||
"clipStrength": "Clip-Stärke",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Wert",
|
||||
"add": "Hinzufügen"
|
||||
"add": "Hinzufügen",
|
||||
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "Trigger Words",
|
||||
@@ -793,13 +990,84 @@
|
||||
"tabs": {
|
||||
"examples": "Beispiele",
|
||||
"description": "Modellbeschreibung",
|
||||
"recipes": "Rezepte"
|
||||
"recipes": "Rezepte",
|
||||
"versions": "Versionen"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Modellnavigation",
|
||||
"previousWithShortcut": "Vorheriges Modell (←)",
|
||||
"nextWithShortcut": "Nächstes Modell (→)",
|
||||
"noPrevious": "Kein vorheriges Modell verfügbar",
|
||||
"noNext": "Kein weiteres Modell verfügbar"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "Ersteller-Angabe erforderlich",
|
||||
"noDerivatives": "Keine gemeinsamen Zusammenführungen",
|
||||
"noReLicense": "Gleiche Berechtigungen erforderlich",
|
||||
"restrictionsLabel": "Lizenzbeschränkungen"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Beispielbilder werden geladen...",
|
||||
"description": "Modellbeschreibung wird geladen...",
|
||||
"recipes": "Rezepte werden geladen...",
|
||||
"examples": "Beispiele werden geladen..."
|
||||
"examples": "Beispiele werden geladen...",
|
||||
"versions": "Versionen werden geladen..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Modellversionen",
|
||||
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
|
||||
"media": {
|
||||
"placeholder": "Keine Vorschau"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Unbenannte Version",
|
||||
"noDetails": "Keine zusätzlichen Details"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Aktuelle Version",
|
||||
"inLibrary": "In der Bibliothek",
|
||||
"newer": "Neuere Version",
|
||||
"ignored": "Ignoriert"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Herunterladen",
|
||||
"delete": "Löschen",
|
||||
"ignore": "Ignorieren",
|
||||
"unignore": "Ignorierung aufheben",
|
||||
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
|
||||
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
|
||||
"viewLocalVersions": "Alle lokalen Versionen anzeigen",
|
||||
"viewLocalTooltip": "Demnächst verfügbar"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Basisfilter",
|
||||
"state": {
|
||||
"showAll": "Alle Versionen",
|
||||
"showSameBase": "Gleiches Basismodell"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "Wechseln, um alle Versionen anzuzeigen",
|
||||
"showSameBaseVersions": "Wechseln, um nur Versionen mit demselben Basismodell anzuzeigen"
|
||||
},
|
||||
"empty": "Keine Versionen entsprechen dem Filter für das aktuelle Basismodell."
|
||||
},
|
||||
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
|
||||
"error": "Versionen konnten nicht geladen werden.",
|
||||
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
|
||||
"confirm": {
|
||||
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Aktualisierungen für dieses Modell werden ignoriert",
|
||||
"modelResumed": "Aktualisierungen für dieses Modell werden wieder geprüft",
|
||||
"versionIgnored": "Aktualisierungen für diese Version werden ignoriert",
|
||||
"versionUnignored": "Version wurde wieder aktiviert",
|
||||
"versionDeleted": "Version gelöscht"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,6 +1116,10 @@
|
||||
"title": "Statistiken werden initialisiert",
|
||||
"message": "Modelldaten für Statistiken werden verarbeitet. Dies kann einige Minuten dauern..."
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
||||
},
|
||||
"tips": {
|
||||
"title": "Tipps & Tricks",
|
||||
"civitai": {
|
||||
@@ -906,11 +1178,19 @@
|
||||
"loraFailedToSend": "Fehler beim Senden der LoRA an den Workflow",
|
||||
"recipeAdded": "Rezept zum Workflow hinzugefügt",
|
||||
"recipeReplaced": "Rezept im Workflow ersetzt",
|
||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow"
|
||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
||||
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
|
||||
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
|
||||
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
|
||||
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
|
||||
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Rezept",
|
||||
"lora": "LoRA",
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler",
|
||||
"replace": "Ersetzen",
|
||||
"append": "Anhängen",
|
||||
"selectTargetNode": "Zielknoten auswählen",
|
||||
@@ -919,7 +1199,11 @@
|
||||
"exampleImages": {
|
||||
"opened": "Beispielbilder-Ordner geöffnet",
|
||||
"openingFolder": "Beispielbilder-Ordner wird geöffnet",
|
||||
"failedToOpen": "Fehler beim Öffnen des Beispielbilder-Ordners"
|
||||
"failedToOpen": "Fehler beim Öffnen des Beispielbilder-Ordners",
|
||||
"setupRequired": "Beispielbilder-Speicher",
|
||||
"setupDescription": "Um benutzerdefinierte Beispielbilder hinzuzufügen, müssen Sie zuerst einen Download-Speicherort festlegen.",
|
||||
"setupUsage": "Dieser Pfad wird sowohl für heruntergeladene als auch für benutzerdefinierte Beispielbilder verwendet.",
|
||||
"openSettings": "Einstellungen öffnen"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -951,6 +1235,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Nach Updates suchen",
|
||||
"notificationsTitle": "Benachrichtigungszentrum",
|
||||
"tabs": {
|
||||
"updates": "Aktualisierungen",
|
||||
"messages": "Mitteilungen"
|
||||
},
|
||||
"updateAvailable": "Update verfügbar",
|
||||
"noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.",
|
||||
"currentVersion": "Aktuelle Version",
|
||||
@@ -963,6 +1252,7 @@
|
||||
"checkingUpdates": "Nach Updates wird gesucht...",
|
||||
"checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.",
|
||||
"showNotifications": "Update-Benachrichtigungen anzeigen",
|
||||
"latestBadge": "Neueste",
|
||||
"updateProgress": {
|
||||
"preparing": "Update wird vorbereitet...",
|
||||
"installing": "Update wird installiert...",
|
||||
@@ -982,6 +1272,13 @@
|
||||
"nightly": {
|
||||
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
|
||||
"enable": "Nightly Updates aktivieren"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Neueste Mitteilungen",
|
||||
"empty": "Keine aktuellen Banner verfügbar.",
|
||||
"shown": "{time} angezeigt",
|
||||
"dismissed": "{time} geschlossen",
|
||||
"active": "Aktiv"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1061,6 +1358,9 @@
|
||||
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
|
||||
"sendFailed": "Fehler beim Senden des Rezepts an Workflow",
|
||||
"sendError": "Fehler beim Senden des Rezepts an Workflow",
|
||||
"missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar",
|
||||
"missingCheckpointInfo": "Checkpoint-Informationen fehlen",
|
||||
"downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}",
|
||||
"cannotDelete": "Kann Rezept nicht löschen: Fehlende Rezept-ID",
|
||||
"deleteConfirmationError": "Fehler beim Anzeigen der Löschbestätigung",
|
||||
"deletedSuccessfully": "Rezept erfolgreich gelöscht",
|
||||
@@ -1101,6 +1401,12 @@
|
||||
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
||||
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
||||
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
||||
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
||||
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
||||
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
||||
"bulkUpdatesMissing": "Ausgewählte {type}-Modelle sind nicht mit Civitai-Updates verknüpft",
|
||||
"bulkUpdatesPartialMissing": "{missing} ausgewählte {type}-Modelle ohne Civitai-Verknüpfung übersprungen",
|
||||
"bulkUpdatesFailed": "Updates für ausgewählte {type}-Modelle konnten nicht geprüft werden: {message}",
|
||||
"invalidCharactersRemoved": "Ungültige Zeichen aus Dateiname entfernt",
|
||||
"filenameCannotBeEmpty": "Dateiname darf nicht leer sein",
|
||||
"renameFailed": "Fehler beim Umbenennen der Datei: {message}",
|
||||
@@ -1112,6 +1418,7 @@
|
||||
"verificationCompleteSuccess": "Verifikation abgeschlossen. Alle Dateien sind bestätigte Duplikate.",
|
||||
"verificationFailed": "Fehler beim Verifizieren der Hashes: {message}",
|
||||
"noTagsToAdd": "Keine Tags zum Hinzufügen",
|
||||
"bulkTagsUpdating": "Tags für {count} Modell(e) werden aktualisiert...",
|
||||
"tagsAddedSuccessfully": "Erfolgreich {tagCount} Tag(s) zu {count} {type}(s) hinzugefügt",
|
||||
"tagsReplacedSuccessfully": "Tags für {count} {type}(s) erfolgreich durch {tagCount} Tag(s) ersetzt",
|
||||
"tagsAddFailed": "Fehler beim Hinzufügen von Tags zu {count} Modell(en)",
|
||||
@@ -1125,6 +1432,7 @@
|
||||
"settings": {
|
||||
"loraRootsFailed": "Fehler beim Laden der LoRA-Stammverzeichnisse: {message}",
|
||||
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
||||
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
|
||||
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
|
||||
"mappingsUpdated": "Basis-Modell-Pfad-Zuordnungen aktualisiert ({count} Zuordnung{plural})",
|
||||
"mappingsCleared": "Basis-Modell-Pfad-Zuordnungen gelöscht",
|
||||
@@ -1145,7 +1453,26 @@
|
||||
"filters": {
|
||||
"applied": "{message}",
|
||||
"cleared": "Filter gelöscht",
|
||||
"noCustomFilterToClear": "Kein benutzerdefinierter Filter zum Löschen"
|
||||
"noCustomFilterToClear": "Kein benutzerdefinierter Filter zum Löschen",
|
||||
"noActiveFilters": "Keine aktiven Filter zum Speichern"
|
||||
},
|
||||
"presets": {
|
||||
"created": "Voreinstellung \"{name}\" erstellt",
|
||||
"deleted": "Voreinstellung \"{name}\" gelöscht",
|
||||
"applied": "Voreinstellung \"{name}\" angewendet",
|
||||
"overwritten": "Voreinstellung \"{name}\" überschrieben",
|
||||
"restored": "Standard-Voreinstellungen wiederhergestellt"
|
||||
},
|
||||
"error": {
|
||||
"presetNameEmpty": "Voreinstellungsname darf nicht leer sein",
|
||||
"presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben",
|
||||
"presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen",
|
||||
"presetNameExists": "Eine Voreinstellung mit diesem Namen existiert bereits",
|
||||
"maxPresetsReached": "Maximal {max} Voreinstellungen erlaubt. Löschen Sie eine, um weitere hinzuzufügen.",
|
||||
"presetNotFound": "Voreinstellung nicht gefunden",
|
||||
"invalidPreset": "Ungültige Voreinstellungsdaten",
|
||||
"deletePresetFailed": "Fehler beim Löschen der Voreinstellung",
|
||||
"applyPresetFailed": "Fehler beim Anwenden der Voreinstellung"
|
||||
},
|
||||
"downloads": {
|
||||
"imagesCompleted": "Beispielbilder {action} abgeschlossen",
|
||||
@@ -1161,7 +1488,7 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "Konnte trainierte Wörter nicht laden",
|
||||
"tooLong": "Trigger Word sollte 30 Wörter nicht überschreiten",
|
||||
"tooLong": "Trigger Word sollte 100 Wörter nicht überschreiten",
|
||||
"tooMany": "Maximal 30 Trigger Words erlaubt",
|
||||
"alreadyExists": "Dieses Trigger Word existiert bereits",
|
||||
"updateSuccess": "Trigger Words erfolgreich aktualisiert",
|
||||
@@ -1210,6 +1537,8 @@
|
||||
"pauseFailed": "Fehler beim Pausieren des Downloads: {error}",
|
||||
"downloadResumed": "Download fortgesetzt",
|
||||
"resumeFailed": "Fehler beim Fortsetzen des Downloads: {error}",
|
||||
"downloadStopped": "Download abgebrochen",
|
||||
"stopFailed": "Download konnte nicht abgebrochen werden: {error}",
|
||||
"deleted": "Beispielbild gelöscht",
|
||||
"deleteFailed": "Fehler beim Löschen des Beispielbilds",
|
||||
"setPreviewFailed": "Fehler beim Setzen des Vorschaubilds"
|
||||
@@ -1230,6 +1559,8 @@
|
||||
"metadataRefreshed": "Metadaten erfolgreich aktualisiert",
|
||||
"metadataRefreshFailed": "Fehler beim Aktualisieren der Metadaten: {message}",
|
||||
"metadataUpdateComplete": "Metadaten-Update abgeschlossen",
|
||||
"operationCancelled": "Vorgang vom Benutzer abgebrochen",
|
||||
"operationCancelledPartial": "Vorgang abgebrochen. {success} Elemente verarbeitet.",
|
||||
"metadataFetchFailed": "Fehler beim Abrufen der Metadaten: {message}",
|
||||
"bulkMetadataCompleteAll": "Alle {count} {type}s erfolgreich aktualisiert",
|
||||
"bulkMetadataCompletePartial": "{success} von {total} {type}s aktualisiert",
|
||||
@@ -1246,7 +1577,8 @@
|
||||
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}"
|
||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
428
locales/en.json
428
locales/en.json
@@ -10,7 +10,8 @@
|
||||
"next": "Next",
|
||||
"backToTop": "Back to top",
|
||||
"settings": "Settings",
|
||||
"help": "Help"
|
||||
"help": "Help",
|
||||
"add": "Add"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
@@ -32,7 +33,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Bytes",
|
||||
@@ -101,7 +102,12 @@
|
||||
"checkpointNameCopied": "Checkpoint name copied",
|
||||
"toggleBlur": "Toggle blur",
|
||||
"show": "Show",
|
||||
"openExampleImages": "Open Example Images Folder"
|
||||
"openExampleImages": "Open Example Images Folder",
|
||||
"replacePreview": "Replace Preview",
|
||||
"copyCheckpointName": "Copy checkpoint name",
|
||||
"copyEmbeddingName": "Copy embedding name",
|
||||
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Mature Content",
|
||||
@@ -115,12 +121,20 @@
|
||||
"updateFailed": "Failed to update favorite status"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented"
|
||||
"checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented",
|
||||
"missingPath": "Unable to determine model path for this card"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Error checking for example images",
|
||||
"missingHash": "Missing model hash information.",
|
||||
"noRemoteImagesAvailable": "No remote example images available for this model on Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Update",
|
||||
"updateAvailable": "Update available"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Times used"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,12 +143,33 @@
|
||||
"missingPath": "Set a download location before downloading example images.",
|
||||
"unavailable": "Example image downloads aren't available yet. Try again after the page finishes loading."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Check for updates",
|
||||
"loading": "Checking for {type} updates...",
|
||||
"success": "Found {count} update(s) for {type}s",
|
||||
"none": "All {type}s are up to date",
|
||||
"error": "Failed to check for {type} updates: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Clean up example image folders",
|
||||
"success": "Moved {count} folder(s) to the deleted folder",
|
||||
"none": "No example image folders needed cleanup",
|
||||
"partial": "Cleanup completed with {failures} folder(s) skipped",
|
||||
"error": "Failed to clean example image folders: {message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "Repair recipes data",
|
||||
"loading": "Repairing recipe data...",
|
||||
"success": "Successfully repaired {count} recipes.",
|
||||
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
||||
"error": "Recipe repair failed: {message}"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -144,6 +179,7 @@
|
||||
"recipes": "Recipes",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"misc": "Misc",
|
||||
"statistics": "Stats"
|
||||
},
|
||||
"search": {
|
||||
@@ -152,7 +188,8 @@
|
||||
"loras": "Search LoRAs...",
|
||||
"recipes": "Search recipes...",
|
||||
"checkpoints": "Search checkpoints...",
|
||||
"embeddings": "Search embeddings..."
|
||||
"embeddings": "Search embeddings...",
|
||||
"misc": "Search VAE/Upscaler models..."
|
||||
},
|
||||
"options": "Search Options",
|
||||
"searchIn": "Search In:",
|
||||
@@ -164,13 +201,30 @@
|
||||
"creator": "Creator",
|
||||
"title": "Recipe Title",
|
||||
"loraName": "LoRA Filename",
|
||||
"loraModel": "LoRA Model Name"
|
||||
"loraModel": "LoRA Model Name",
|
||||
"prompt": "Prompt"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filter Models",
|
||||
"presets": "Presets",
|
||||
"savePreset": "Save current active filters as a new preset.",
|
||||
"savePresetDisabledActive": "Cannot save: A preset is already active. Modify filters to save new preset.",
|
||||
"savePresetDisabledNoFilters": "Select filters first to save as preset",
|
||||
"savePresetPrompt": "Enter preset name:",
|
||||
"presetClickTooltip": "Click to apply preset \"{name}\"",
|
||||
"presetDeleteTooltip": "Delete preset",
|
||||
"presetDeleteConfirm": "Delete preset \"{name}\"?",
|
||||
"presetDeleteConfirmClick": "Click again to confirm",
|
||||
"presetOverwriteConfirm": "Preset \"{name}\" already exists. Overwrite?",
|
||||
"presetNamePlaceholder": "Preset name...",
|
||||
"baseModel": "Base Model",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "License",
|
||||
"noCreditRequired": "No Credit Required",
|
||||
"allowSellingGeneratedContent": "Allow Selling",
|
||||
"noTags": "No tags",
|
||||
"clearAll": "Clear All Filters"
|
||||
},
|
||||
"theme": {
|
||||
@@ -181,6 +235,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Check Updates",
|
||||
"notifications": "Notifications",
|
||||
"support": "Support"
|
||||
}
|
||||
},
|
||||
@@ -190,21 +245,31 @@
|
||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Open settings folder",
|
||||
"tooltip": "Open the folder containing settings.json",
|
||||
"tooltip": "Open folder containing settings.json",
|
||||
"success": "Opened settings.json folder",
|
||||
"failed": "Failed to open settings.json folder"
|
||||
"failed": "Failed to open settings.json folder",
|
||||
"copied": "Settings path copied to clipboard: {{path}}",
|
||||
"clipboardFallback": "Settings path: {{path}}"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Content Filtering",
|
||||
"videoSettings": "Video Settings",
|
||||
"layoutSettings": "Layout Settings",
|
||||
"folderSettings": "Folder Settings",
|
||||
"priorityTags": "Priority Tags",
|
||||
"downloadPathTemplates": "Download Path Templates",
|
||||
"exampleImages": "Example Images",
|
||||
"updateFlags": "Update Flags",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"misc": "Misc.",
|
||||
"metadataArchive": "Metadata Archive Database",
|
||||
"storageLocation": "Settings Location",
|
||||
"proxySettings": "Proxy Settings"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "Portable mode",
|
||||
"locationHelp": "Enable to keep settings.json inside the repository; disable to store it in your user config directory."
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "Blur NSFW Content",
|
||||
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
|
||||
@@ -215,30 +280,49 @@
|
||||
"autoplayOnHover": "Autoplay Videos on Hover",
|
||||
"autoplayOnHoverHelp": "Only play video previews when hovering over them"
|
||||
},
|
||||
"autoOrganizeExclusions": {
|
||||
"label": "Auto-organize exclusions",
|
||||
"placeholder": "Example: curated/*, */backups/*; *_temp.safetensors",
|
||||
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
|
||||
"validation": {
|
||||
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
|
||||
"saveFailed": "Unable to save exclusions: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Display Density",
|
||||
"displayDensityOptions": {
|
||||
"default": "Default",
|
||||
"medium": "Medium",
|
||||
"medium": "Medium",
|
||||
"compact": "Compact"
|
||||
},
|
||||
"displayDensityHelp": "Choose how many cards to display per row:",
|
||||
"displayDensityDetails": {
|
||||
"default": "Default: 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "Medium: 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "Compact: 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
|
||||
"showFolderSidebar": "Show Folder Sidebar",
|
||||
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
|
||||
"cardInfoDisplay": "Card Info Display",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Always Visible",
|
||||
"hover": "Reveal on Hover"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Always Visible: Headers and footers are always visible",
|
||||
"hover": "Reveal on Hover: Headers and footers only appear when hovering over a card"
|
||||
}
|
||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
|
||||
"modelCardFooterAction": "Model Card Button Action",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Open Example Images",
|
||||
"replacePreview": "Replace Preview"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Choose what the bottom-right card button does",
|
||||
"modelNameDisplay": "Model Name Display",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Model Name",
|
||||
"fileName": "File Name"
|
||||
},
|
||||
"modelNameDisplayHelp": "Choose what to display in the model card footer"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Active Library",
|
||||
@@ -246,13 +330,35 @@
|
||||
"loadingLibraries": "Loading libraries...",
|
||||
"noLibraries": "No libraries configured",
|
||||
"defaultLoraRoot": "Default LoRA Root",
|
||||
"defaultLoraRootHelp": "Set the default LoRA root directory for downloads, imports and moves",
|
||||
"defaultLoraRootHelp": "Set default LoRA root directory for downloads, imports and moves",
|
||||
"defaultCheckpointRoot": "Default Checkpoint Root",
|
||||
"defaultCheckpointRootHelp": "Set the default checkpoint root directory for downloads, imports and moves",
|
||||
"defaultCheckpointRootHelp": "Set default checkpoint root directory for downloads, imports and moves",
|
||||
"defaultUnetRoot": "Default Diffusion Model Root",
|
||||
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
||||
"defaultEmbeddingRoot": "Default Embedding Root",
|
||||
"defaultEmbeddingRootHelp": "Set the default embedding root directory for downloads, imports and moves",
|
||||
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
||||
"noDefault": "No Default"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Priority Tags",
|
||||
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "Open priority tags help",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "Priority tags updated.",
|
||||
"saveError": "Failed to update priority tags.",
|
||||
"loadingSuggestions": "Loading suggestions...",
|
||||
"validation": {
|
||||
"missingClosingParen": "Entry {index} is missing a closing parenthesis.",
|
||||
"missingCanonical": "Entry {index} must include a canonical tag name.",
|
||||
"duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.",
|
||||
"unknown": "Invalid priority tag configuration."
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "Download Path Templates",
|
||||
"help": "Configure folder structures for different model types when downloading from Civitai.",
|
||||
@@ -260,7 +366,7 @@
|
||||
"templateOptions": {
|
||||
"flatStructure": "Flat Structure",
|
||||
"byBaseModel": "By Base Model",
|
||||
"byAuthor": "By Author",
|
||||
"byAuthor": "By Author",
|
||||
"byFirstTag": "By First Tag",
|
||||
"baseModelFirstTag": "Base Model + First Tag",
|
||||
"baseModelAuthor": "Base Model + Author",
|
||||
@@ -271,7 +377,7 @@
|
||||
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"baseModelPathMappings": "Base Model Path Mappings",
|
||||
@@ -300,6 +406,14 @@
|
||||
"download": "Download",
|
||||
"restartRequired": "Requires restart"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "Update Flag Strategy",
|
||||
"help": "Decide whether update badges should only appear when a new release shares the same base model as your local files or whenever any newer version exists for that model.",
|
||||
"options": {
|
||||
"sameBase": "Match updates by base model",
|
||||
"any": "Flag any available update"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
|
||||
@@ -336,11 +450,11 @@
|
||||
"proxyHost": "Proxy Host",
|
||||
"proxyHostPlaceholder": "proxy.example.com",
|
||||
"proxyHostHelp": "The hostname or IP address of your proxy server",
|
||||
"proxyPort": "Proxy Port",
|
||||
"proxyPort": "Proxy Port",
|
||||
"proxyPortPlaceholder": "8080",
|
||||
"proxyPortHelp": "The port number of your proxy server",
|
||||
"proxyUsername": "Username (Optional)",
|
||||
"proxyUsernamePlaceholder": "username",
|
||||
"proxyUsernamePlaceholder": "username",
|
||||
"proxyUsernameHelp": "Username for proxy authentication (if required)",
|
||||
"proxyPassword": "Password (Optional)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
@@ -359,12 +473,17 @@
|
||||
"dateAsc": "Oldest",
|
||||
"size": "File Size",
|
||||
"sizeDesc": "Largest",
|
||||
"sizeAsc": "Smallest"
|
||||
"sizeAsc": "Smallest",
|
||||
"usage": "Use Count",
|
||||
"usageDesc": "Most",
|
||||
"usageAsc": "Least"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Refresh model list",
|
||||
"quick": "Quick Refresh (incremental)",
|
||||
"full": "Full Rebuild (complete)"
|
||||
"quick": "Sync Changes",
|
||||
"quickTooltip": "Scan for new or missing model files so the list stays current.",
|
||||
"full": "Rebuild Cache",
|
||||
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Fetch metadata from Civitai",
|
||||
@@ -385,20 +504,28 @@
|
||||
"favorites": {
|
||||
"title": "Show Favorites Only",
|
||||
"action": "Favorites"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Show models with updates available",
|
||||
"action": "Updates",
|
||||
"menuLabel": "Show update options",
|
||||
"check": "Check updates",
|
||||
"checkTooltip": "Checking updates may take a while."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
"selected": "{count} selected",
|
||||
"selectedSuffix": "selected",
|
||||
"viewSelected": "View Selected",
|
||||
"addTags": "Add Tags to All",
|
||||
"setBaseModel": "Set Base Model for All",
|
||||
"setContentRating": "Set Content Rating for All",
|
||||
"copyAll": "Copy All Syntax",
|
||||
"refreshAll": "Refresh All Metadata",
|
||||
"moveAll": "Move All to Folder",
|
||||
"addTags": "Add Tags to Selected",
|
||||
"setBaseModel": "Set Base Model for Selected",
|
||||
"setContentRating": "Set Content Rating for Selected",
|
||||
"copyAll": "Copy Selected Syntax",
|
||||
"refreshAll": "Refresh Selected Metadata",
|
||||
"checkUpdates": "Check Updates for Selected",
|
||||
"moveAll": "Move Selected to Folder",
|
||||
"autoOrganize": "Auto-Organize Selected",
|
||||
"deleteAll": "Delete All Models",
|
||||
"deleteAll": "Delete Selected Models",
|
||||
"clear": "Clear Selection",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Initializing auto-organize...",
|
||||
@@ -412,6 +539,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Refresh Civitai Data",
|
||||
"checkUpdates": "Check Updates",
|
||||
"relinkCivitai": "Re-link to Civitai",
|
||||
"copySyntax": "Copy LoRA Syntax",
|
||||
"copyFilename": "Copy Model Filename",
|
||||
@@ -423,6 +551,7 @@
|
||||
"replacePreview": "Replace Preview",
|
||||
"setContentRating": "Set Content Rating",
|
||||
"moveToFolder": "Move to Folder",
|
||||
"repairMetadata": "Repair metadata",
|
||||
"excludeModel": "Exclude Model",
|
||||
"deleteModel": "Delete Model",
|
||||
"shareRecipe": "Share Recipe",
|
||||
@@ -433,6 +562,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA Recipes",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Import",
|
||||
@@ -490,10 +622,26 @@
|
||||
"selectLoraRoot": "Please select a LoRA root directory"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort recipes by...",
|
||||
"name": "Name",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "Date",
|
||||
"dateDesc": "Newest",
|
||||
"dateAsc": "Oldest",
|
||||
"lorasCount": "LoRA Count",
|
||||
"lorasCountDesc": "Most",
|
||||
"lorasCountAsc": "Least"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Refresh recipe list"
|
||||
},
|
||||
"filteredByLora": "Filtered by LoRA"
|
||||
"filteredByLora": "Filtered by LoRA",
|
||||
"favorites": {
|
||||
"title": "Show Favorites Only",
|
||||
"action": "Favorites"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "Found {count} duplicate groups",
|
||||
@@ -519,23 +667,54 @@
|
||||
"noMissingLoras": "No missing LoRAs to download",
|
||||
"getInfoFailed": "Failed to get information for missing LoRAs",
|
||||
"prepareError": "Error preparing LoRAs for download: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "Repairing recipe metadata...",
|
||||
"success": "Recipe metadata repaired successfully",
|
||||
"skipped": "Recipe already at latest version, no repair needed",
|
||||
"failed": "Failed to repair recipe: {message}",
|
||||
"missingId": "Cannot repair recipe: Missing recipe ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "Checkpoint Models"
|
||||
"title": "Checkpoint Models",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "Embedding Models"
|
||||
},
|
||||
"misc": {
|
||||
"title": "VAE & Upscaler Models",
|
||||
"modelTypes": {
|
||||
"vae": "VAE",
|
||||
"upscaler": "Upscaler"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Model Root",
|
||||
"modelRoot": "Root",
|
||||
"collapseAll": "Collapse All Folders",
|
||||
"pinSidebar": "Pin Sidebar",
|
||||
"unpinSidebar": "Unpin Sidebar",
|
||||
"switchToListView": "Switch to List View",
|
||||
"switchToTreeView": "Switch to Tree View",
|
||||
"collapseAllDisabled": "Not available in list view"
|
||||
"recursiveOn": "Search subfolders",
|
||||
"recursiveOff": "Search current folder only",
|
||||
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||
"collapseAllDisabled": "Not available in list view",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Unable to determine destination path for move.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Statistics",
|
||||
@@ -610,6 +789,14 @@
|
||||
"downloadedPreview": "Downloaded preview image",
|
||||
"downloadingFile": "Downloading {type} file",
|
||||
"finalizing": "Finalizing download..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Current file:",
|
||||
"downloading": "Downloading: {name}",
|
||||
"transferred": "Transferred: {downloaded} / {total}",
|
||||
"transferredSimple": "Transferred: {downloaded}",
|
||||
"transferredUnknown": "Transferred: --",
|
||||
"speed": "Speed: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -657,6 +844,12 @@
|
||||
"countMessage": "models will be permanently deleted.",
|
||||
"action": "Delete All"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Check updates for all {typePlural}?",
|
||||
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
||||
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
|
||||
"action": "Check All"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Add Tags to Multiple Models",
|
||||
"description": "Add tags to",
|
||||
@@ -730,7 +923,9 @@
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "File location opened successfully",
|
||||
"failed": "Failed to open file location"
|
||||
"failed": "Failed to open file location",
|
||||
"copied": "Path copied to clipboard: {{path}}",
|
||||
"clipboardFallback": "Path: {{path}}"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Version",
|
||||
@@ -753,11 +948,13 @@
|
||||
"addPresetParameter": "Add preset parameter...",
|
||||
"strengthMin": "Strength Min",
|
||||
"strengthMax": "Strength Max",
|
||||
"strengthRange": "Strength Range",
|
||||
"strength": "Strength",
|
||||
"clipStrength": "Clip Strength",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Value",
|
||||
"add": "Add"
|
||||
"add": "Add",
|
||||
"invalidRange": "Invalid range format. Use x.x-y.y"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "Trigger Words",
|
||||
@@ -793,13 +990,84 @@
|
||||
"tabs": {
|
||||
"examples": "Examples",
|
||||
"description": "Model Description",
|
||||
"recipes": "Recipes"
|
||||
"recipes": "Recipes",
|
||||
"versions": "Versions"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Model navigation",
|
||||
"previousWithShortcut": "Previous model (←)",
|
||||
"nextWithShortcut": "Next model (→)",
|
||||
"noPrevious": "No previous model available",
|
||||
"noNext": "No next model available"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "Creator credit required",
|
||||
"noDerivatives": "No sharing merges",
|
||||
"noReLicense": "Same permissions required",
|
||||
"restrictionsLabel": "License restrictions"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Loading example images...",
|
||||
"description": "Loading model description...",
|
||||
"recipes": "Loading recipes...",
|
||||
"examples": "Loading examples..."
|
||||
"examples": "Loading examples...",
|
||||
"versions": "Loading versions..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Model versions",
|
||||
"copy": "Track and manage every version of this model in one place.",
|
||||
"media": {
|
||||
"placeholder": "No preview"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Untitled Version",
|
||||
"noDetails": "No additional details"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Current Version",
|
||||
"inLibrary": "In Library",
|
||||
"newer": "Newer Version",
|
||||
"ignored": "Ignored"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"ignore": "Ignore",
|
||||
"unignore": "Unignore",
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
"viewLocalVersions": "View all local versions",
|
||||
"viewLocalTooltip": "Coming soon"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Base filter",
|
||||
"state": {
|
||||
"showAll": "All versions",
|
||||
"showSameBase": "Same base"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "Switch to showing all versions",
|
||||
"showSameBaseVersions": "Switch to showing only versions that match the current base model"
|
||||
},
|
||||
"empty": "No versions match the current base model filter."
|
||||
},
|
||||
"empty": "No version history available for this model yet.",
|
||||
"error": "Failed to load versions.",
|
||||
"missingModelId": "This model is missing a Civitai model id.",
|
||||
"confirm": {
|
||||
"delete": "Delete this version from your library?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Updates ignored for this model",
|
||||
"modelResumed": "Update tracking resumed",
|
||||
"versionIgnored": "Updates ignored for this version",
|
||||
"versionUnignored": "Version re-enabled",
|
||||
"versionDeleted": "Version deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,6 +1116,10 @@
|
||||
"title": "Initializing Statistics",
|
||||
"message": "Processing model data for statistics. This may take a few minutes..."
|
||||
},
|
||||
"misc": {
|
||||
"title": "Initializing Misc Model Manager",
|
||||
"message": "Scanning VAE and Upscaler models..."
|
||||
},
|
||||
"tips": {
|
||||
"title": "Tips & Tricks",
|
||||
"civitai": {
|
||||
@@ -906,11 +1178,19 @@
|
||||
"loraFailedToSend": "Failed to send LoRA to workflow",
|
||||
"recipeAdded": "Recipe appended to workflow",
|
||||
"recipeReplaced": "Recipe replaced in workflow",
|
||||
"recipeFailedToSend": "Failed to send recipe to workflow"
|
||||
"recipeFailedToSend": "Failed to send recipe to workflow",
|
||||
"vaeUpdated": "VAE updated in workflow",
|
||||
"vaeFailed": "Failed to update VAE in workflow",
|
||||
"upscalerUpdated": "Upscaler updated in workflow",
|
||||
"upscalerFailed": "Failed to update upscaler in workflow",
|
||||
"noMatchingNodes": "No compatible nodes available in the current workflow",
|
||||
"noTargetNodeSelected": "No target node selected"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
"lora": "LoRA",
|
||||
"vae": "VAE",
|
||||
"upscaler": "Upscaler",
|
||||
"replace": "Replace",
|
||||
"append": "Append",
|
||||
"selectTargetNode": "Select target node",
|
||||
@@ -919,7 +1199,11 @@
|
||||
"exampleImages": {
|
||||
"opened": "Example images folder opened",
|
||||
"openingFolder": "Opening example images folder",
|
||||
"failedToOpen": "Failed to open example images folder"
|
||||
"failedToOpen": "Failed to open example images folder",
|
||||
"setupRequired": "Example Images Storage",
|
||||
"setupDescription": "To add custom example images, you need to set a download location first.",
|
||||
"setupUsage": "This path is used for both downloaded and custom example images.",
|
||||
"openSettings": "Open Settings"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -951,6 +1235,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Check for Updates",
|
||||
"notificationsTitle": "Notifications",
|
||||
"tabs": {
|
||||
"updates": "Updates",
|
||||
"messages": "Messages"
|
||||
},
|
||||
"updateAvailable": "Update Available",
|
||||
"noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.",
|
||||
"currentVersion": "Current Version",
|
||||
@@ -963,6 +1252,7 @@
|
||||
"checkingUpdates": "Checking for updates...",
|
||||
"checkingMessage": "Please wait while we check for the latest version.",
|
||||
"showNotifications": "Show update notifications",
|
||||
"latestBadge": "Latest",
|
||||
"updateProgress": {
|
||||
"preparing": "Preparing update...",
|
||||
"installing": "Installing update...",
|
||||
@@ -982,6 +1272,13 @@
|
||||
"nightly": {
|
||||
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
|
||||
"enable": "Enable Nightly Updates"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Recent messages",
|
||||
"empty": "No recent banners yet.",
|
||||
"shown": "Shown {time}",
|
||||
"dismissed": "Dismissed {time}",
|
||||
"active": "Active"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1061,6 +1358,9 @@
|
||||
"cannotSend": "Cannot send recipe: Missing recipe ID",
|
||||
"sendFailed": "Failed to send recipe to workflow",
|
||||
"sendError": "Error sending recipe to workflow",
|
||||
"missingCheckpointPath": "Checkpoint path not available",
|
||||
"missingCheckpointInfo": "Missing checkpoint information",
|
||||
"downloadCheckpointFailed": "Failed to download checkpoint: {message}",
|
||||
"cannotDelete": "Cannot delete recipe: Missing recipe ID",
|
||||
"deleteConfirmationError": "Error showing delete confirmation",
|
||||
"deletedSuccessfully": "Recipe deleted successfully",
|
||||
@@ -1101,6 +1401,12 @@
|
||||
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
||||
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
||||
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
||||
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
||||
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
||||
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
||||
"bulkUpdatesMissing": "Selected {type}(s) are not linked to Civitai updates",
|
||||
"bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without Civitai links",
|
||||
"bulkUpdatesFailed": "Failed to check updates for selected {type}(s): {message}",
|
||||
"invalidCharactersRemoved": "Invalid characters removed from filename",
|
||||
"filenameCannotBeEmpty": "File name cannot be empty",
|
||||
"renameFailed": "Failed to rename file: {message}",
|
||||
@@ -1112,6 +1418,7 @@
|
||||
"verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.",
|
||||
"verificationFailed": "Failed to verify hashes: {message}",
|
||||
"noTagsToAdd": "No tags to add",
|
||||
"bulkTagsUpdating": "Updating tags for {count} model(s)...",
|
||||
"tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)",
|
||||
"tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)",
|
||||
"tagsAddFailed": "Failed to add tags to {count} model(s)",
|
||||
@@ -1125,6 +1432,7 @@
|
||||
"settings": {
|
||||
"loraRootsFailed": "Failed to load LoRA roots: {message}",
|
||||
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
||||
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
|
||||
"embeddingRootsFailed": "Failed to load embedding roots: {message}",
|
||||
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
|
||||
"mappingsCleared": "Base model path mappings cleared",
|
||||
@@ -1145,7 +1453,26 @@
|
||||
"filters": {
|
||||
"applied": "{message}",
|
||||
"cleared": "Filters cleared",
|
||||
"noCustomFilterToClear": "No custom filter to clear"
|
||||
"noCustomFilterToClear": "No custom filter to clear",
|
||||
"noActiveFilters": "No active filters to save"
|
||||
},
|
||||
"presets": {
|
||||
"created": "Preset \"{name}\" created",
|
||||
"deleted": "Preset \"{name}\" deleted",
|
||||
"applied": "Preset \"{name}\" applied",
|
||||
"overwritten": "Preset \"{name}\" overwritten",
|
||||
"restored": "Default presets restored"
|
||||
},
|
||||
"error": {
|
||||
"presetNameEmpty": "Preset name cannot be empty",
|
||||
"presetNameTooLong": "Preset name must be {max} characters or less",
|
||||
"presetNameInvalidChars": "Preset name contains invalid characters",
|
||||
"presetNameExists": "A preset with this name already exists",
|
||||
"maxPresetsReached": "Maximum {max} presets allowed. Delete one to add more.",
|
||||
"presetNotFound": "Preset not found",
|
||||
"invalidPreset": "Invalid preset data",
|
||||
"deletePresetFailed": "Failed to delete preset",
|
||||
"applyPresetFailed": "Failed to apply preset"
|
||||
},
|
||||
"downloads": {
|
||||
"imagesCompleted": "Example images {action} completed",
|
||||
@@ -1161,7 +1488,7 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "Could not load trained words",
|
||||
"tooLong": "Trigger word should not exceed 30 words",
|
||||
"tooLong": "Trigger word should not exceed 100 words",
|
||||
"tooMany": "Maximum 30 trigger words allowed",
|
||||
"alreadyExists": "This trigger word already exists",
|
||||
"updateSuccess": "Trigger words updated successfully",
|
||||
@@ -1210,6 +1537,8 @@
|
||||
"pauseFailed": "Failed to pause download: {error}",
|
||||
"downloadResumed": "Download resumed",
|
||||
"resumeFailed": "Failed to resume download: {error}",
|
||||
"downloadStopped": "Download cancelled",
|
||||
"stopFailed": "Failed to cancel download: {error}",
|
||||
"deleted": "Example image deleted",
|
||||
"deleteFailed": "Failed to delete example image",
|
||||
"setPreviewFailed": "Failed to set preview image"
|
||||
@@ -1230,6 +1559,8 @@
|
||||
"metadataRefreshed": "Metadata refreshed successfully",
|
||||
"metadataRefreshFailed": "Failed to refresh metadata: {message}",
|
||||
"metadataUpdateComplete": "Metadata update complete",
|
||||
"operationCancelled": "Operation cancelled by user",
|
||||
"operationCancelledPartial": "Operation cancelled. {success} items processed.",
|
||||
"metadataFetchFailed": "Failed to fetch metadata: {message}",
|
||||
"bulkMetadataCompleteAll": "Successfully refreshed all {count} {type}s",
|
||||
"bulkMetadataCompletePartial": "Refreshed {success} of {total} {type}s",
|
||||
@@ -1246,7 +1577,8 @@
|
||||
"bulkMoveFailures": "Failed moves:\n{failures}",
|
||||
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
||||
"exampleImagesDownloadFailed": "Failed to download example images: {message}"
|
||||
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1264,4 +1596,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
394
locales/es.json
394
locales/es.json
@@ -10,7 +10,8 @@
|
||||
"next": "Siguiente",
|
||||
"backToTop": "Volver arriba",
|
||||
"settings": "Configuración",
|
||||
"help": "Ayuda"
|
||||
"help": "Ayuda",
|
||||
"add": "Añadir"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Cargando...",
|
||||
@@ -32,7 +33,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Bytes",
|
||||
@@ -101,7 +102,12 @@
|
||||
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
||||
"toggleBlur": "Alternar difuminado",
|
||||
"show": "Mostrar",
|
||||
"openExampleImages": "Abrir carpeta de imágenes de ejemplo"
|
||||
"openExampleImages": "Abrir carpeta de imágenes de ejemplo",
|
||||
"replacePreview": "Reemplazar vista previa",
|
||||
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Contenido para adultos",
|
||||
@@ -115,12 +121,20 @@
|
||||
"updateFailed": "Error al actualizar estado de favoritos"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Enviar checkpoint al flujo de trabajo - función por implementar"
|
||||
"checkpointNotImplemented": "Enviar checkpoint al flujo de trabajo - función por implementar",
|
||||
"missingPath": "No se puede determinar la ruta del modelo para esta tarjeta"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Error al verificar imágenes de ejemplo",
|
||||
"missingHash": "Falta información del hash del modelo.",
|
||||
"noRemoteImagesAvailable": "No hay imágenes de ejemplo remotas disponibles para este modelo en Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Actualización",
|
||||
"updateAvailable": "Actualización disponible"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Veces usado"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,12 +143,33 @@
|
||||
"missingPath": "Establece una ubicación de descarga antes de descargar imágenes de ejemplo.",
|
||||
"unavailable": "Las descargas de imágenes de ejemplo aún no están disponibles. Intenta de nuevo después de que la página termine de cargar."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Buscar actualizaciones",
|
||||
"loading": "Buscando actualizaciones de {type}...",
|
||||
"success": "Se encontraron {count} actualización(es) para {type}",
|
||||
"none": "Todos los {type} están actualizados",
|
||||
"error": "Error al buscar actualizaciones de {type}: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Limpiar carpetas de imágenes de ejemplo",
|
||||
"success": "Se movieron {count} carpeta(s) a la carpeta de eliminados",
|
||||
"none": "No hay carpetas de imágenes de ejemplo que necesiten limpieza",
|
||||
"partial": "Limpieza completada con {failures} carpeta(s) omitidas",
|
||||
"error": "No se pudieron limpiar las carpetas de imágenes de ejemplo: {message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "Reparar datos de recetas",
|
||||
"loading": "Reparando datos de recetas...",
|
||||
"success": "Se repararon con éxito {count} recetas.",
|
||||
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||
"error": "Error al reparar recetas: {message}"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -144,6 +179,7 @@
|
||||
"recipes": "Recetas",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"misc": "[TODO: Translate] Misc",
|
||||
"statistics": "Estadísticas"
|
||||
},
|
||||
"search": {
|
||||
@@ -152,7 +188,8 @@
|
||||
"loras": "Buscar LoRAs...",
|
||||
"recipes": "Buscar recetas...",
|
||||
"checkpoints": "Buscar checkpoints...",
|
||||
"embeddings": "Buscar embeddings..."
|
||||
"embeddings": "Buscar embeddings...",
|
||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
||||
},
|
||||
"options": "Opciones de búsqueda",
|
||||
"searchIn": "Buscar en:",
|
||||
@@ -164,13 +201,30 @@
|
||||
"creator": "Creador",
|
||||
"title": "Título de la receta",
|
||||
"loraName": "Nombre de archivo LoRA",
|
||||
"loraModel": "Nombre del modelo LoRA"
|
||||
"loraModel": "Nombre del modelo LoRA",
|
||||
"prompt": "Prompt"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filtrar modelos",
|
||||
"presets": "Preajustes",
|
||||
"savePreset": "Guardar filtros activos como nuevo preajuste.",
|
||||
"savePresetDisabledActive": "No se puede guardar: Ya hay un preajuste activo. Modifique los filtros para guardar un nuevo preajuste",
|
||||
"savePresetDisabledNoFilters": "Seleccione filtros primero para guardar como preajuste",
|
||||
"savePresetPrompt": "Ingrese el nombre del preajuste:",
|
||||
"presetClickTooltip": "Hacer clic para aplicar preajuste \"{name}\"",
|
||||
"presetDeleteTooltip": "Eliminar preajuste",
|
||||
"presetDeleteConfirm": "¿Eliminar preajuste \"{name}\"?",
|
||||
"presetDeleteConfirmClick": "Haga clic de nuevo para confirmar",
|
||||
"presetOverwriteConfirm": "El preset \"{name}\" ya existe. ¿Sobrescribir?",
|
||||
"presetNamePlaceholder": "Nombre del preajuste...",
|
||||
"baseModel": "Modelo base",
|
||||
"modelTags": "Etiquetas (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
"noTags": "Sin etiquetas",
|
||||
"clearAll": "Limpiar todos los filtros"
|
||||
},
|
||||
"theme": {
|
||||
@@ -181,6 +235,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
"notifications": "Notificaciones",
|
||||
"support": "Soporte"
|
||||
}
|
||||
},
|
||||
@@ -192,19 +247,29 @@
|
||||
"label": "Abrir carpeta de ajustes",
|
||||
"tooltip": "Abrir la carpeta que contiene settings.json",
|
||||
"success": "Carpeta de settings.json abierta",
|
||||
"failed": "No se pudo abrir la carpeta de settings.json"
|
||||
"failed": "No se pudo abrir la carpeta de settings.json",
|
||||
"copied": "Ruta de configuración copiada al portapapeles: {{path}}",
|
||||
"clipboardFallback": "Ruta de configuración: {{path}}"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Filtrado de contenido",
|
||||
"videoSettings": "Configuración de video",
|
||||
"layoutSettings": "Configuración de diseño",
|
||||
"folderSettings": "Configuración de carpetas",
|
||||
"priorityTags": "Etiquetas prioritarias",
|
||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||
"exampleImages": "Imágenes de ejemplo",
|
||||
"updateFlags": "Indicadores de actualización",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"misc": "Varios",
|
||||
"metadataArchive": "Base de datos de archivo de metadatos",
|
||||
"storageLocation": "Ubicación de ajustes",
|
||||
"proxySettings": "Configuración de proxy"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "Modo portátil",
|
||||
"locationHelp": "Activa para mantener settings.json dentro del repositorio; desactívalo para guardarlo en tu directorio de configuración de usuario."
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "Difuminar contenido NSFW",
|
||||
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
|
||||
@@ -215,6 +280,15 @@
|
||||
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
|
||||
"autoplayOnHoverHelp": "Solo reproducir vistas previas de video al pasar el ratón sobre ellas"
|
||||
},
|
||||
"autoOrganizeExclusions": {
|
||||
"label": "Exclusiones de auto-organización",
|
||||
"placeholder": "Ejemplo: curated/*, */backups/*; *_temp.safetensors",
|
||||
"help": "Omitir archivos que coincidan con estos patrones comodín. Separe múltiples patrones con comas o puntos y comas.",
|
||||
"validation": {
|
||||
"noPatterns": "Ingrese al menos un patrón separado por comas o puntos y comas.",
|
||||
"saveFailed": "No se pudieron guardar las exclusiones: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Densidad de visualización",
|
||||
"displayDensityOptions": {
|
||||
@@ -224,21 +298,31 @@
|
||||
},
|
||||
"displayDensityHelp": "Elige cuántas tarjetas mostrar por fila:",
|
||||
"displayDensityDetails": {
|
||||
"default": "Predeterminado: 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "Medio: 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "Compacto: 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
|
||||
"showFolderSidebar": "Mostrar barra lateral de carpetas",
|
||||
"showFolderSidebarHelp": "Activa o desactiva la barra lateral de navegación de carpetas en las páginas de modelos. Cuando está desactivada, la barra lateral y el área de desplazamiento permanecen ocultas.",
|
||||
"cardInfoDisplay": "Visualización de información de tarjeta",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Siempre visible",
|
||||
"hover": "Mostrar al pasar el ratón"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Siempre visible: Los encabezados y pies de página siempre son visibles",
|
||||
"hover": "Mostrar al pasar el ratón: Los encabezados y pies de página solo aparecen al pasar el ratón sobre una tarjeta"
|
||||
}
|
||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
||||
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Abrir imágenes de ejemplo",
|
||||
"replacePreview": "Reemplazar vista previa"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Elige qué hace el botón en la esquina inferior derecha de la tarjeta",
|
||||
"modelNameDisplay": "Visualización del nombre del modelo",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Nombre del modelo",
|
||||
"fileName": "Nombre del archivo"
|
||||
},
|
||||
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Biblioteca activa",
|
||||
@@ -249,10 +333,32 @@
|
||||
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
|
||||
"defaultCheckpointRoot": "Raíz predeterminada de checkpoint",
|
||||
"defaultCheckpointRootHelp": "Establecer el directorio raíz predeterminado de checkpoint para descargas, importaciones y movimientos",
|
||||
"defaultUnetRoot": "Raíz predeterminada de Diffusion Model",
|
||||
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
||||
"defaultEmbeddingRoot": "Raíz predeterminada de embedding",
|
||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||
"noDefault": "Sin predeterminado"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Etiquetas prioritarias",
|
||||
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "Abrir ayuda de etiquetas prioritarias",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "Etiquetas prioritarias actualizadas.",
|
||||
"saveError": "Error al actualizar las etiquetas prioritarias.",
|
||||
"loadingSuggestions": "Cargando sugerencias...",
|
||||
"validation": {
|
||||
"missingClosingParen": "A la entrada {index} le falta un paréntesis de cierre.",
|
||||
"missingCanonical": "La entrada {index} debe incluir un nombre de etiqueta canónica.",
|
||||
"duplicateCanonical": "La etiqueta canónica \"{tag}\" aparece más de una vez.",
|
||||
"unknown": "Configuración de etiquetas prioritarias no válida."
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "Plantillas de rutas de descarga",
|
||||
"help": "Configurar estructuras de carpetas para diferentes tipos de modelos al descargar de Civitai.",
|
||||
@@ -300,6 +406,14 @@
|
||||
"download": "Descargar",
|
||||
"restartRequired": "Requiere reinicio"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "Estrategia de indicadores de actualización",
|
||||
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
|
||||
"options": {
|
||||
"sameBase": "Coincidir actualizaciones por modelo base",
|
||||
"any": "Marcar cualquier actualización disponible"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
|
||||
@@ -359,12 +473,17 @@
|
||||
"dateAsc": "Más antiguo",
|
||||
"size": "Tamaño de archivo",
|
||||
"sizeDesc": "Mayor",
|
||||
"sizeAsc": "Menor"
|
||||
"sizeAsc": "Menor",
|
||||
"usage": "Número de usos",
|
||||
"usageDesc": "Más",
|
||||
"usageAsc": "Menos"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de modelos",
|
||||
"quick": "Actualización rápida (incremental)",
|
||||
"full": "Reconstrucción completa"
|
||||
"quick": "Sincronizar cambios",
|
||||
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
|
||||
"full": "Reconstruir caché",
|
||||
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Obtener metadatos de Civitai",
|
||||
@@ -385,6 +504,13 @@
|
||||
"favorites": {
|
||||
"title": "Mostrar solo favoritos",
|
||||
"action": "Favoritos"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Mostrar solo modelos con actualizaciones disponibles",
|
||||
"action": "Actualizaciones",
|
||||
"menuLabel": "Mostrar opciones de actualización",
|
||||
"check": "Buscar actualizaciones",
|
||||
"checkTooltip": "Comprobar actualizaciones puede tardar."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -396,6 +522,7 @@
|
||||
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||
"copyAll": "Copiar toda la sintaxis",
|
||||
"refreshAll": "Actualizar todos los metadatos",
|
||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||
"moveAll": "Mover todos a carpeta",
|
||||
"autoOrganize": "Auto-organizar seleccionados",
|
||||
"deleteAll": "Eliminar todos los modelos",
|
||||
@@ -412,6 +539,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Actualizar datos de Civitai",
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
"relinkCivitai": "Re-vincular a Civitai",
|
||||
"copySyntax": "Copiar sintaxis de LoRA",
|
||||
"copyFilename": "Copiar nombre de archivo del modelo",
|
||||
@@ -423,6 +551,7 @@
|
||||
"replacePreview": "Reemplazar vista previa",
|
||||
"setContentRating": "Establecer clasificación de contenido",
|
||||
"moveToFolder": "Mover a carpeta",
|
||||
"repairMetadata": "Reparar metadatos",
|
||||
"excludeModel": "Excluir modelo",
|
||||
"deleteModel": "Eliminar modelo",
|
||||
"shareRecipe": "Compartir receta",
|
||||
@@ -433,6 +562,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Recetas de LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Enviar a ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Importar",
|
||||
@@ -490,10 +622,26 @@
|
||||
"selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "Ordenar recetas por...",
|
||||
"name": "Nombre",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "Fecha",
|
||||
"dateDesc": "Más reciente",
|
||||
"dateAsc": "Más antiguo",
|
||||
"lorasCount": "Cant. de LoRAs",
|
||||
"lorasCountDesc": "Más",
|
||||
"lorasCountAsc": "Menos"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de recetas"
|
||||
},
|
||||
"filteredByLora": "Filtrado por LoRA"
|
||||
"filteredByLora": "Filtrado por LoRA",
|
||||
"favorites": {
|
||||
"title": "Mostrar solo favoritos",
|
||||
"action": "Favoritos"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "Se encontraron {count} grupos de duplicados",
|
||||
@@ -519,23 +667,54 @@
|
||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||
"prepareError": "Error preparando LoRAs para descarga: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "Reparando metadatos de la receta...",
|
||||
"success": "Metadatos de la receta reparados con éxito",
|
||||
"skipped": "La receta ya está en la última versión, no se necesita reparación",
|
||||
"failed": "Error al reparar la receta: {message}",
|
||||
"missingId": "No se puede reparar la receta: falta el ID de la receta"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "Modelos checkpoint"
|
||||
"title": "Modelos checkpoint",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "Modelos embedding"
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] VAE & Upscaler Models",
|
||||
"modelTypes": {
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Raíz del modelo",
|
||||
"modelRoot": "Raíz",
|
||||
"collapseAll": "Colapsar todas las carpetas",
|
||||
"pinSidebar": "Fijar barra lateral",
|
||||
"unpinSidebar": "Desfijar barra lateral",
|
||||
"switchToListView": "Cambiar a vista de lista",
|
||||
"switchToTreeView": "Cambiar a vista de árbol",
|
||||
"collapseAllDisabled": "No disponible en vista de lista"
|
||||
"recursiveOn": "Buscar en subcarpetas",
|
||||
"recursiveOff": "Buscar solo en la carpeta actual",
|
||||
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||
"collapseAllDisabled": "No disponible en vista de lista",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Estadísticas",
|
||||
@@ -610,6 +789,14 @@
|
||||
"downloadedPreview": "Imagen de vista previa descargada",
|
||||
"downloadingFile": "Descargando archivo de {type}",
|
||||
"finalizing": "Finalizando descarga..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Archivo actual:",
|
||||
"downloading": "Descargando: {name}",
|
||||
"transferred": "Descargado: {downloaded} / {total}",
|
||||
"transferredSimple": "Descargado: {downloaded}",
|
||||
"transferredUnknown": "Descargado: --",
|
||||
"speed": "Velocidad: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -657,6 +844,12 @@
|
||||
"countMessage": "modelos serán eliminados permanentemente.",
|
||||
"action": "Eliminar todo"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
|
||||
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
|
||||
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
|
||||
"action": "Comprobar todo"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Añadir etiquetas a múltiples modelos",
|
||||
"description": "Añadir etiquetas a",
|
||||
@@ -730,7 +923,9 @@
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Ubicación del archivo abierta exitosamente",
|
||||
"failed": "Error al abrir la ubicación del archivo"
|
||||
"failed": "Error al abrir la ubicación del archivo",
|
||||
"copied": "Ruta copiada al portapapeles: {{path}}",
|
||||
"clipboardFallback": "Ruta: {{path}}"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Versión",
|
||||
@@ -753,11 +948,13 @@
|
||||
"addPresetParameter": "Añadir parámetro preestablecido...",
|
||||
"strengthMin": "Fuerza mínima",
|
||||
"strengthMax": "Fuerza máxima",
|
||||
"strengthRange": "Rango de fuerza",
|
||||
"strength": "Fuerza",
|
||||
"clipStrength": "Fuerza de Clip",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Valor",
|
||||
"add": "Añadir"
|
||||
"add": "Añadir",
|
||||
"invalidRange": "Formato de rango inválido. Use x.x-y.y"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "Palabras clave",
|
||||
@@ -793,13 +990,84 @@
|
||||
"tabs": {
|
||||
"examples": "Ejemplos",
|
||||
"description": "Descripción del modelo",
|
||||
"recipes": "Recetas"
|
||||
"recipes": "Recetas",
|
||||
"versions": "Versiones"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Navegación de modelos",
|
||||
"previousWithShortcut": "Modelo anterior (←)",
|
||||
"nextWithShortcut": "Siguiente modelo (→)",
|
||||
"noPrevious": "No hay modelo anterior disponible",
|
||||
"noNext": "No hay siguiente modelo disponible"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "Crédito del creador requerido",
|
||||
"noDerivatives": "No se permiten fusiones",
|
||||
"noReLicense": "Se requieren mismos permisos",
|
||||
"restrictionsLabel": "Restricciones de licencia"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Cargando imágenes de ejemplo...",
|
||||
"description": "Cargando descripción del modelo...",
|
||||
"recipes": "Cargando recetas...",
|
||||
"examples": "Cargando ejemplos..."
|
||||
"examples": "Cargando ejemplos...",
|
||||
"versions": "Cargando versiones..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versiones del modelo",
|
||||
"copy": "Administra todas las versiones de este modelo en un solo lugar.",
|
||||
"media": {
|
||||
"placeholder": "Sin vista previa"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Versión sin nombre",
|
||||
"noDetails": "Sin detalles adicionales"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Versión actual",
|
||||
"inLibrary": "En la biblioteca",
|
||||
"newer": "Versión más reciente",
|
||||
"ignored": "Ignorada"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Descargar",
|
||||
"delete": "Eliminar",
|
||||
"ignore": "Ignorar",
|
||||
"unignore": "Dejar de ignorar",
|
||||
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
|
||||
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
|
||||
"viewLocalVersions": "Ver todas las versiones locales",
|
||||
"viewLocalTooltip": "Disponible pronto"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtro base",
|
||||
"state": {
|
||||
"showAll": "Todas las versiones",
|
||||
"showSameBase": "Mismo modelo base"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "Cambiar para mostrar todas las versiones",
|
||||
"showSameBaseVersions": "Cambiar para mostrar solo versiones del mismo modelo base"
|
||||
},
|
||||
"empty": "Ninguna versión coincide con el filtro del modelo base actual."
|
||||
},
|
||||
"empty": "Aún no hay historial de versiones para este modelo.",
|
||||
"error": "No se pudieron cargar las versiones.",
|
||||
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
|
||||
"confirm": {
|
||||
"delete": "¿Eliminar esta versión de tu biblioteca?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Se ignoran las actualizaciones de este modelo",
|
||||
"modelResumed": "Seguimiento de actualizaciones reanudado",
|
||||
"versionIgnored": "Se ignoran las actualizaciones de esta versión",
|
||||
"versionUnignored": "Versión habilitada nuevamente",
|
||||
"versionDeleted": "Versión eliminada"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,6 +1116,10 @@
|
||||
"title": "Inicializando estadísticas",
|
||||
"message": "Procesando datos del modelo para estadísticas. Esto puede tomar unos minutos..."
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
||||
},
|
||||
"tips": {
|
||||
"title": "Consejos y trucos",
|
||||
"civitai": {
|
||||
@@ -906,11 +1178,19 @@
|
||||
"loraFailedToSend": "Error al enviar LoRA al flujo de trabajo",
|
||||
"recipeAdded": "Receta añadida al flujo de trabajo",
|
||||
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo"
|
||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
||||
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
|
||||
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
|
||||
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
|
||||
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
|
||||
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Receta",
|
||||
"lora": "LoRA",
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler",
|
||||
"replace": "Reemplazar",
|
||||
"append": "Añadir",
|
||||
"selectTargetNode": "Seleccionar nodo de destino",
|
||||
@@ -919,7 +1199,11 @@
|
||||
"exampleImages": {
|
||||
"opened": "Carpeta de imágenes de ejemplo abierta",
|
||||
"openingFolder": "Abriendo carpeta de imágenes de ejemplo",
|
||||
"failedToOpen": "Error al abrir carpeta de imágenes de ejemplo"
|
||||
"failedToOpen": "Error al abrir carpeta de imágenes de ejemplo",
|
||||
"setupRequired": "Almacenamiento de imágenes de ejemplo",
|
||||
"setupDescription": "Para agregar imágenes de ejemplo personalizadas, primero necesita establecer una ubicación de descarga.",
|
||||
"setupUsage": "Esta ruta se utiliza tanto para imágenes de ejemplo descargadas como personalizadas.",
|
||||
"openSettings": "Abrir configuración"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -951,6 +1235,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Comprobar actualizaciones",
|
||||
"notificationsTitle": "Centro de notificaciones",
|
||||
"tabs": {
|
||||
"updates": "Actualizaciones",
|
||||
"messages": "Mensajes"
|
||||
},
|
||||
"updateAvailable": "Actualización disponible",
|
||||
"noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.",
|
||||
"currentVersion": "Versión actual",
|
||||
@@ -963,6 +1252,7 @@
|
||||
"checkingUpdates": "Comprobando actualizaciones...",
|
||||
"checkingMessage": "Por favor espera mientras comprobamos la última versión.",
|
||||
"showNotifications": "Mostrar notificaciones de actualización",
|
||||
"latestBadge": "Último",
|
||||
"updateProgress": {
|
||||
"preparing": "Preparando actualización...",
|
||||
"installing": "Instalando actualización...",
|
||||
@@ -982,6 +1272,13 @@
|
||||
"nightly": {
|
||||
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
|
||||
"enable": "Habilitar actualizaciones nocturnas"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Notificaciones recientes",
|
||||
"empty": "No hay banners recientes.",
|
||||
"shown": "Mostrado {time}",
|
||||
"dismissed": "Descartado {time}",
|
||||
"active": "Activo"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1061,6 +1358,9 @@
|
||||
"cannotSend": "No se puede enviar receta: Falta ID de receta",
|
||||
"sendFailed": "Error al enviar receta al flujo de trabajo",
|
||||
"sendError": "Error enviando receta al flujo de trabajo",
|
||||
"missingCheckpointPath": "Ruta del checkpoint no disponible",
|
||||
"missingCheckpointInfo": "Falta información del checkpoint",
|
||||
"downloadCheckpointFailed": "Error al descargar el checkpoint: {message}",
|
||||
"cannotDelete": "No se puede eliminar receta: Falta ID de receta",
|
||||
"deleteConfirmationError": "Error mostrando confirmación de eliminación",
|
||||
"deletedSuccessfully": "Receta eliminada exitosamente",
|
||||
@@ -1101,6 +1401,12 @@
|
||||
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
||||
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
|
||||
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
|
||||
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
||||
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
||||
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
||||
"bulkUpdatesMissing": "Los {type} seleccionados no están vinculados a actualizaciones de Civitai",
|
||||
"bulkUpdatesPartialMissing": "Se omitieron {missing} {type} seleccionados sin enlace de Civitai",
|
||||
"bulkUpdatesFailed": "Error al comprobar actualizaciones para los {type} seleccionados: {message}",
|
||||
"invalidCharactersRemoved": "Caracteres inválidos eliminados del nombre de archivo",
|
||||
"filenameCannotBeEmpty": "El nombre de archivo no puede estar vacío",
|
||||
"renameFailed": "Error al renombrar archivo: {message}",
|
||||
@@ -1112,6 +1418,7 @@
|
||||
"verificationCompleteSuccess": "Verificación completa. Todos los archivos son confirmados duplicados.",
|
||||
"verificationFailed": "Error al verificar hashes: {message}",
|
||||
"noTagsToAdd": "No hay etiquetas para añadir",
|
||||
"bulkTagsUpdating": "Actualizando etiquetas para {count} modelo(s)...",
|
||||
"tagsAddedSuccessfully": "Se añadieron exitosamente {tagCount} etiqueta(s) a {count} {type}(s)",
|
||||
"tagsReplacedSuccessfully": "Se reemplazaron exitosamente las etiquetas de {count} {type}(s) con {tagCount} etiqueta(s)",
|
||||
"tagsAddFailed": "Error al añadir etiquetas a {count} modelo(s)",
|
||||
@@ -1125,6 +1432,7 @@
|
||||
"settings": {
|
||||
"loraRootsFailed": "Error al cargar raíces de LoRA: {message}",
|
||||
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
||||
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
|
||||
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
|
||||
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
|
||||
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
||||
@@ -1145,7 +1453,26 @@
|
||||
"filters": {
|
||||
"applied": "{message}",
|
||||
"cleared": "Filtros limpiados",
|
||||
"noCustomFilterToClear": "No hay filtro personalizado para limpiar"
|
||||
"noCustomFilterToClear": "No hay filtro personalizado para limpiar",
|
||||
"noActiveFilters": "No hay filtros activos para guardar"
|
||||
},
|
||||
"presets": {
|
||||
"created": "Preajuste \"{name}\" creado",
|
||||
"deleted": "Preajuste \"{name}\" eliminado",
|
||||
"applied": "Preajuste \"{name}\" aplicado",
|
||||
"overwritten": "Preset \"{name}\" sobrescrito",
|
||||
"restored": "Presets predeterminados restaurados"
|
||||
},
|
||||
"error": {
|
||||
"presetNameEmpty": "El nombre del preajuste no puede estar vacío",
|
||||
"presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos",
|
||||
"presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos",
|
||||
"presetNameExists": "Ya existe un preajuste con este nombre",
|
||||
"maxPresetsReached": "Máximo {max} preajustes permitidos. Elimine uno para agregar más.",
|
||||
"presetNotFound": "Preajuste no encontrado",
|
||||
"invalidPreset": "Datos de preajuste inválidos",
|
||||
"deletePresetFailed": "Error al eliminar el preajuste",
|
||||
"applyPresetFailed": "Error al aplicar el preajuste"
|
||||
},
|
||||
"downloads": {
|
||||
"imagesCompleted": "Imágenes de ejemplo {action} completadas",
|
||||
@@ -1161,7 +1488,7 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "No se pudieron cargar palabras entrenadas",
|
||||
"tooLong": "La palabra clave no debe exceder 30 palabras",
|
||||
"tooLong": "La palabra clave no debe exceder 100 palabras",
|
||||
"tooMany": "Máximo 30 palabras clave permitidas",
|
||||
"alreadyExists": "Esta palabra clave ya existe",
|
||||
"updateSuccess": "Palabras clave actualizadas exitosamente",
|
||||
@@ -1210,6 +1537,8 @@
|
||||
"pauseFailed": "Error al pausar descarga: {error}",
|
||||
"downloadResumed": "Descarga reanudada",
|
||||
"resumeFailed": "Error al reanudar descarga: {error}",
|
||||
"downloadStopped": "Descarga cancelada",
|
||||
"stopFailed": "Error al cancelar descarga: {error}",
|
||||
"deleted": "Imagen de ejemplo eliminada",
|
||||
"deleteFailed": "Error al eliminar imagen de ejemplo",
|
||||
"setPreviewFailed": "Error al establecer imagen de vista previa"
|
||||
@@ -1230,6 +1559,8 @@
|
||||
"metadataRefreshed": "Metadatos actualizados exitosamente",
|
||||
"metadataRefreshFailed": "Error al actualizar metadatos: {message}",
|
||||
"metadataUpdateComplete": "Actualización de metadatos completada",
|
||||
"operationCancelled": "Operación cancelada por el usuario",
|
||||
"operationCancelledPartial": "Operación cancelada. {success} elementos procesados.",
|
||||
"metadataFetchFailed": "Error al obtener metadatos: {message}",
|
||||
"bulkMetadataCompleteAll": "Actualizados exitosamente todos los {count} {type}s",
|
||||
"bulkMetadataCompletePartial": "Actualizados {success} de {total} {type}s",
|
||||
@@ -1246,7 +1577,8 @@
|
||||
"bulkMoveFailures": "Movimientos fallidos:\n{failures}",
|
||||
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
||||
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}"
|
||||
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
396
locales/fr.json
396
locales/fr.json
@@ -10,7 +10,8 @@
|
||||
"next": "Suivant",
|
||||
"backToTop": "Retour en haut",
|
||||
"settings": "Paramètres",
|
||||
"help": "Aide"
|
||||
"help": "Aide",
|
||||
"add": "Ajouter"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement...",
|
||||
@@ -32,7 +33,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Octets",
|
||||
@@ -101,7 +102,12 @@
|
||||
"checkpointNameCopied": "Nom du checkpoint copié",
|
||||
"toggleBlur": "Basculer le flou",
|
||||
"show": "Afficher",
|
||||
"openExampleImages": "Ouvrir le dossier d'images d'exemple"
|
||||
"openExampleImages": "Ouvrir le dossier d'images d'exemple",
|
||||
"replacePreview": "Remplacer l'aperçu",
|
||||
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Contenu pour adultes",
|
||||
@@ -115,12 +121,20 @@
|
||||
"updateFailed": "Échec de la mise à jour du statut des favoris"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Envoyer le checkpoint vers le workflow - fonctionnalité à implémenter"
|
||||
"checkpointNotImplemented": "Envoyer le checkpoint vers le workflow - fonctionnalité à implémenter",
|
||||
"missingPath": "Impossible de déterminer le chemin du modèle pour cette carte"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Erreur lors de la vérification des images d'exemple",
|
||||
"missingHash": "Informations de hachage du modèle manquantes.",
|
||||
"noRemoteImagesAvailable": "Aucune image d'exemple distante disponible pour ce modèle sur Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Mise à jour",
|
||||
"updateAvailable": "Mise à jour disponible"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Nombre d'utilisations"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,12 +143,33 @@
|
||||
"missingPath": "Définissez un emplacement de téléchargement avant de télécharger les images d'exemple.",
|
||||
"unavailable": "Le téléchargement des images d'exemple n'est pas encore disponible. Réessayez après le chargement complet de la page."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Vérifier les mises à jour",
|
||||
"loading": "Recherche de mises à jour pour {type}...",
|
||||
"success": "{count} mise(s) à jour trouvée(s) pour {type}",
|
||||
"none": "Tous les {type} sont à jour",
|
||||
"error": "Échec de la vérification des mises à jour pour {type} : {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Nettoyer les dossiers d'images d'exemple",
|
||||
"label": "Supprimer les dossiers d'exemples orphelins",
|
||||
"success": "{count} dossier(s) déplacé(s) vers le dossier supprimé",
|
||||
"none": "Aucun dossier d'images d'exemple à nettoyer",
|
||||
"partial": "Nettoyage terminé avec {failures} dossier(s) ignoré(s)",
|
||||
"error": "Échec du nettoyage des dossiers d'images d'exemple : {message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "Réparer les données de recettes",
|
||||
"loading": "Réparation des données de recettes...",
|
||||
"success": "{count} recettes réparées avec succès.",
|
||||
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
||||
"error": "Échec de la réparation des recettes : {message}"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -144,6 +179,7 @@
|
||||
"recipes": "Recipes",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"misc": "[TODO: Translate] Misc",
|
||||
"statistics": "Statistiques"
|
||||
},
|
||||
"search": {
|
||||
@@ -152,7 +188,8 @@
|
||||
"loras": "Rechercher des LoRAs...",
|
||||
"recipes": "Rechercher des recipes...",
|
||||
"checkpoints": "Rechercher des checkpoints...",
|
||||
"embeddings": "Rechercher des embeddings..."
|
||||
"embeddings": "Rechercher des embeddings...",
|
||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
||||
},
|
||||
"options": "Options de recherche",
|
||||
"searchIn": "Rechercher dans :",
|
||||
@@ -164,13 +201,30 @@
|
||||
"creator": "Créateur",
|
||||
"title": "Titre de la recipe",
|
||||
"loraName": "Nom de fichier LoRA",
|
||||
"loraModel": "Nom du modèle LoRA"
|
||||
"loraModel": "Nom du modèle LoRA",
|
||||
"prompt": "Prompt"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filtrer les modèles",
|
||||
"presets": "Préréglages",
|
||||
"savePreset": "Enregistrer les filtres actifs comme nouveau préréglage.",
|
||||
"savePresetDisabledActive": "Impossible d'enregistrer : Un préréglage est déjà actif. Modifiez les filtres pour enregistrer un nouveau préréglage",
|
||||
"savePresetDisabledNoFilters": "Sélectionnez d'abord des filtres à enregistrer comme préréglage",
|
||||
"savePresetPrompt": "Entrez le nom du préréglage :",
|
||||
"presetClickTooltip": "Cliquer pour appliquer le préréglage \"{name}\"",
|
||||
"presetDeleteTooltip": "Supprimer le préréglage",
|
||||
"presetDeleteConfirm": "Supprimer le préréglage \"{name}\" ?",
|
||||
"presetDeleteConfirmClick": "Cliquez à nouveau pour confirmer",
|
||||
"presetOverwriteConfirm": "Le préréglage \"{name}\" existe déjà. Remplacer?",
|
||||
"presetNamePlaceholder": "Nom du préréglage...",
|
||||
"baseModel": "Modèle de base",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"allowSellingGeneratedContent": "Vente autorisée",
|
||||
"noTags": "Aucun tag",
|
||||
"clearAll": "Effacer tous les filtres"
|
||||
},
|
||||
"theme": {
|
||||
@@ -181,6 +235,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
"notifications": "Notifications",
|
||||
"support": "Support"
|
||||
}
|
||||
},
|
||||
@@ -192,19 +247,29 @@
|
||||
"label": "Ouvrir le dossier des paramètres",
|
||||
"tooltip": "Ouvrir le dossier contenant settings.json",
|
||||
"success": "Dossier settings.json ouvert",
|
||||
"failed": "Impossible d'ouvrir le dossier settings.json"
|
||||
"failed": "Impossible d'ouvrir le dossier settings.json",
|
||||
"copied": "Chemin des paramètres copié dans le presse-papiers: {{path}}",
|
||||
"clipboardFallback": "Chemin des paramètres: {{path}}"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Filtrage du contenu",
|
||||
"videoSettings": "Paramètres vidéo",
|
||||
"layoutSettings": "Paramètres d'affichage",
|
||||
"folderSettings": "Paramètres des dossiers",
|
||||
"priorityTags": "Étiquettes prioritaires",
|
||||
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
||||
"exampleImages": "Images d'exemple",
|
||||
"updateFlags": "Indicateurs de mise à jour",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"misc": "Divers",
|
||||
"metadataArchive": "Base de données d'archive des métadonnées",
|
||||
"storageLocation": "Emplacement des paramètres",
|
||||
"proxySettings": "Paramètres du proxy"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "Mode portable",
|
||||
"locationHelp": "Activez pour garder settings.json dans le dépôt ; désactivez pour le placer dans votre dossier de configuration utilisateur."
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "Flouter le contenu NSFW",
|
||||
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
|
||||
@@ -215,6 +280,15 @@
|
||||
"autoplayOnHover": "Lecture automatique vidéo au survol",
|
||||
"autoplayOnHoverHelp": "Lire les aperçus vidéo uniquement lors du survol"
|
||||
},
|
||||
"autoOrganizeExclusions": {
|
||||
"label": "Exclusions de l'auto-organisation",
|
||||
"placeholder": "Exemple : curated/*, */backups/*; *_temp.safetensors",
|
||||
"help": "Ignorer les fichiers correspondant à ces motifs génériques. Séparez plusieurs motifs par des virgules ou des points-virgules.",
|
||||
"validation": {
|
||||
"noPatterns": "Entrez au moins un motif séparé par des virgules ou des points-virgules.",
|
||||
"saveFailed": "Impossible d'enregistrer les exclusions : {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Densité d'affichage",
|
||||
"displayDensityOptions": {
|
||||
@@ -224,21 +298,31 @@
|
||||
},
|
||||
"displayDensityHelp": "Choisissez combien de cartes afficher par ligne :",
|
||||
"displayDensityDetails": {
|
||||
"default": "Par défaut : 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "Moyen : 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "Compact : 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.",
|
||||
"showFolderSidebar": "Afficher la barre latérale des dossiers",
|
||||
"showFolderSidebarHelp": "Activez ou désactivez la barre latérale de navigation des dossiers sur les pages de modèles. Lorsqu'elle est désactivée, la barre latérale et la zone de survol restent masquées.",
|
||||
"cardInfoDisplay": "Affichage des informations de carte",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Toujours visible",
|
||||
"hover": "Révéler au survol"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action :",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Toujours visible : Les en-têtes et pieds de page sont toujours visibles",
|
||||
"hover": "Révéler au survol : Les en-têtes et pieds de page n'apparaissent qu'au survol d'une carte"
|
||||
}
|
||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
||||
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Ouvrir les images d'exemple",
|
||||
"replacePreview": "Remplacer l'aperçu"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Choisissez ce que fait le bouton en bas à droite de la carte",
|
||||
"modelNameDisplay": "Affichage du nom du modèle",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Nom du modèle",
|
||||
"fileName": "Nom du fichier"
|
||||
},
|
||||
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Bibliothèque active",
|
||||
@@ -249,10 +333,32 @@
|
||||
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultCheckpointRoot": "Racine Checkpoint par défaut",
|
||||
"defaultCheckpointRootHelp": "Définir le répertoire racine checkpoint par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultUnetRoot": "Racine Diffusion Model par défaut",
|
||||
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultEmbeddingRoot": "Racine Embedding par défaut",
|
||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
||||
"noDefault": "Aucun par défaut"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Étiquettes prioritaires",
|
||||
"description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "Ouvrir l'aide sur les étiquettes prioritaires",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "Étiquettes prioritaires mises à jour.",
|
||||
"saveError": "Échec de la mise à jour des étiquettes prioritaires.",
|
||||
"loadingSuggestions": "Chargement des suggestions...",
|
||||
"validation": {
|
||||
"missingClosingParen": "L'entrée {index} n'a pas de parenthèse fermante.",
|
||||
"missingCanonical": "L'entrée {index} doit inclure un nom d'étiquette canonique.",
|
||||
"duplicateCanonical": "L'étiquette canonique \"{tag}\" apparaît plusieurs fois.",
|
||||
"unknown": "Configuration d'étiquettes prioritaires invalide."
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "Modèles de chemin de téléchargement",
|
||||
"help": "Configurer les structures de dossiers pour différents types de modèles lors du téléchargement depuis Civitai.",
|
||||
@@ -300,6 +406,14 @@
|
||||
"download": "Télécharger",
|
||||
"restartRequired": "Redémarrage requis"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "Stratégie des indicateurs de mise à jour",
|
||||
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsqu’une nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès qu’il existe une version plus récente pour ce modèle.",
|
||||
"options": {
|
||||
"sameBase": "Faire correspondre les mises à jour par modèle de base",
|
||||
"any": "Signaler n’importe quelle mise à jour disponible"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
||||
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers"
|
||||
@@ -359,12 +473,17 @@
|
||||
"dateAsc": "Plus ancien",
|
||||
"size": "Taille du fichier",
|
||||
"sizeDesc": "Plus grand",
|
||||
"sizeAsc": "Plus petit"
|
||||
"sizeAsc": "Plus petit",
|
||||
"usage": "Nombre d'utilisations",
|
||||
"usageDesc": "Plus",
|
||||
"usageAsc": "Moins"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des modèles",
|
||||
"quick": "Actualisation rapide (incrémentale)",
|
||||
"full": "Reconstruction complète"
|
||||
"quick": "Synchroniser les changements",
|
||||
"quickTooltip": "Analyse les nouveaux fichiers de modèle ou les fichiers manquants pour garder la liste à jour.",
|
||||
"full": "Reconstruire le cache",
|
||||
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Récupérer les métadonnées depuis Civitai",
|
||||
@@ -385,6 +504,13 @@
|
||||
"favorites": {
|
||||
"title": "Afficher uniquement les favoris",
|
||||
"action": "Favoris"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Afficher uniquement les modèles avec des mises à jour disponibles",
|
||||
"action": "Mises à jour",
|
||||
"menuLabel": "Afficher les options de mise à jour",
|
||||
"check": "Rechercher des mises à jour",
|
||||
"checkTooltip": "La vérification peut prendre du temps."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -396,6 +522,7 @@
|
||||
"setContentRating": "Définir la classification du contenu pour tous",
|
||||
"copyAll": "Copier toute la syntaxe",
|
||||
"refreshAll": "Actualiser toutes les métadonnées",
|
||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||
"moveAll": "Déplacer tout vers un dossier",
|
||||
"autoOrganize": "Auto-organiser la sélection",
|
||||
"deleteAll": "Supprimer tous les modèles",
|
||||
@@ -412,6 +539,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Actualiser les données Civitai",
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
"relinkCivitai": "Relier à nouveau à Civitai",
|
||||
"copySyntax": "Copier la syntaxe LoRA",
|
||||
"copyFilename": "Copier le nom de fichier du modèle",
|
||||
@@ -423,6 +551,7 @@
|
||||
"replacePreview": "Remplacer l'aperçu",
|
||||
"setContentRating": "Définir la classification du contenu",
|
||||
"moveToFolder": "Déplacer vers un dossier",
|
||||
"repairMetadata": "Réparer les métadonnées",
|
||||
"excludeModel": "Exclure le modèle",
|
||||
"deleteModel": "Supprimer le modèle",
|
||||
"shareRecipe": "Partager la recipe",
|
||||
@@ -433,6 +562,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA Recipes",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Envoyer vers ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Importer",
|
||||
@@ -490,10 +622,26 @@
|
||||
"selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "Trier les recettes par...",
|
||||
"name": "Nom",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "Date",
|
||||
"dateDesc": "Plus récent",
|
||||
"dateAsc": "Plus ancien",
|
||||
"lorasCount": "Nombre de LoRAs",
|
||||
"lorasCountDesc": "Plus",
|
||||
"lorasCountAsc": "Moins"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des recipes"
|
||||
},
|
||||
"filteredByLora": "Filtré par LoRA"
|
||||
"filteredByLora": "Filtré par LoRA",
|
||||
"favorites": {
|
||||
"title": "Afficher uniquement les favoris",
|
||||
"action": "Favoris"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "Trouvé {count} groupes de doublons",
|
||||
@@ -519,23 +667,54 @@
|
||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "Réparation des métadonnées de la recette...",
|
||||
"success": "Métadonnées de la recette réparées avec succès",
|
||||
"skipped": "Recette déjà à la version la plus récente, aucune réparation nécessaire",
|
||||
"failed": "Échec de la réparation de la recette : {message}",
|
||||
"missingId": "Impossible de réparer la recette : ID de recette manquant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "Modèles Checkpoint"
|
||||
"title": "Modèles Checkpoint",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "Modèles Embedding"
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] VAE & Upscaler Models",
|
||||
"modelTypes": {
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Racine du modèle",
|
||||
"modelRoot": "Racine",
|
||||
"collapseAll": "Réduire tous les dossiers",
|
||||
"pinSidebar": "Épingler la barre latérale",
|
||||
"unpinSidebar": "Désépingler la barre latérale",
|
||||
"switchToListView": "Passer en vue liste",
|
||||
"switchToTreeView": "Passer en vue arborescence",
|
||||
"collapseAllDisabled": "Non disponible en vue liste"
|
||||
"recursiveOn": "Rechercher dans les sous-dossiers",
|
||||
"recursiveOff": "Rechercher uniquement dans le dossier actuel",
|
||||
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||
"collapseAllDisabled": "Non disponible en vue liste",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Statistiques",
|
||||
@@ -610,6 +789,14 @@
|
||||
"downloadedPreview": "Image d'aperçu téléchargée",
|
||||
"downloadingFile": "Téléchargement du fichier {type}",
|
||||
"finalizing": "Finalisation du téléchargement..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Fichier actuel :",
|
||||
"downloading": "Téléchargement : {name}",
|
||||
"transferred": "Téléchargé : {downloaded} / {total}",
|
||||
"transferredSimple": "Téléchargé : {downloaded}",
|
||||
"transferredUnknown": "Téléchargé : --",
|
||||
"speed": "Vitesse : {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -657,6 +844,12 @@
|
||||
"countMessage": "modèles seront définitivement supprimés.",
|
||||
"action": "Tout supprimer"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
|
||||
"message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.",
|
||||
"tip": "Besoin de procéder par étapes ? Passez en mode lot, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
|
||||
"action": "Tout vérifier"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Ajouter des tags à plusieurs modèles",
|
||||
"description": "Ajouter des tags à",
|
||||
@@ -730,7 +923,9 @@
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Emplacement du fichier ouvert avec succès",
|
||||
"failed": "Échec de l'ouverture de l'emplacement du fichier"
|
||||
"failed": "Échec de l'ouverture de l'emplacement du fichier",
|
||||
"copied": "Chemin copié dans le presse-papiers: {{path}}",
|
||||
"clipboardFallback": "Chemin: {{path}}"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Version",
|
||||
@@ -753,11 +948,13 @@
|
||||
"addPresetParameter": "Ajouter un paramètre prédéfini...",
|
||||
"strengthMin": "Force Min",
|
||||
"strengthMax": "Force Max",
|
||||
"strengthRange": "Gamme de force",
|
||||
"strength": "Force",
|
||||
"clipStrength": "Force Clip",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Valeur",
|
||||
"add": "Ajouter"
|
||||
"add": "Ajouter",
|
||||
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "Mots-clés",
|
||||
@@ -793,13 +990,84 @@
|
||||
"tabs": {
|
||||
"examples": "Exemples",
|
||||
"description": "Description du modèle",
|
||||
"recipes": "Recipes"
|
||||
"recipes": "Recipes",
|
||||
"versions": "Versions"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Navigation des modèles",
|
||||
"previousWithShortcut": "Modèle précédent (←)",
|
||||
"nextWithShortcut": "Modèle suivant (→)",
|
||||
"noPrevious": "Aucun modèle précédent",
|
||||
"noNext": "Aucun modèle suivant"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "Crédit du créateur requis",
|
||||
"noDerivatives": "Pas de fusion de partage",
|
||||
"noReLicense": "Mêmes autorisations requises",
|
||||
"restrictionsLabel": "Restrictions de licence"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Chargement des images d'exemple...",
|
||||
"description": "Chargement de la description du modèle...",
|
||||
"recipes": "Chargement des recipes...",
|
||||
"examples": "Chargement des exemples..."
|
||||
"examples": "Chargement des exemples...",
|
||||
"versions": "Chargement des versions..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versions du modèle",
|
||||
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
|
||||
"media": {
|
||||
"placeholder": "Aucune prévisualisation"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Version sans nom",
|
||||
"noDetails": "Aucun détail supplémentaire"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Version actuelle",
|
||||
"inLibrary": "Dans la bibliothèque",
|
||||
"newer": "Version plus récente",
|
||||
"ignored": "Ignorée"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Télécharger",
|
||||
"delete": "Supprimer",
|
||||
"ignore": "Ignorer",
|
||||
"unignore": "Ne plus ignorer",
|
||||
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
|
||||
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
|
||||
"viewLocalVersions": "Voir toutes les versions locales",
|
||||
"viewLocalTooltip": "Bientôt disponible"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtre de base",
|
||||
"state": {
|
||||
"showAll": "Toutes les versions",
|
||||
"showSameBase": "Même modèle de base"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "Passer à l'affichage de toutes les versions",
|
||||
"showSameBaseVersions": "Passer à l'affichage des versions du même modèle de base"
|
||||
},
|
||||
"empty": "Aucune version ne correspond au filtre du modèle de base actuel."
|
||||
},
|
||||
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
|
||||
"error": "Échec du chargement des versions.",
|
||||
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
|
||||
"confirm": {
|
||||
"delete": "Supprimer cette version de votre bibliothèque ?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Les mises à jour de ce modèle sont ignorées",
|
||||
"modelResumed": "Suivi des mises à jour repris",
|
||||
"versionIgnored": "Les mises à jour de cette version sont ignorées",
|
||||
"versionUnignored": "Version réactivée",
|
||||
"versionDeleted": "Version supprimée"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,6 +1116,10 @@
|
||||
"title": "Initialisation des statistiques",
|
||||
"message": "Traitement des données de modèle pour les statistiques. Cela peut prendre quelques minutes..."
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
||||
},
|
||||
"tips": {
|
||||
"title": "Astuces et conseils",
|
||||
"civitai": {
|
||||
@@ -906,11 +1178,19 @@
|
||||
"loraFailedToSend": "Échec de l'envoi du LoRA au workflow",
|
||||
"recipeAdded": "Recipe ajoutée au workflow",
|
||||
"recipeReplaced": "Recipe remplacée dans le workflow",
|
||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow"
|
||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
||||
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
|
||||
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
|
||||
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
|
||||
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
|
||||
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
"lora": "LoRA",
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler",
|
||||
"replace": "Remplacer",
|
||||
"append": "Ajouter",
|
||||
"selectTargetNode": "Sélectionner le nœud cible",
|
||||
@@ -919,7 +1199,11 @@
|
||||
"exampleImages": {
|
||||
"opened": "Dossier d'images d'exemple ouvert",
|
||||
"openingFolder": "Ouverture du dossier d'images d'exemple",
|
||||
"failedToOpen": "Échec de l'ouverture du dossier d'images d'exemple"
|
||||
"failedToOpen": "Échec de l'ouverture du dossier d'images d'exemple",
|
||||
"setupRequired": "Stockage d'images d'exemple",
|
||||
"setupDescription": "Pour ajouter des images d'exemple personnalisées, vous devez d'abord définir un emplacement de téléchargement.",
|
||||
"setupUsage": "Ce chemin est utilisé pour les images d'exemple téléchargées et personnalisées.",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -951,6 +1235,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Vérifier les mises à jour",
|
||||
"notificationsTitle": "Notifications",
|
||||
"tabs": {
|
||||
"updates": "Mises à jour",
|
||||
"messages": "Messages"
|
||||
},
|
||||
"updateAvailable": "Mise à jour disponible",
|
||||
"noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.",
|
||||
"currentVersion": "Version actuelle",
|
||||
@@ -963,6 +1252,7 @@
|
||||
"checkingUpdates": "Vérification des mises à jour...",
|
||||
"checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.",
|
||||
"showNotifications": "Afficher les notifications de mise à jour",
|
||||
"latestBadge": "Dernier",
|
||||
"updateProgress": {
|
||||
"preparing": "Préparation de la mise à jour...",
|
||||
"installing": "Installation de la mise à jour...",
|
||||
@@ -982,6 +1272,13 @@
|
||||
"nightly": {
|
||||
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
|
||||
"enable": "Activer les mises à jour nightly"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Messages récents",
|
||||
"empty": "Aucune bannière récente.",
|
||||
"shown": "Affiché {time}",
|
||||
"dismissed": "Ignoré {time}",
|
||||
"active": "Actif"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1061,6 +1358,9 @@
|
||||
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
|
||||
"sendFailed": "Échec de l'envoi de la recipe vers le workflow",
|
||||
"sendError": "Erreur lors de l'envoi de la recipe vers le workflow",
|
||||
"missingCheckpointPath": "Chemin du checkpoint indisponible",
|
||||
"missingCheckpointInfo": "Informations sur le checkpoint manquantes",
|
||||
"downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}",
|
||||
"cannotDelete": "Impossible de supprimer la recipe : ID de recipe manquant",
|
||||
"deleteConfirmationError": "Erreur lors de l'affichage de la confirmation de suppression",
|
||||
"deletedSuccessfully": "Recipe supprimée avec succès",
|
||||
@@ -1101,6 +1401,12 @@
|
||||
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
||||
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
|
||||
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
|
||||
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
|
||||
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
|
||||
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
||||
"bulkUpdatesMissing": "Les {type} sélectionnés ne sont pas liés aux mises à jour Civitai",
|
||||
"bulkUpdatesPartialMissing": "{missing} {type} sélectionnés sans lien Civitai ignorés",
|
||||
"bulkUpdatesFailed": "Échec de la vérification des mises à jour pour les {type} sélectionnés : {message}",
|
||||
"invalidCharactersRemoved": "Caractères invalides supprimés du nom de fichier",
|
||||
"filenameCannotBeEmpty": "Le nom de fichier ne peut pas être vide",
|
||||
"renameFailed": "Échec du renommage du fichier : {message}",
|
||||
@@ -1112,6 +1418,7 @@
|
||||
"verificationCompleteSuccess": "Vérification terminée. Tous les fichiers sont confirmés comme doublons.",
|
||||
"verificationFailed": "Échec de la vérification des hash : {message}",
|
||||
"noTagsToAdd": "Aucun tag à ajouter",
|
||||
"bulkTagsUpdating": "Mise à jour des tags pour {count} modèle(s)...",
|
||||
"tagsAddedSuccessfully": "{tagCount} tag(s) ajouté(s) avec succès à {count} {type}(s)",
|
||||
"tagsReplacedSuccessfully": "Tags remplacés avec succès pour {count} {type}(s) avec {tagCount} tag(s)",
|
||||
"tagsAddFailed": "Échec de l'ajout des tags à {count} modèle(s)",
|
||||
@@ -1125,6 +1432,7 @@
|
||||
"settings": {
|
||||
"loraRootsFailed": "Échec du chargement des racines LoRA : {message}",
|
||||
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
||||
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
|
||||
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
|
||||
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
|
||||
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
|
||||
@@ -1145,7 +1453,26 @@
|
||||
"filters": {
|
||||
"applied": "{message}",
|
||||
"cleared": "Filtres effacés",
|
||||
"noCustomFilterToClear": "Aucun filtre personnalisé à effacer"
|
||||
"noCustomFilterToClear": "Aucun filtre personnalisé à effacer",
|
||||
"noActiveFilters": "Aucun filtre actif à enregistrer"
|
||||
},
|
||||
"presets": {
|
||||
"created": "Préréglage \"{name}\" créé",
|
||||
"deleted": "Préréglage \"{name}\" supprimé",
|
||||
"applied": "Préréglage \"{name}\" appliqué",
|
||||
"overwritten": "Préréglage \"{name}\" remplacé",
|
||||
"restored": "Paramètres par défaut restaurés"
|
||||
},
|
||||
"error": {
|
||||
"presetNameEmpty": "Le nom du préréglage ne peut pas être vide",
|
||||
"presetNameTooLong": "Le nom du préréglage doit contenir au maximum {max} caractères",
|
||||
"presetNameInvalidChars": "Le nom du préréglage contient des caractères invalides",
|
||||
"presetNameExists": "Un préréglage avec ce nom existe déjà",
|
||||
"maxPresetsReached": "Maximum {max} préréglages autorisés. Supprimez-en un pour en ajouter plus.",
|
||||
"presetNotFound": "Préréglage non trouvé",
|
||||
"invalidPreset": "Données de préréglage invalides",
|
||||
"deletePresetFailed": "Échec de la suppression du préréglage",
|
||||
"applyPresetFailed": "Échec de l'application du préréglage"
|
||||
},
|
||||
"downloads": {
|
||||
"imagesCompleted": "Images d'exemple {action} terminées",
|
||||
@@ -1161,7 +1488,7 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "Impossible de charger les mots entraînés",
|
||||
"tooLong": "Le mot-clé ne doit pas dépasser 30 mots",
|
||||
"tooLong": "Le mot-clé ne doit pas dépasser 100 mots",
|
||||
"tooMany": "Maximum 30 mots-clés autorisés",
|
||||
"alreadyExists": "Ce mot-clé existe déjà",
|
||||
"updateSuccess": "Mots-clés mis à jour avec succès",
|
||||
@@ -1210,6 +1537,8 @@
|
||||
"pauseFailed": "Échec de la mise en pause du téléchargement : {error}",
|
||||
"downloadResumed": "Téléchargement repris",
|
||||
"resumeFailed": "Échec de la reprise du téléchargement : {error}",
|
||||
"downloadStopped": "Téléchargement annulé",
|
||||
"stopFailed": "Échec de l'annulation du téléchargement : {error}",
|
||||
"deleted": "Image d'exemple supprimée",
|
||||
"deleteFailed": "Échec de la suppression de l'image d'exemple",
|
||||
"setPreviewFailed": "Échec de la définition de l'image d'aperçu"
|
||||
@@ -1230,6 +1559,8 @@
|
||||
"metadataRefreshed": "Métadonnées actualisées avec succès",
|
||||
"metadataRefreshFailed": "Échec de l'actualisation des métadonnées : {message}",
|
||||
"metadataUpdateComplete": "Mise à jour des métadonnées terminée",
|
||||
"operationCancelled": "Opération annulée par l'utilisateur",
|
||||
"operationCancelledPartial": "Opération annulée. {success} éléments traités.",
|
||||
"metadataFetchFailed": "Échec de la récupération des métadonnées : {message}",
|
||||
"bulkMetadataCompleteAll": "Actualisation réussie de tous les {count} {type}s",
|
||||
"bulkMetadataCompletePartial": "{success} sur {total} {type}s actualisés",
|
||||
@@ -1246,7 +1577,8 @@
|
||||
"bulkMoveFailures": "Échecs de déplacement :\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
||||
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
||||
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}"
|
||||
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
400
locales/he.json
400
locales/he.json
@@ -10,7 +10,8 @@
|
||||
"next": "הבא",
|
||||
"backToTop": "חזור למעלה",
|
||||
"settings": "הגדרות",
|
||||
"help": "עזרה"
|
||||
"help": "עזרה",
|
||||
"add": "הוסף"
|
||||
},
|
||||
"status": {
|
||||
"loading": "טוען...",
|
||||
@@ -32,7 +33,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 בתים",
|
||||
@@ -101,7 +102,12 @@
|
||||
"checkpointNameCopied": "שם Checkpoint הועתק",
|
||||
"toggleBlur": "הפעל/כבה טשטוש",
|
||||
"show": "הצג",
|
||||
"openExampleImages": "פתח תיקיית תמונות דוגמה"
|
||||
"openExampleImages": "פתח תיקיית תמונות דוגמה",
|
||||
"replacePreview": "החלף תצוגה מקדימה",
|
||||
"copyCheckpointName": "העתק שם Checkpoint",
|
||||
"copyEmbeddingName": "העתק שם Embedding",
|
||||
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "תוכן למבוגרים",
|
||||
@@ -115,12 +121,20 @@
|
||||
"updateFailed": "עדכון סטטוס מועדפים נכשל"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "שליחת checkpoint ל-workflow - תכונה שתיושם בעתיד"
|
||||
"checkpointNotImplemented": "שליחת checkpoint ל-workflow - תכונה שתיושם בעתיד",
|
||||
"missingPath": "לא ניתן לקבוע את נתיב המודל לכרטיס זה"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "שגיאה בבדיקת תמונות דוגמה",
|
||||
"missingHash": "חסר מידע hash של המודל.",
|
||||
"noRemoteImagesAvailable": "אין תמונות דוגמה מרוחקות זמינות למודל זה ב-Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "עדכון",
|
||||
"updateAvailable": "עדכון זמין"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "מספר שימושים"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,12 +143,33 @@
|
||||
"missingPath": "הגדר מיקום הורדה לפני הורדת תמונות דוגמה.",
|
||||
"unavailable": "הורדות תמונות דוגמה אינן זמינות עדיין. נסה שוב לאחר שהדף מסיים להיטען."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "בדוק עדכונים",
|
||||
"loading": "בודק עדכונים עבור {type}...",
|
||||
"success": "נמצאו {count} עדכונים עבור {type}",
|
||||
"none": "כל ה-{type} מעודכנים",
|
||||
"error": "נכשל בבדיקת העדכונים עבור {type}: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "נקה תיקיות תמונות דוגמה",
|
||||
"success": "הועברו {count} תיקיות לתיקיית המחוקים",
|
||||
"none": "אין תיקיות תמונות דוגמה שזקוקות לניקוי",
|
||||
"partial": "הניקוי הושלם עם דילוג על {failures} תיקיות",
|
||||
"error": "ניקוי תיקיות תמונות הדוגמה נכשל: {message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "תיקון נתוני מתכונים",
|
||||
"loading": "מתקן נתוני מתכונים...",
|
||||
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||
"error": "תיקון המתכונים נכשל: {message}"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -144,6 +179,7 @@
|
||||
"recipes": "מתכונים",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"misc": "[TODO: Translate] Misc",
|
||||
"statistics": "סטטיסטיקה"
|
||||
},
|
||||
"search": {
|
||||
@@ -152,7 +188,8 @@
|
||||
"loras": "חפש LoRAs...",
|
||||
"recipes": "חפש מתכונים...",
|
||||
"checkpoints": "חפש checkpoints...",
|
||||
"embeddings": "חפש embeddings..."
|
||||
"embeddings": "חפש embeddings...",
|
||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
||||
},
|
||||
"options": "אפשרויות חיפוש",
|
||||
"searchIn": "חפש ב:",
|
||||
@@ -164,13 +201,30 @@
|
||||
"creator": "יוצר",
|
||||
"title": "כותרת מתכון",
|
||||
"loraName": "שם קובץ LoRA",
|
||||
"loraModel": "שם מודל LoRA"
|
||||
"loraModel": "שם מודל LoRA",
|
||||
"prompt": "הנחיה"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "סנן מודלים",
|
||||
"presets": "קביעות מראש",
|
||||
"savePreset": "שמור מסננים פעילים כקביעה מראש חדשה.",
|
||||
"savePresetDisabledActive": "לא ניתן לשמור: קביעה מראש כבר פעילה. שנה מסננים כדי לשמור קביעה מראש חדשה",
|
||||
"savePresetDisabledNoFilters": "בחר מסננים תחילה כדי לשמור כקביעה מראש",
|
||||
"savePresetPrompt": "הזן שם קביעה מראש:",
|
||||
"presetClickTooltip": "לחץ כדי להפעיל קביעה מראש \"{name}\"",
|
||||
"presetDeleteTooltip": "מחק קביעה מראש",
|
||||
"presetDeleteConfirm": "למחוק קביעה מראש \"{name}\"?",
|
||||
"presetDeleteConfirmClick": "לחץ שוב לאישור",
|
||||
"presetOverwriteConfirm": "הפריסט \"{name}\" כבר קיים. לדרוס?",
|
||||
"presetNamePlaceholder": "שם קביעה מראש...",
|
||||
"baseModel": "מודל בסיס",
|
||||
"modelTags": "תגיות (20 המובילות)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
"noTags": "ללא תגיות",
|
||||
"clearAll": "נקה את כל המסננים"
|
||||
},
|
||||
"theme": {
|
||||
@@ -181,6 +235,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
"notifications": "התראות",
|
||||
"support": "תמיכה"
|
||||
}
|
||||
},
|
||||
@@ -192,19 +247,29 @@
|
||||
"label": "פתח תיקיית הגדרות",
|
||||
"tooltip": "פתח את התיקייה שמכילה את settings.json",
|
||||
"success": "תיקיית settings.json נפתחה",
|
||||
"failed": "לא ניתן לפתוח את תיקיית settings.json"
|
||||
"failed": "לא ניתן לפתוח את תיקיית settings.json",
|
||||
"copied": "נתיב ההגדרות הועתק ללוח העריכה: {{path}}",
|
||||
"clipboardFallback": "נתיב ההגדרות: {{path}}"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "סינון תוכן",
|
||||
"videoSettings": "הגדרות וידאו",
|
||||
"layoutSettings": "הגדרות פריסה",
|
||||
"folderSettings": "הגדרות תיקייה",
|
||||
"priorityTags": "תגיות עדיפות",
|
||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||
"exampleImages": "תמונות דוגמה",
|
||||
"updateFlags": "תגי עדכון",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"misc": "שונות",
|
||||
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
|
||||
"storageLocation": "מיקום ההגדרות",
|
||||
"proxySettings": "הגדרות פרוקסי"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "מצב נייד",
|
||||
"locationHelp": "הפעל כדי לשמור את settings.json בתוך המאגר; בטל כדי לשמור אותו בתיקיית ההגדרות של המשתמש."
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "טשטש תוכן NSFW",
|
||||
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
|
||||
@@ -215,6 +280,15 @@
|
||||
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
|
||||
"autoplayOnHoverHelp": "נגן תצוגות מקדימות של וידאו רק בעת ריחוף מעליהן"
|
||||
},
|
||||
"autoOrganizeExclusions": {
|
||||
"label": "יוצא דופן של ארגון אוטומטי",
|
||||
"placeholder": "דוגמה: curated/*, */backups/*; *_temp.safetensors",
|
||||
"help": "דלג על העברת קבצים התואמים לתבניות אלו. הפרד תבניות מרובות בפסיקים או בנקודותיים.",
|
||||
"validation": {
|
||||
"noPatterns": "הזן לפחות תבנית אחת מופרדת בפסיקים או בנקודותיים.",
|
||||
"saveFailed": "לא ניתן לשמור את ההוצאות: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "צפיפות תצוגה",
|
||||
"displayDensityOptions": {
|
||||
@@ -224,35 +298,67 @@
|
||||
},
|
||||
"displayDensityHelp": "בחר כמה כרטיסים להציג בכל שורה:",
|
||||
"displayDensityDetails": {
|
||||
"default": "ברירת מחדל: 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "בינוני: 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "קומפקטי: 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.",
|
||||
"showFolderSidebar": "הצג סרגל צד תיקיות",
|
||||
"showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.",
|
||||
"cardInfoDisplay": "תצוגת מידע בכרטיס",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "תמיד גלוי",
|
||||
"hover": "חשוף בריחוף"
|
||||
},
|
||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "תמיד גלוי: כותרות עליונות ותחתונות תמיד גלויות",
|
||||
"hover": "חשוף בריחוף: כותרות עליונות ותחתונות מופיעות רק בעת ריחוף מעל כרטיס"
|
||||
}
|
||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
||||
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "פתח תמונות דוגמה",
|
||||
"replacePreview": "החלף תצוגה מקדימה"
|
||||
},
|
||||
"modelCardFooterActionHelp": "בחר מה עושה הכפתור בפינה הימנית התחתונה של הכרטיס",
|
||||
"modelNameDisplay": "תצוגת שם מודל",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "שם מודל",
|
||||
"fileName": "שם קובץ"
|
||||
},
|
||||
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "ספרייה פעילה",
|
||||
"activeLibraryHelp": "החלפה בין הספריות המוגדרות תעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף.",
|
||||
"activeLibraryHelp": "החלפה בין הספריות המוגדרות לעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף.",
|
||||
"loadingLibraries": "טוען ספריות...",
|
||||
"noLibraries": "לא הוגדרו ספריות",
|
||||
"defaultLoraRoot": "תיקיית שורש ברירת מחדל של LoRA",
|
||||
"defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות",
|
||||
"defaultCheckpointRoot": "תיקיית שורש ברירת מחדל של Checkpoint",
|
||||
"defaultCheckpointRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של checkpoint להורדות, ייבוא והעברות",
|
||||
"defaultUnetRoot": "תיקיית שורש ברירת מחדל של Diffusion Model",
|
||||
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
||||
"defaultEmbeddingRoot": "תיקיית שורש ברירת מחדל של Embedding",
|
||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||
"noDefault": "אין ברירת מחדל"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "תגיות עדיפות",
|
||||
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "תגיות העדיפות עודכנו.",
|
||||
"saveError": "עדכון תגיות העדיפות נכשל.",
|
||||
"loadingSuggestions": "טוען הצעות...",
|
||||
"validation": {
|
||||
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
|
||||
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
|
||||
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
|
||||
"unknown": "תצורת תגיות העדיפות שגויה."
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "תבניות נתיב הורדה",
|
||||
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
|
||||
@@ -264,8 +370,8 @@
|
||||
"byFirstTag": "לפי תגית ראשונה",
|
||||
"baseModelFirstTag": "מודל בסיס + תגית ראשונה",
|
||||
"baseModelAuthor": "מודל בסיס + יוצר",
|
||||
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
|
||||
"authorFirstTag": "יוצר + תגית ראשונה",
|
||||
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
|
||||
"customTemplate": "תבנית מותאמת אישית"
|
||||
},
|
||||
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
|
||||
@@ -300,6 +406,14 @@
|
||||
"download": "הורד",
|
||||
"restartRequired": "דורש הפעלה מחדש"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "אסטרטגיית תגי עדכון",
|
||||
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
|
||||
"options": {
|
||||
"sameBase": "התאמת עדכונים לפי דגם בסיס",
|
||||
"any": "תוויות לכל עדכון זמין"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח"
|
||||
@@ -359,12 +473,17 @@
|
||||
"dateAsc": "הישן ביותר",
|
||||
"size": "גודל קובץ",
|
||||
"sizeDesc": "הגדול ביותר",
|
||||
"sizeAsc": "הקטן ביותר"
|
||||
"sizeAsc": "הקטן ביותר",
|
||||
"usage": "מספר שימושים",
|
||||
"usageDesc": "הכי הרבה",
|
||||
"usageAsc": "הכי פחות"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מודלים",
|
||||
"quick": "רענון מהיר (מצטבר)",
|
||||
"full": "בנייה מחדש מלאה (שלם)"
|
||||
"quick": "סנכרון שינויים",
|
||||
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
|
||||
"full": "בניית מטמון מחדש",
|
||||
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "אחזר מטא-דאטה מ-Civitai",
|
||||
@@ -385,6 +504,13 @@
|
||||
"favorites": {
|
||||
"title": "הצג מועדפים בלבד",
|
||||
"action": "מועדפים"
|
||||
},
|
||||
"updates": {
|
||||
"title": "הצג רק דגמים עם עדכונים זמינים",
|
||||
"action": "עדכונים",
|
||||
"menuLabel": "הצגת אפשרויות עדכון",
|
||||
"check": "בדוק עדכונים",
|
||||
"checkTooltip": "בדיקת עדכונים עלולה לקחת זמן."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -396,6 +522,7 @@
|
||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||
"copyAll": "העתק את כל התחבירים",
|
||||
"refreshAll": "רענן את כל המטא-דאטה",
|
||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||
"moveAll": "העבר הכל לתיקייה",
|
||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||
"deleteAll": "מחק את כל המודלים",
|
||||
@@ -412,6 +539,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "רענן נתוני Civitai",
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
"relinkCivitai": "קשר מחדש ל-Civitai",
|
||||
"copySyntax": "העתק תחביר LoRA",
|
||||
"copyFilename": "העתק שם קובץ מודל",
|
||||
@@ -423,6 +551,7 @@
|
||||
"replacePreview": "החלף תצוגה מקדימה",
|
||||
"setContentRating": "הגדר דירוג תוכן",
|
||||
"moveToFolder": "העבר לתיקייה",
|
||||
"repairMetadata": "תיקון מטא-דאטה",
|
||||
"excludeModel": "החרג מודל",
|
||||
"deleteModel": "מחק מודל",
|
||||
"shareRecipe": "שתף מתכון",
|
||||
@@ -433,6 +562,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "מתכוני LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "שלח ל-ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "ייבא",
|
||||
@@ -490,10 +622,26 @@
|
||||
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "מיון מתכונים לפי...",
|
||||
"name": "שם",
|
||||
"nameAsc": "א - ת",
|
||||
"nameDesc": "ת - א",
|
||||
"date": "תאריך",
|
||||
"dateDesc": "הכי חדש",
|
||||
"dateAsc": "הכי ישן",
|
||||
"lorasCount": "מספר LoRAs",
|
||||
"lorasCountDesc": "הכי הרבה",
|
||||
"lorasCountAsc": "הכי פחות"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מתכונים"
|
||||
},
|
||||
"filteredByLora": "מסונן לפי LoRA"
|
||||
"filteredByLora": "מסונן לפי LoRA",
|
||||
"favorites": {
|
||||
"title": "הצג מועדפים בלבד",
|
||||
"action": "מועדפים"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "נמצאו {count} קבוצות כפולות",
|
||||
@@ -519,23 +667,54 @@
|
||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "מתקן מטא-דאטה של מתכון...",
|
||||
"success": "מטא-דאטה של מתכון תוקן בהצלחה",
|
||||
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
|
||||
"failed": "תיקון המתכון נכשל: {message}",
|
||||
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "מודלי Checkpoint"
|
||||
"title": "מודלי Checkpoint",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "מודלי Embedding"
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] VAE & Upscaler Models",
|
||||
"modelTypes": {
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "שורש המודלים",
|
||||
"modelRoot": "שורש",
|
||||
"collapseAll": "כווץ את כל התיקיות",
|
||||
"pinSidebar": "נעל סרגל צד",
|
||||
"unpinSidebar": "שחרר סרגל צד",
|
||||
"switchToListView": "עבור לתצוגת רשימה",
|
||||
"switchToTreeView": "עבור לתצוגת עץ",
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה"
|
||||
"switchToTreeView": "תצוגת עץ",
|
||||
"recursiveOn": "חיפוש בתיקיות משנה",
|
||||
"recursiveOff": "חיפוש רק בתיקייה הנוכחית",
|
||||
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "סטטיסטיקה",
|
||||
@@ -610,6 +789,14 @@
|
||||
"downloadedPreview": "תמונת תצוגה מקדימה הורדה",
|
||||
"downloadingFile": "מוריד קובץ {type}",
|
||||
"finalizing": "מסיים הורדה..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "הקובץ הנוכחי:",
|
||||
"downloading": "מוריד: {name}",
|
||||
"transferred": "הורד: {downloaded} / {total}",
|
||||
"transferredSimple": "הורד: {downloaded}",
|
||||
"transferredUnknown": "הורד: --",
|
||||
"speed": "מהירות: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -657,6 +844,12 @@
|
||||
"countMessage": "מודלים יימחקו לצמיתות.",
|
||||
"action": "מחק הכל"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
||||
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
||||
"tip": "רוצים לחלק למנות קטנות? עברו למצב קבוצתי, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
|
||||
"action": "בדוק הכל"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "הוסף תגיות למספר מודלים",
|
||||
"description": "הוסף תגיות ל-",
|
||||
@@ -730,7 +923,9 @@
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "מיקום הקובץ נפתח בהצלחה",
|
||||
"failed": "פתיחת מיקום הקובץ נכשלה"
|
||||
"failed": "פתיחת מיקום הקובץ נכשלה",
|
||||
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||
"clipboardFallback": "נתיב: {{path}}"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "גרסה",
|
||||
@@ -753,11 +948,13 @@
|
||||
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
|
||||
"strengthMin": "חוזק מינימלי",
|
||||
"strengthMax": "חוזק מקסימלי",
|
||||
"strengthRange": "טווח עוצמה",
|
||||
"strength": "חוזק",
|
||||
"clipStrength": "עוצמת CLIP",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "ערך",
|
||||
"add": "הוסף"
|
||||
"add": "הוסף",
|
||||
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "מילות טריגר",
|
||||
@@ -793,13 +990,84 @@
|
||||
"tabs": {
|
||||
"examples": "דוגמאות",
|
||||
"description": "תיאור המודל",
|
||||
"recipes": "מתכונים"
|
||||
"recipes": "מתכונים",
|
||||
"versions": "גרסאות"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "ניווט מודלים",
|
||||
"previousWithShortcut": "המודל הקודם (←)",
|
||||
"nextWithShortcut": "המודל הבא (→)",
|
||||
"noPrevious": "אין מודל קודם זמין",
|
||||
"noNext": "אין מודל נוסף זמין"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "נדרש ייחוס ליוצר",
|
||||
"noDerivatives": "אין שיתוף מיזוגים",
|
||||
"noReLicense": "נדרשות אותן הרשאות",
|
||||
"restrictionsLabel": "הגבלות רישיון"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "טוען תמונות דוגמה...",
|
||||
"description": "טוען תיאור מודל...",
|
||||
"recipes": "טוען מתכונים...",
|
||||
"examples": "טוען דוגמאות..."
|
||||
"examples": "טוען דוגמאות...",
|
||||
"versions": "טוען גרסאות..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "גרסאות המודל",
|
||||
"copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.",
|
||||
"media": {
|
||||
"placeholder": "אין תצוגה מקדימה"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "גרסה ללא שם",
|
||||
"noDetails": "אין פרטים נוספים"
|
||||
},
|
||||
"badges": {
|
||||
"current": "גרסה נוכחית",
|
||||
"inLibrary": "בספרייה",
|
||||
"newer": "גרסה חדשה יותר",
|
||||
"ignored": "התעלם"
|
||||
},
|
||||
"actions": {
|
||||
"download": "הורדה",
|
||||
"delete": "מחיקה",
|
||||
"ignore": "התעלם",
|
||||
"unignore": "בטל התעלמות",
|
||||
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
|
||||
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
|
||||
"viewLocalVersions": "הצג את כל הגרסאות המקומיות",
|
||||
"viewLocalTooltip": "יגיע בקרוב"
|
||||
},
|
||||
"filters": {
|
||||
"label": "מסנן בסיס",
|
||||
"state": {
|
||||
"showAll": "כל הגרסאות",
|
||||
"showSameBase": "אותו מודל בסיס"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "החלף להצגת כל הגרסאות",
|
||||
"showSameBaseVersions": "החלף להצגת גרסאות עם אותו מודל בסיס"
|
||||
},
|
||||
"empty": "אין גרסאות התואמות את המסנן של מודל הבסיס הנוכחי."
|
||||
},
|
||||
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
|
||||
"error": "טעינת הגרסאות נכשלה.",
|
||||
"missingModelId": "למודל זה אין מזהה מודל של Civitai.",
|
||||
"confirm": {
|
||||
"delete": "למחוק גרסה זו מהספרייה שלך?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "העדכונים עבור מודל זה נוגבו",
|
||||
"modelResumed": "מעקב העדכונים חודש",
|
||||
"versionIgnored": "העדכונים עבור גרסה זו נוגבו",
|
||||
"versionUnignored": "הגרסה הופעלה מחדש",
|
||||
"versionDeleted": "הגרסה נמחקה"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,6 +1116,10 @@
|
||||
"title": "מאתחל סטטיסטיקה",
|
||||
"message": "מעבד נתוני מודלים עבור סטטיסטיקה. זה עשוי לקחת מספר דקות..."
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
||||
},
|
||||
"tips": {
|
||||
"title": "טיפים וטריקים",
|
||||
"civitai": {
|
||||
@@ -906,11 +1178,19 @@
|
||||
"loraFailedToSend": "שליחת LoRA ל-workflow נכשלה",
|
||||
"recipeAdded": "מתכון נוסף ל-workflow",
|
||||
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
||||
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה"
|
||||
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
||||
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
|
||||
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
|
||||
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
|
||||
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
|
||||
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||
"noTargetNodeSelected": "לא נבחר צומת יעד"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "מתכון",
|
||||
"lora": "LoRA",
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler",
|
||||
"replace": "החלף",
|
||||
"append": "הוסף",
|
||||
"selectTargetNode": "בחר צומת יעד",
|
||||
@@ -919,7 +1199,11 @@
|
||||
"exampleImages": {
|
||||
"opened": "תיקיית תמונות הדוגמה נפתחה",
|
||||
"openingFolder": "פותח תיקיית תמונות דוגמה",
|
||||
"failedToOpen": "פתיחת תיקיית תמונות הדוגמה נכשלה"
|
||||
"failedToOpen": "פתיחת תיקיית תמונות הדוגמה נכשלה",
|
||||
"setupRequired": "אחסון תמונות דוגמה",
|
||||
"setupDescription": "כדי להוסיף תמונות דוגמה מותאמות אישית, עליך קודם להגדיר מיקום הורדה.",
|
||||
"setupUsage": "נתיב זה משמש הן עבור תמונות דוגמה שהורדו והן עבור תמונות מותאמות אישית.",
|
||||
"openSettings": "פתח הגדרות"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -951,6 +1235,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "בדוק עדכונים",
|
||||
"notificationsTitle": "מרכז התראות",
|
||||
"tabs": {
|
||||
"updates": "עדכונים",
|
||||
"messages": "הודעות"
|
||||
},
|
||||
"updateAvailable": "עדכון זמין",
|
||||
"noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.",
|
||||
"currentVersion": "גרסה נוכחית",
|
||||
@@ -963,6 +1252,7 @@
|
||||
"checkingUpdates": "בודק עדכונים...",
|
||||
"checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.",
|
||||
"showNotifications": "הצג התראות עדכון",
|
||||
"latestBadge": "עדכן",
|
||||
"updateProgress": {
|
||||
"preparing": "מכין עדכון...",
|
||||
"installing": "מתקין עדכון...",
|
||||
@@ -982,6 +1272,13 @@
|
||||
"nightly": {
|
||||
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
|
||||
"enable": "הפעל עדכונים ליליים"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "הודעות אחרונות",
|
||||
"empty": "אין כרגע באנרים אחרונים.",
|
||||
"shown": "הוצג {time}",
|
||||
"dismissed": "הוסר {time}",
|
||||
"active": "פעיל"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1061,6 +1358,9 @@
|
||||
"cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון",
|
||||
"sendFailed": "שליחת המתכון ל-workflow נכשלה",
|
||||
"sendError": "שגיאה בשליחת המתכון ל-workflow",
|
||||
"missingCheckpointPath": "נתיב ה-checkpoint אינו זמין",
|
||||
"missingCheckpointInfo": "חסרים פרטי checkpoint",
|
||||
"downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}",
|
||||
"cannotDelete": "לא ניתן למחוק מתכון: חסר מזהה מתכון",
|
||||
"deleteConfirmationError": "שגיאה בהצגת אישור המחיקה",
|
||||
"deletedSuccessfully": "המתכון נמחק בהצלחה",
|
||||
@@ -1101,6 +1401,12 @@
|
||||
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
||||
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
||||
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
|
||||
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
|
||||
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
|
||||
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
|
||||
"bulkUpdatesMissing": "ה-{type} שנבחרו אינם מקושרים לעדכוני Civitai",
|
||||
"bulkUpdatesPartialMissing": "דילג על {missing} {type} שנבחרו ללא קישור Civitai",
|
||||
"bulkUpdatesFailed": "בדיקת העדכונים עבור {type} שנבחרו נכשלה: {message}",
|
||||
"invalidCharactersRemoved": "תווים לא חוקיים הוסרו משם הקובץ",
|
||||
"filenameCannotBeEmpty": "שם הקובץ אינו יכול להיות ריק",
|
||||
"renameFailed": "שינוי שם הקובץ נכשל: {message}",
|
||||
@@ -1112,6 +1418,7 @@
|
||||
"verificationCompleteSuccess": "האימות הושלם. כל הקבצים אושרו ככפולים.",
|
||||
"verificationFailed": "אימות ה-hashes נכשל: {message}",
|
||||
"noTagsToAdd": "אין תגיות להוספה",
|
||||
"bulkTagsUpdating": "מעדכן תגיות עבור {count} מודלים...",
|
||||
"tagsAddedSuccessfully": "נוספו בהצלחה {tagCount} תגית(ות) ל-{count} {type}(ים)",
|
||||
"tagsReplacedSuccessfully": "הוחלפו בהצלחה תגיות עבור {count} {type}(ים) ב-{tagCount} תגית(ות)",
|
||||
"tagsAddFailed": "הוספת תגיות ל-{count} מודל(ים) נכשלה",
|
||||
@@ -1125,6 +1432,7 @@
|
||||
"settings": {
|
||||
"loraRootsFailed": "טעינת שורשי LoRA נכשלה: {message}",
|
||||
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
||||
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
|
||||
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
||||
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count} מיפוי{plural})",
|
||||
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
||||
@@ -1145,7 +1453,26 @@
|
||||
"filters": {
|
||||
"applied": "{message}",
|
||||
"cleared": "המסננים נוקו",
|
||||
"noCustomFilterToClear": "אין מסנן מותאם אישית לניקוי"
|
||||
"noCustomFilterToClear": "אין מסנן מותאם אישית לניקוי",
|
||||
"noActiveFilters": "אין מסננים פעילים לשמירה"
|
||||
},
|
||||
"presets": {
|
||||
"created": "קביעה מראש \"{name}\" נוצרה",
|
||||
"deleted": "קביעה מראש \"{name}\" נמחקה",
|
||||
"applied": "קביעה מראש \"{name}\" הופעלה",
|
||||
"overwritten": "קביעה מראש \"{name}\" נדרסה",
|
||||
"restored": "ברירות המחדל שוחזרו"
|
||||
},
|
||||
"error": {
|
||||
"presetNameEmpty": "שם קביעה מראש לא יכול להיות ריק",
|
||||
"presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות",
|
||||
"presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים",
|
||||
"presetNameExists": "קביעה מראש עם שם זה כבר קיימת",
|
||||
"maxPresetsReached": "מותר מקסימום {max} קביעות מראש. מחק אחת כדי להוסיף עוד.",
|
||||
"presetNotFound": "קביעה מראש לא נמצאה",
|
||||
"invalidPreset": "נתוני קביעה מראש לא חוקיים",
|
||||
"deletePresetFailed": "מחיקת קביעה מראש נכשלה",
|
||||
"applyPresetFailed": "הפעלת קביעה מראש נכשלה"
|
||||
},
|
||||
"downloads": {
|
||||
"imagesCompleted": "{action} תמונות הדוגמה הושלם",
|
||||
@@ -1161,7 +1488,7 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "לא ניתן היה לטעון מילים מאומנות",
|
||||
"tooLong": "מילת טריגר לא תעלה על 30 מילים",
|
||||
"tooLong": "מילת טריגר לא תעלה על 100 מילים",
|
||||
"tooMany": "מותרות עד 30 מילות טריגר",
|
||||
"alreadyExists": "מילת טריגר זו כבר קיימת",
|
||||
"updateSuccess": "מילות הטריגר עודכנו בהצלחה",
|
||||
@@ -1210,6 +1537,8 @@
|
||||
"pauseFailed": "השהיית ההורדה נכשלה: {error}",
|
||||
"downloadResumed": "ההורדה חודשה",
|
||||
"resumeFailed": "חידוש ההורדה נכשל: {error}",
|
||||
"downloadStopped": "ההורדה בוטלה",
|
||||
"stopFailed": "נכשל בביטול ההורדה: {error}",
|
||||
"deleted": "תמונת הדוגמה נמחקה",
|
||||
"deleteFailed": "מחיקת תמונת הדוגמה נכשלה",
|
||||
"setPreviewFailed": "הגדרת תמונת התצוגה המקדימה נכשלה"
|
||||
@@ -1230,6 +1559,8 @@
|
||||
"metadataRefreshed": "המטא-דאטה רועננה בהצלחה",
|
||||
"metadataRefreshFailed": "רענון המטא-דאטה נכשל: {message}",
|
||||
"metadataUpdateComplete": "עדכון המטא-דאטה הושלם",
|
||||
"operationCancelled": "הפעולה בוטלה על ידי המשתמש",
|
||||
"operationCancelledPartial": "הפעולה בוטלה. {success} פריטים עובדו.",
|
||||
"metadataFetchFailed": "אחזור המטא-דאטה נכשל: {message}",
|
||||
"bulkMetadataCompleteAll": "רועננו בהצלחה כל {count} ה-{type}s",
|
||||
"bulkMetadataCompletePartial": "רועננו {success} מתוך {total} {type}s",
|
||||
@@ -1246,7 +1577,8 @@
|
||||
"bulkMoveFailures": "העברות שנכשלו:\n{failures}",
|
||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}"
|
||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
396
locales/ja.json
396
locales/ja.json
@@ -10,7 +10,8 @@
|
||||
"next": "次へ",
|
||||
"backToTop": "トップに戻る",
|
||||
"settings": "設定",
|
||||
"help": "ヘルプ"
|
||||
"help": "ヘルプ",
|
||||
"add": "追加"
|
||||
},
|
||||
"status": {
|
||||
"loading": "読み込み中...",
|
||||
@@ -32,7 +33,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0バイト",
|
||||
@@ -101,7 +102,12 @@
|
||||
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
||||
"toggleBlur": "ぼかしの切り替え",
|
||||
"show": "表示",
|
||||
"openExampleImages": "例画像フォルダを開く"
|
||||
"openExampleImages": "例画像フォルダを開く",
|
||||
"replacePreview": "プレビューを置換",
|
||||
"copyCheckpointName": "checkpoint名をコピー",
|
||||
"copyEmbeddingName": "embedding名をコピー",
|
||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "成人向けコンテンツ",
|
||||
@@ -115,12 +121,20 @@
|
||||
"updateFailed": "お気に入り状態の更新に失敗しました"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能"
|
||||
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能",
|
||||
"missingPath": "このカードのモデルパスを特定できません"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "例画像の確認中にエラーが発生しました",
|
||||
"missingHash": "モデルハッシュ情報がありません。",
|
||||
"noRemoteImagesAvailable": "このモデルのCivitaiでのリモート例画像は利用できません"
|
||||
},
|
||||
"badges": {
|
||||
"update": "アップデート",
|
||||
"updateAvailable": "アップデートがあります"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用回数"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,12 +143,33 @@
|
||||
"missingPath": "例画像をダウンロードする前にダウンロード場所を設定してください。",
|
||||
"unavailable": "例画像のダウンロードはまだ利用できません。ページの読み込みが完了してから再度お試しください。"
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "アップデートを確認",
|
||||
"loading": "{type} のアップデートを確認中…",
|
||||
"success": "{type} のアップデートが {count} 件見つかりました",
|
||||
"none": "すべての {type} は最新です",
|
||||
"error": "{type} のアップデート確認に失敗しました: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "例画像フォルダをクリーンアップ",
|
||||
"success": "{count} 個のフォルダを削除フォルダに移動しました",
|
||||
"none": "クリーンアップが必要な例画像フォルダはありません",
|
||||
"partial": "クリーンアップが完了しましたが、{failures} 個のフォルダはスキップされました",
|
||||
"error": "例画像フォルダのクリーンアップに失敗しました:{message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "レシピデータの修復",
|
||||
"loading": "レシピデータを修復中...",
|
||||
"success": "{count} 件のレシピを正常に修復しました。",
|
||||
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||
"error": "レシピの修復に失敗しました: {message}"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -144,6 +179,7 @@
|
||||
"recipes": "レシピ",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"misc": "[TODO: Translate] Misc",
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
@@ -152,7 +188,8 @@
|
||||
"loras": "LoRAを検索...",
|
||||
"recipes": "レシピを検索...",
|
||||
"checkpoints": "checkpointを検索...",
|
||||
"embeddings": "embeddingを検索..."
|
||||
"embeddings": "embeddingを検索...",
|
||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
||||
},
|
||||
"options": "検索オプション",
|
||||
"searchIn": "検索対象:",
|
||||
@@ -164,13 +201,30 @@
|
||||
"creator": "作成者",
|
||||
"title": "レシピタイトル",
|
||||
"loraName": "LoRAファイル名",
|
||||
"loraModel": "LoRAモデル名"
|
||||
"loraModel": "LoRAモデル名",
|
||||
"prompt": "プロンプト"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "モデルをフィルタ",
|
||||
"presets": "プリセット",
|
||||
"savePreset": "現在のアクティブフィルタを新しいプリセットとして保存。",
|
||||
"savePresetDisabledActive": "保存できません:プリセットがすでにアクティブです。フィルタを変更して新しいプリセットを保存してください",
|
||||
"savePresetDisabledNoFilters": "先にフィルタを選択してからプリセットとして保存",
|
||||
"savePresetPrompt": "プリセット名を入力:",
|
||||
"presetClickTooltip": "プリセット \"{name}\" を適用するにはクリック",
|
||||
"presetDeleteTooltip": "プリセットを削除",
|
||||
"presetDeleteConfirm": "プリセット \"{name}\" を削除しますか?",
|
||||
"presetDeleteConfirmClick": "もう一度クリックして確認",
|
||||
"presetOverwriteConfirm": "プリセット「{name}」は既に存在します。上書きしますか?",
|
||||
"presetNamePlaceholder": "プリセット名...",
|
||||
"baseModel": "ベースモデル",
|
||||
"modelTags": "タグ(上位20)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
"noTags": "タグなし",
|
||||
"clearAll": "すべてのフィルタをクリア"
|
||||
},
|
||||
"theme": {
|
||||
@@ -181,6 +235,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "更新確認",
|
||||
"notifications": "通知",
|
||||
"support": "サポート"
|
||||
}
|
||||
},
|
||||
@@ -192,19 +247,29 @@
|
||||
"label": "設定フォルダーを開く",
|
||||
"tooltip": "settings.json を含むフォルダーを開きます",
|
||||
"success": "settings.json フォルダーを開きました",
|
||||
"failed": "settings.json フォルダーを開けませんでした"
|
||||
"failed": "settings.json フォルダーを開けませんでした",
|
||||
"copied": "設定パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "設定パス: {{path}}"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "コンテンツフィルタリング",
|
||||
"videoSettings": "動画設定",
|
||||
"layoutSettings": "レイアウト設定",
|
||||
"folderSettings": "フォルダ設定",
|
||||
"priorityTags": "優先タグ",
|
||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||
"exampleImages": "例画像",
|
||||
"updateFlags": "アップデートフラグ",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"misc": "その他",
|
||||
"metadataArchive": "メタデータアーカイブデータベース",
|
||||
"storageLocation": "設定の場所",
|
||||
"proxySettings": "プロキシ設定"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "ポータブルモード",
|
||||
"locationHelp": "有効にすると settings.json をリポジトリ内に保持し、無効にするとユーザー設定ディレクトリに格納します。"
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
||||
"blurNsfwContentHelp": "成人向け(NSFW)コンテンツのプレビュー画像をぼかします",
|
||||
@@ -215,6 +280,15 @@
|
||||
"autoplayOnHover": "ホバー時に動画を自動再生",
|
||||
"autoplayOnHoverHelp": "動画プレビューはホバー時にのみ再生されます"
|
||||
},
|
||||
"autoOrganizeExclusions": {
|
||||
"label": "自動整理除外設定",
|
||||
"placeholder": "例: curated/*, */backups/*; *_temp.safetensors",
|
||||
"help": "これらのワイルドカードパターンに一致するファイルの移動をスキップします。複数のパターンはカンマまたはセミコロンで区切ってください。",
|
||||
"validation": {
|
||||
"noPatterns": "カンマまたはセミコロンで区切られた少なくとも1つのパターンを入力してください。",
|
||||
"saveFailed": "除外設定を保存できませんでした: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "表示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -224,21 +298,31 @@
|
||||
},
|
||||
"displayDensityHelp": "1行に表示するカード数を選択:",
|
||||
"displayDensityDetails": {
|
||||
"default": "デフォルト:5(1080p)、6(2K)、8(4K)",
|
||||
"medium": "中:6(1080p)、7(2K)、9(4K)",
|
||||
"compact": "コンパクト:7(1080p)、8(2K)、10(4K)"
|
||||
"default": "5(1080p)、6(2K)、8(4K)",
|
||||
"medium": "6(1080p)、7(2K)、9(4K)",
|
||||
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||
},
|
||||
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
|
||||
"showFolderSidebar": "フォルダサイドバーを表示",
|
||||
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
|
||||
"cardInfoDisplay": "カード情報表示",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "常に表示",
|
||||
"hover": "ホバー時に表示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "常に表示:ヘッダーとフッターが常に表示されます",
|
||||
"hover": "ホバー時に表示:カードにホバーしたときのみヘッダーとフッターが表示されます"
|
||||
}
|
||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "例画像を開く",
|
||||
"replacePreview": "プレビューを置換"
|
||||
},
|
||||
"modelCardFooterActionHelp": "カード右下のボタンが何をするかを選択します",
|
||||
"modelNameDisplay": "モデル名表示",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "モデル名",
|
||||
"fileName": "ファイル名"
|
||||
},
|
||||
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "アクティブライブラリ",
|
||||
@@ -249,10 +333,32 @@
|
||||
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
|
||||
"defaultCheckpointRoot": "デフォルトCheckpointルート",
|
||||
"defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定",
|
||||
"defaultUnetRoot": "デフォルトDiffusion Modelルート",
|
||||
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||
"defaultEmbeddingRoot": "デフォルトEmbeddingルート",
|
||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||
"noDefault": "デフォルトなし"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "優先タグ",
|
||||
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "優先タグのヘルプを開く",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "チェックポイント",
|
||||
"embedding": "埋め込み"
|
||||
},
|
||||
"saveSuccess": "優先タグを更新しました。",
|
||||
"saveError": "優先タグの更新に失敗しました。",
|
||||
"loadingSuggestions": "候補を読み込み中...",
|
||||
"validation": {
|
||||
"missingClosingParen": "エントリ {index} に閉じ括弧がありません。",
|
||||
"missingCanonical": "エントリ {index} には正規タグ名を含める必要があります。",
|
||||
"duplicateCanonical": "正規タグ \"{tag}\" が複数回登場しています。",
|
||||
"unknown": "無効な優先タグ設定です。"
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "ダウンロードパステンプレート",
|
||||
"help": "Civitaiからダウンロードする際の異なるモデルタイプのフォルダ構造を設定します。",
|
||||
@@ -300,6 +406,14 @@
|
||||
"download": "ダウンロード",
|
||||
"restartRequired": "再起動が必要"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "アップデートフラグの表示戦略",
|
||||
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
|
||||
"options": {
|
||||
"sameBase": "ベースモデルで更新をマッチ",
|
||||
"any": "利用可能な更新すべてを表示"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
|
||||
@@ -359,12 +473,17 @@
|
||||
"dateAsc": "古い順",
|
||||
"size": "ファイルサイズ",
|
||||
"sizeDesc": "大きい順",
|
||||
"sizeAsc": "小さい順"
|
||||
"sizeAsc": "小さい順",
|
||||
"usage": "使用回数",
|
||||
"usageDesc": "多い",
|
||||
"usageAsc": "少ない"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "モデルリストを更新",
|
||||
"quick": "クイック更新(増分)",
|
||||
"full": "完全再構築(完全)"
|
||||
"quick": "変更を同期",
|
||||
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Civitaiからメタデータを取得",
|
||||
@@ -385,6 +504,13 @@
|
||||
"favorites": {
|
||||
"title": "お気に入りのみ表示",
|
||||
"action": "お気に入り"
|
||||
},
|
||||
"updates": {
|
||||
"title": "アップデート可能なモデルのみ表示",
|
||||
"action": "アップデート",
|
||||
"menuLabel": "更新オプションを表示",
|
||||
"check": "アップデートを確認",
|
||||
"checkTooltip": "確認には時間がかかる場合があります。"
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -396,6 +522,7 @@
|
||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||
"copyAll": "すべての構文をコピー",
|
||||
"refreshAll": "すべてのメタデータを更新",
|
||||
"checkUpdates": "選択項目の更新を確認",
|
||||
"moveAll": "すべてをフォルダに移動",
|
||||
"autoOrganize": "自動整理を実行",
|
||||
"deleteAll": "すべてのモデルを削除",
|
||||
@@ -412,6 +539,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitaiデータを更新",
|
||||
"checkUpdates": "更新確認",
|
||||
"relinkCivitai": "Civitaiに再リンク",
|
||||
"copySyntax": "LoRA構文をコピー",
|
||||
"copyFilename": "モデルファイル名をコピー",
|
||||
@@ -423,6 +551,7 @@
|
||||
"replacePreview": "プレビューを置換",
|
||||
"setContentRating": "コンテンツレーティングを設定",
|
||||
"moveToFolder": "フォルダに移動",
|
||||
"repairMetadata": "メタデータを修復",
|
||||
"excludeModel": "モデルを除外",
|
||||
"deleteModel": "モデルを削除",
|
||||
"shareRecipe": "レシピを共有",
|
||||
@@ -433,6 +562,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRAレシピ",
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUIへ送信"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "インポート",
|
||||
@@ -490,10 +622,26 @@
|
||||
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "レシピの並び替え...",
|
||||
"name": "名前",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "日付",
|
||||
"dateDesc": "新しい順",
|
||||
"dateAsc": "古い順",
|
||||
"lorasCount": "LoRA数",
|
||||
"lorasCountDesc": "多い順",
|
||||
"lorasCountAsc": "少ない順"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "レシピリストを更新"
|
||||
},
|
||||
"filteredByLora": "LoRAでフィルタ済み"
|
||||
"filteredByLora": "LoRAでフィルタ済み",
|
||||
"favorites": {
|
||||
"title": "お気に入りのみ表示",
|
||||
"action": "お気に入り"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "{count} 個の重複グループが見つかりました",
|
||||
@@ -519,23 +667,54 @@
|
||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "レシピのメタデータを修復中...",
|
||||
"success": "レシピのメタデータが正常に修復されました",
|
||||
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
|
||||
"failed": "レシピの修復に失敗しました: {message}",
|
||||
"missingId": "レシピを修復できません: レシピIDがありません"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "Checkpointモデル"
|
||||
"title": "Checkpointモデル",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "{otherType} フォルダに移動"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "Embeddingモデル"
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] VAE & Upscaler Models",
|
||||
"modelTypes": {
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "モデルルート",
|
||||
"modelRoot": "ルート",
|
||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||
"pinSidebar": "サイドバーを固定",
|
||||
"unpinSidebar": "サイドバーの固定を解除",
|
||||
"switchToListView": "リストビューに切り替え",
|
||||
"switchToTreeView": "ツリービューに切り替え",
|
||||
"collapseAllDisabled": "リストビューでは利用できません"
|
||||
"switchToTreeView": "ツリー表示に切り替え",
|
||||
"recursiveOn": "サブフォルダーを検索",
|
||||
"recursiveOff": "現在のフォルダーのみを検索",
|
||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||
"collapseAllDisabled": "リストビューでは利用できません",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "統計",
|
||||
@@ -610,6 +789,14 @@
|
||||
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
||||
"downloadingFile": "{type}ファイルをダウンロード中",
|
||||
"finalizing": "ダウンロードを完了中..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "現在のファイル:",
|
||||
"downloading": "ダウンロード中: {name}",
|
||||
"transferred": "ダウンロード済み: {downloaded} / {total}",
|
||||
"transferredSimple": "ダウンロード済み: {downloaded}",
|
||||
"transferredUnknown": "ダウンロード済み: --",
|
||||
"speed": "速度: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -657,6 +844,12 @@
|
||||
"countMessage": "モデルが完全に削除されます。",
|
||||
"action": "すべて削除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "すべての{type}の更新を確認しますか?",
|
||||
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||
"tip": "少しずつ確認したい場合はバルクモードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
|
||||
"action": "すべて確認"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "複数モデルにタグを追加",
|
||||
"description": "タグを追加するモデル:",
|
||||
@@ -730,7 +923,9 @@
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "ファイルの場所を正常に開きました",
|
||||
"failed": "ファイルの場所を開くのに失敗しました"
|
||||
"failed": "ファイルの場所を開くのに失敗しました",
|
||||
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "パス: {{path}}"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "バージョン",
|
||||
@@ -753,11 +948,13 @@
|
||||
"addPresetParameter": "プリセットパラメータを追加...",
|
||||
"strengthMin": "強度最小",
|
||||
"strengthMax": "強度最大",
|
||||
"strengthRange": "強度範囲",
|
||||
"strength": "強度",
|
||||
"clipStrength": "クリップ強度",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "値",
|
||||
"add": "追加"
|
||||
"add": "追加",
|
||||
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "トリガーワード",
|
||||
@@ -793,13 +990,84 @@
|
||||
"tabs": {
|
||||
"examples": "例",
|
||||
"description": "モデル説明",
|
||||
"recipes": "レシピ"
|
||||
"recipes": "レシピ",
|
||||
"versions": "バージョン"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "モデルナビゲーション",
|
||||
"previousWithShortcut": "前のモデル(←)",
|
||||
"nextWithShortcut": "次のモデル(→)",
|
||||
"noPrevious": "前のモデルがありません",
|
||||
"noNext": "次のモデルがありません"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "作成者のクレジットが必要",
|
||||
"noDerivatives": "共有マージ不可",
|
||||
"noReLicense": "同じ権限が必要",
|
||||
"restrictionsLabel": "ライセンス制限"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "例画像を読み込み中...",
|
||||
"description": "モデル説明を読み込み中...",
|
||||
"recipes": "レシピを読み込み中...",
|
||||
"examples": "例を読み込み中..."
|
||||
"examples": "例を読み込み中...",
|
||||
"versions": "バージョンを読み込み中..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "モデルバージョン",
|
||||
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
|
||||
"media": {
|
||||
"placeholder": "プレビューなし"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "名前のないバージョン",
|
||||
"noDetails": "追加情報なし"
|
||||
},
|
||||
"badges": {
|
||||
"current": "現在のバージョン",
|
||||
"inLibrary": "ライブラリにあります",
|
||||
"newer": "新しいバージョン",
|
||||
"ignored": "無視中"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"delete": "削除",
|
||||
"ignore": "無視",
|
||||
"unignore": "無視を解除",
|
||||
"resumeModelUpdates": "このモデルの更新を再開",
|
||||
"ignoreModelUpdates": "このモデルの更新を無視",
|
||||
"viewLocalVersions": "ローカルの全バージョンを表示",
|
||||
"viewLocalTooltip": "近日対応予定"
|
||||
},
|
||||
"filters": {
|
||||
"label": "ベースフィルター",
|
||||
"state": {
|
||||
"showAll": "すべてのバージョン",
|
||||
"showSameBase": "同じベース"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "すべてのバージョンを表示する",
|
||||
"showSameBaseVersions": "同じベースモデルのバージョンのみ表示する"
|
||||
},
|
||||
"empty": "現在のベースモデルフィルターに一致するバージョンがありません。"
|
||||
},
|
||||
"empty": "このモデルにはまだバージョン履歴がありません。",
|
||||
"error": "バージョンの読み込みに失敗しました。",
|
||||
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
|
||||
"confirm": {
|
||||
"delete": "このバージョンをライブラリから削除しますか?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "このモデルの更新は無視されます",
|
||||
"modelResumed": "更新の監視を再開しました",
|
||||
"versionIgnored": "このバージョンの更新は無視されます",
|
||||
"versionUnignored": "バージョンを再度有効にしました",
|
||||
"versionDeleted": "バージョンを削除しました"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,6 +1116,10 @@
|
||||
"title": "統計を初期化中",
|
||||
"message": "統計用のモデルデータを処理中。数分かかる場合があります..."
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
||||
},
|
||||
"tips": {
|
||||
"title": "ヒント&コツ",
|
||||
"civitai": {
|
||||
@@ -906,11 +1178,19 @@
|
||||
"loraFailedToSend": "LoRAをワークフローに送信できませんでした",
|
||||
"recipeAdded": "レシピがワークフローに追加されました",
|
||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした"
|
||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
|
||||
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
|
||||
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
|
||||
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
|
||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "レシピ",
|
||||
"lora": "LoRA",
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler",
|
||||
"replace": "置換",
|
||||
"append": "追加",
|
||||
"selectTargetNode": "ターゲットノードを選択",
|
||||
@@ -919,7 +1199,11 @@
|
||||
"exampleImages": {
|
||||
"opened": "例画像フォルダが開かれました",
|
||||
"openingFolder": "例画像フォルダを開いています",
|
||||
"failedToOpen": "例画像フォルダを開くのに失敗しました"
|
||||
"failedToOpen": "例画像フォルダを開くのに失敗しました",
|
||||
"setupRequired": "例画像ストレージ",
|
||||
"setupDescription": "カスタム例画像を追加するには、まずダウンロード場所を設定する必要があります。",
|
||||
"setupUsage": "このパスは、ダウンロードした例画像とカスタム画像の両方に使用されます。",
|
||||
"openSettings": "設定を開く"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -951,6 +1235,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "更新確認",
|
||||
"notificationsTitle": "通知センター",
|
||||
"tabs": {
|
||||
"updates": "更新",
|
||||
"messages": "メッセージ"
|
||||
},
|
||||
"updateAvailable": "更新が利用可能",
|
||||
"noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。",
|
||||
"currentVersion": "現在のバージョン",
|
||||
@@ -963,6 +1252,7 @@
|
||||
"checkingUpdates": "更新を確認中...",
|
||||
"checkingMessage": "最新バージョンを確認しています。お待ちください。",
|
||||
"showNotifications": "更新通知を表示",
|
||||
"latestBadge": "最新",
|
||||
"updateProgress": {
|
||||
"preparing": "更新を準備中...",
|
||||
"installing": "更新をインストール中...",
|
||||
@@ -982,6 +1272,13 @@
|
||||
"nightly": {
|
||||
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
|
||||
"enable": "ナイトリー更新を有効にする"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最近の通知",
|
||||
"empty": "最近のバナーはありません。",
|
||||
"shown": "{time} に表示",
|
||||
"dismissed": "{time} に非表示",
|
||||
"active": "アクティブ"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1061,6 +1358,9 @@
|
||||
"cannotSend": "レシピを送信できません:レシピIDがありません",
|
||||
"sendFailed": "レシピのワークフローへの送信に失敗しました",
|
||||
"sendError": "レシピのワークフロー送信エラー",
|
||||
"missingCheckpointPath": "チェックポイントのパスがありません",
|
||||
"missingCheckpointInfo": "チェックポイント情報が不足しています",
|
||||
"downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}",
|
||||
"cannotDelete": "レシピを削除できません:レシピIDがありません",
|
||||
"deleteConfirmationError": "削除確認の表示中にエラーが発生しました",
|
||||
"deletedSuccessfully": "レシピが正常に削除されました",
|
||||
@@ -1101,6 +1401,12 @@
|
||||
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
||||
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
||||
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
||||
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
||||
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
||||
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
||||
"bulkUpdatesMissing": "選択された{type}はCivitaiの更新にリンクされていません",
|
||||
"bulkUpdatesPartialMissing": "Civitaiリンクがない{missing} 件の{type}をスキップしました",
|
||||
"bulkUpdatesFailed": "選択された{type}の更新確認に失敗しました: {message}",
|
||||
"invalidCharactersRemoved": "ファイル名から無効な文字が削除されました",
|
||||
"filenameCannotBeEmpty": "ファイル名を空にすることはできません",
|
||||
"renameFailed": "ファイル名の変更に失敗しました:{message}",
|
||||
@@ -1112,6 +1418,7 @@
|
||||
"verificationCompleteSuccess": "検証完了。すべてのファイルが重複であることが確認されました。",
|
||||
"verificationFailed": "ハッシュの検証に失敗しました:{message}",
|
||||
"noTagsToAdd": "追加するタグがありません",
|
||||
"bulkTagsUpdating": "{count} 個のモデルのタグを更新しています...",
|
||||
"tagsAddedSuccessfully": "{count} {type} に {tagCount} 個のタグを追加しました",
|
||||
"tagsReplacedSuccessfully": "{count} {type} のタグを {tagCount} 個に置換しました",
|
||||
"tagsAddFailed": "{count} モデルへのタグ追加に失敗しました",
|
||||
@@ -1125,6 +1432,7 @@
|
||||
"settings": {
|
||||
"loraRootsFailed": "LoRAルートの読み込みに失敗しました:{message}",
|
||||
"checkpointRootsFailed": "checkpointルートの読み込みに失敗しました:{message}",
|
||||
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
||||
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
||||
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング{plural})",
|
||||
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
||||
@@ -1145,7 +1453,26 @@
|
||||
"filters": {
|
||||
"applied": "{message}",
|
||||
"cleared": "フィルタがクリアされました",
|
||||
"noCustomFilterToClear": "クリアするカスタムフィルタがありません"
|
||||
"noCustomFilterToClear": "クリアするカスタムフィルタがありません",
|
||||
"noActiveFilters": "保存するアクティブフィルタがありません"
|
||||
},
|
||||
"presets": {
|
||||
"created": "プリセット \"{name}\" が作成されました",
|
||||
"deleted": "プリセット \"{name}\" が削除されました",
|
||||
"applied": "プリセット \"{name}\" が適用されました",
|
||||
"overwritten": "プリセット「{name}」を上書きしました",
|
||||
"restored": "デフォルトのプリセットを復元しました"
|
||||
},
|
||||
"error": {
|
||||
"presetNameEmpty": "プリセット名を入力してください",
|
||||
"presetNameTooLong": "プリセット名は{max}文字以内にしてください",
|
||||
"presetNameInvalidChars": "プリセット名に使用できない文字が含まれています",
|
||||
"presetNameExists": "同じ名前のプリセットが既に存在します",
|
||||
"maxPresetsReached": "プリセットは最大{max}個までです。追加するには既存のものを削除してください。",
|
||||
"presetNotFound": "プリセットが見つかりません",
|
||||
"invalidPreset": "無効なプリセットデータです",
|
||||
"deletePresetFailed": "プリセットの削除に失敗しました",
|
||||
"applyPresetFailed": "プリセットの適用に失敗しました"
|
||||
},
|
||||
"downloads": {
|
||||
"imagesCompleted": "例画像 {action} が完了しました",
|
||||
@@ -1161,7 +1488,7 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "学習済みワードを読み込めませんでした",
|
||||
"tooLong": "トリガーワードは30ワードを超えてはいけません",
|
||||
"tooLong": "トリガーワードは100ワードを超えてはいけません",
|
||||
"tooMany": "最大30トリガーワードまで許可されています",
|
||||
"alreadyExists": "このトリガーワードは既に存在します",
|
||||
"updateSuccess": "トリガーワードが正常に更新されました",
|
||||
@@ -1210,6 +1537,8 @@
|
||||
"pauseFailed": "ダウンロードの一時停止に失敗しました:{error}",
|
||||
"downloadResumed": "ダウンロードが再開されました",
|
||||
"resumeFailed": "ダウンロードの再開に失敗しました:{error}",
|
||||
"downloadStopped": "ダウンロードをキャンセルしました",
|
||||
"stopFailed": "ダウンロードのキャンセルに失敗しました:{error}",
|
||||
"deleted": "例画像が削除されました",
|
||||
"deleteFailed": "例画像の削除に失敗しました",
|
||||
"setPreviewFailed": "プレビュー画像の設定に失敗しました"
|
||||
@@ -1230,6 +1559,8 @@
|
||||
"metadataRefreshed": "メタデータが正常に更新されました",
|
||||
"metadataRefreshFailed": "メタデータの更新に失敗しました:{message}",
|
||||
"metadataUpdateComplete": "メタデータ更新完了",
|
||||
"operationCancelled": "ユーザーによって操作がキャンセルされました",
|
||||
"operationCancelledPartial": "操作がキャンセルされました。{success} 個の項目が処理されました。",
|
||||
"metadataFetchFailed": "メタデータの取得に失敗しました:{message}",
|
||||
"bulkMetadataCompleteAll": "{count} {type}すべてが正常に更新されました",
|
||||
"bulkMetadataCompletePartial": "{total} {type}のうち {success} が更新されました",
|
||||
@@ -1246,7 +1577,8 @@
|
||||
"bulkMoveFailures": "失敗した移動:\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}"
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
394
locales/ko.json
394
locales/ko.json
@@ -10,7 +10,8 @@
|
||||
"next": "다음",
|
||||
"backToTop": "맨 위로",
|
||||
"settings": "설정",
|
||||
"help": "도움말"
|
||||
"help": "도움말",
|
||||
"add": "추가"
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
@@ -32,7 +33,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 바이트",
|
||||
@@ -101,7 +102,12 @@
|
||||
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
||||
"toggleBlur": "블러 토글",
|
||||
"show": "보기",
|
||||
"openExampleImages": "예시 이미지 폴더 열기"
|
||||
"openExampleImages": "예시 이미지 폴더 열기",
|
||||
"replacePreview": "미리보기 교체",
|
||||
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||
"copyEmbeddingName": "Embedding 이름 복사",
|
||||
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "성인 콘텐츠",
|
||||
@@ -115,12 +121,20 @@
|
||||
"updateFailed": "즐겨찾기 상태 업데이트 실패"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능"
|
||||
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능",
|
||||
"missingPath": "이 카드의 모델 경로를 확인할 수 없습니다"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "예시 이미지 확인 중 오류",
|
||||
"missingHash": "모델 해시 정보가 없습니다.",
|
||||
"noRemoteImagesAvailable": "Civitai에서 이 모델의 원격 예시 이미지를 사용할 수 없습니다"
|
||||
},
|
||||
"badges": {
|
||||
"update": "업데이트",
|
||||
"updateAvailable": "업데이트 가능"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "사용 횟수"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,12 +143,33 @@
|
||||
"missingPath": "예시 이미지를 다운로드하기 전에 다운로드 위치를 설정하세요.",
|
||||
"unavailable": "예시 이미지 다운로드는 아직 사용할 수 없습니다. 페이지 로딩이 완료된 후 다시 시도하세요."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "업데이트 확인",
|
||||
"loading": "{type} 업데이트를 확인 중...",
|
||||
"success": "{type} 업데이트 {count}개를 찾았습니다",
|
||||
"none": "모든 {type}가 최신 상태입니다",
|
||||
"error": "{type} 업데이트 확인 실패: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "예시 이미지 폴더 정리",
|
||||
"success": "{count}개의 폴더가 삭제 폴더로 이동되었습니다",
|
||||
"none": "정리가 필요한 예시 이미지 폴더가 없습니다",
|
||||
"partial": "정리가 완료되었으나 {failures}개의 폴더가 건너뛰어졌습니다",
|
||||
"error": "예시 이미지 폴더 정리에 실패했습니다: {message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "레시피 데이터 복구",
|
||||
"loading": "레시피 데이터 복구 중...",
|
||||
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||
"error": "레시피 복구 실패: {message}"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -144,6 +179,7 @@
|
||||
"recipes": "레시피",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"misc": "[TODO: Translate] Misc",
|
||||
"statistics": "통계"
|
||||
},
|
||||
"search": {
|
||||
@@ -152,7 +188,8 @@
|
||||
"loras": "LoRA 검색...",
|
||||
"recipes": "레시피 검색...",
|
||||
"checkpoints": "Checkpoint 검색...",
|
||||
"embeddings": "Embedding 검색..."
|
||||
"embeddings": "Embedding 검색...",
|
||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
||||
},
|
||||
"options": "검색 옵션",
|
||||
"searchIn": "검색 범위:",
|
||||
@@ -164,13 +201,30 @@
|
||||
"creator": "제작자",
|
||||
"title": "레시피 제목",
|
||||
"loraName": "LoRA 파일명",
|
||||
"loraModel": "LoRA 모델명"
|
||||
"loraModel": "LoRA 모델명",
|
||||
"prompt": "프롬프트"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "모델 필터",
|
||||
"presets": "프리셋",
|
||||
"savePreset": "현재 활성 필터를 새 프리셋으로 저장.",
|
||||
"savePresetDisabledActive": "저장할 수 없음: 프리셋이 이미 활성화되어 있습니다. 필터를 수정한 후 새 프리셋을 저장하세요",
|
||||
"savePresetDisabledNoFilters": "먼저 필터를 선택한 후 프리셋으로 저장",
|
||||
"savePresetPrompt": "프리셋 이름 입력:",
|
||||
"presetClickTooltip": "프리셋 \"{name}\" 적용하려면 클릭",
|
||||
"presetDeleteTooltip": "프리셋 삭제",
|
||||
"presetDeleteConfirm": "프리셋 \"{name}\" 삭제하시겠습니까?",
|
||||
"presetDeleteConfirmClick": "다시 클릭하여 확인",
|
||||
"presetOverwriteConfirm": "프리셋 \"{name}\"이(가) 이미 존재합니다. 덮어쓰시겠습니까?",
|
||||
"presetNamePlaceholder": "프리셋 이름...",
|
||||
"baseModel": "베이스 모델",
|
||||
"modelTags": "태그 (상위 20개)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
"noTags": "태그 없음",
|
||||
"clearAll": "모든 필터 지우기"
|
||||
},
|
||||
"theme": {
|
||||
@@ -181,6 +235,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"notifications": "알림",
|
||||
"support": "지원"
|
||||
}
|
||||
},
|
||||
@@ -192,19 +247,29 @@
|
||||
"label": "설정 폴더 열기",
|
||||
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
||||
"success": "settings.json 폴더를 열었습니다",
|
||||
"failed": "settings.json 폴더를 열지 못했습니다"
|
||||
"failed": "settings.json 폴더를 열지 못했습니다",
|
||||
"copied": "설정 경로가 클립보드에 복사되었습니다: {{path}}",
|
||||
"clipboardFallback": "설정 경로: {{path}}"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "콘텐츠 필터링",
|
||||
"videoSettings": "비디오 설정",
|
||||
"layoutSettings": "레이아웃 설정",
|
||||
"folderSettings": "폴더 설정",
|
||||
"priorityTags": "우선순위 태그",
|
||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||
"exampleImages": "예시 이미지",
|
||||
"updateFlags": "업데이트 표시",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"misc": "기타",
|
||||
"metadataArchive": "메타데이터 아카이브 데이터베이스",
|
||||
"storageLocation": "설정 위치",
|
||||
"proxySettings": "프록시 설정"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "휴대용 모드",
|
||||
"locationHelp": "활성화하면 settings.json을 리포지토리에 유지하고, 비활성화하면 사용자 구성 디렉터리에 저장합니다."
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
||||
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
|
||||
@@ -215,6 +280,15 @@
|
||||
"autoplayOnHover": "호버 시 비디오 자동 재생",
|
||||
"autoplayOnHoverHelp": "마우스를 올렸을 때만 비디오 미리보기를 재생합니다"
|
||||
},
|
||||
"autoOrganizeExclusions": {
|
||||
"label": "자동 정리 제외 항목",
|
||||
"placeholder": "예: curated/*, */backups/*; *_temp.safetensors",
|
||||
"help": "이 와일드카드 패턴과 일치하는 파일 이동을 건너뜁니다. 여러 패턴은 쉼표 또는 세미콜론으로 구분하십시오.",
|
||||
"validation": {
|
||||
"noPatterns": "쉼표 또는 세미콜론으로 구분된 최소한 하나의 패턴을 입력하십시오.",
|
||||
"saveFailed": "제외 항목을 저장할 수 없습니다: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "표시 밀도",
|
||||
"displayDensityOptions": {
|
||||
@@ -224,21 +298,31 @@
|
||||
},
|
||||
"displayDensityHelp": "한 줄에 표시할 카드 수를 선택하세요:",
|
||||
"displayDensityDetails": {
|
||||
"default": "기본: 5개 (1080p), 6개 (2K), 8개 (4K)",
|
||||
"medium": "중간: 6개 (1080p), 7개 (2K), 9개 (4K)",
|
||||
"compact": "조밀: 7개 (1080p), 8개 (2K), 10개 (4K)"
|
||||
"default": "5개 (1080p), 6개 (2K), 8개 (4K)",
|
||||
"medium": "6개 (1080p), 7개 (2K), 9개 (4K)",
|
||||
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
|
||||
"showFolderSidebar": "폴더 사이드바 표시",
|
||||
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
|
||||
"cardInfoDisplay": "카드 정보 표시",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "항상 표시",
|
||||
"hover": "호버 시 표시"
|
||||
},
|
||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "항상 표시: 헤더와 푸터가 항상 보입니다",
|
||||
"hover": "호버 시 표시: 카드에 마우스를 올렸을 때만 헤더와 푸터가 나타납니다"
|
||||
}
|
||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "예시 이미지 열기",
|
||||
"replacePreview": "미리보기 교체"
|
||||
},
|
||||
"modelCardFooterActionHelp": "카드 우측 하단 버튼이 수행할 작업을 선택하세요",
|
||||
"modelNameDisplay": "모델명 표시",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "모델명",
|
||||
"fileName": "파일명"
|
||||
},
|
||||
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "활성 라이브러리",
|
||||
@@ -249,10 +333,32 @@
|
||||
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
|
||||
"defaultCheckpointRoot": "기본 Checkpoint 루트",
|
||||
"defaultCheckpointRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Checkpoint 루트 디렉토리를 설정합니다",
|
||||
"defaultUnetRoot": "기본 Diffusion Model 루트",
|
||||
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
||||
"defaultEmbeddingRoot": "기본 Embedding 루트",
|
||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||
"noDefault": "기본값 없음"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "우선순위 태그",
|
||||
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "우선순위 태그 도움말 열기",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "체크포인트",
|
||||
"embedding": "임베딩"
|
||||
},
|
||||
"saveSuccess": "우선순위 태그가 업데이트되었습니다.",
|
||||
"saveError": "우선순위 태그를 업데이트하지 못했습니다.",
|
||||
"loadingSuggestions": "추천을 불러오는 중...",
|
||||
"validation": {
|
||||
"missingClosingParen": "{index}번째 항목에 닫는 괄호가 없습니다.",
|
||||
"missingCanonical": "{index}번째 항목에는 정식 태그 이름이 포함되어야 합니다.",
|
||||
"duplicateCanonical": "정식 태그 \"{tag}\"가 여러 번 나타납니다.",
|
||||
"unknown": "잘못된 우선순위 태그 구성입니다."
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "다운로드 경로 템플릿",
|
||||
"help": "Civitai에서 다운로드할 때 다양한 모델 유형의 폴더 구조를 구성합니다.",
|
||||
@@ -300,6 +406,14 @@
|
||||
"download": "다운로드",
|
||||
"restartRequired": "재시작 필요"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "업데이트 표시 전략",
|
||||
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
|
||||
"options": {
|
||||
"sameBase": "베이스 모델로 업데이트 일치",
|
||||
"any": "사용 가능한 모든 업데이트 표시"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
|
||||
@@ -359,12 +473,17 @@
|
||||
"dateAsc": "오래된순",
|
||||
"size": "파일 크기",
|
||||
"sizeDesc": "큰 순서",
|
||||
"sizeAsc": "작은 순서"
|
||||
"sizeAsc": "작은 순서",
|
||||
"usage": "사용 횟수",
|
||||
"usageDesc": "많은 순",
|
||||
"usageAsc": "적은 순"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "모델 목록 새로고침",
|
||||
"quick": "빠른 새로고침 (증분)",
|
||||
"full": "전체 재구성 (완전)"
|
||||
"quick": "변경 사항 동기화",
|
||||
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Civitai에서 메타데이터 가져오기",
|
||||
@@ -385,6 +504,13 @@
|
||||
"favorites": {
|
||||
"title": "즐겨찾기만 보기",
|
||||
"action": "즐겨찾기"
|
||||
},
|
||||
"updates": {
|
||||
"title": "업데이트 가능한 모델만 표시",
|
||||
"action": "업데이트",
|
||||
"menuLabel": "업데이트 옵션 표시",
|
||||
"check": "업데이트 확인",
|
||||
"checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -396,6 +522,7 @@
|
||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||
"copyAll": "모든 문법 복사",
|
||||
"refreshAll": "모든 메타데이터 새로고침",
|
||||
"checkUpdates": "선택 항목 업데이트 확인",
|
||||
"moveAll": "모두 폴더로 이동",
|
||||
"autoOrganize": "자동 정리 선택",
|
||||
"deleteAll": "모든 모델 삭제",
|
||||
@@ -412,6 +539,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitai 데이터 새로고침",
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"relinkCivitai": "Civitai에 다시 연결",
|
||||
"copySyntax": "LoRA 문법 복사",
|
||||
"copyFilename": "모델 파일명 복사",
|
||||
@@ -423,6 +551,7 @@
|
||||
"replacePreview": "미리보기 교체",
|
||||
"setContentRating": "콘텐츠 등급 설정",
|
||||
"moveToFolder": "폴더로 이동",
|
||||
"repairMetadata": "메타데이터 복구",
|
||||
"excludeModel": "모델 제외",
|
||||
"deleteModel": "모델 삭제",
|
||||
"shareRecipe": "레시피 공유",
|
||||
@@ -433,6 +562,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA 레시피",
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUI로 보내기"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "가져오기",
|
||||
@@ -490,10 +622,26 @@
|
||||
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "레시피 정렬...",
|
||||
"name": "이름",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "날짜",
|
||||
"dateDesc": "최신순",
|
||||
"dateAsc": "오래된순",
|
||||
"lorasCount": "LoRA 수",
|
||||
"lorasCountDesc": "많은순",
|
||||
"lorasCountAsc": "적은순"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "레시피 목록 새로고침"
|
||||
},
|
||||
"filteredByLora": "LoRA로 필터링됨"
|
||||
"filteredByLora": "LoRA로 필터링됨",
|
||||
"favorites": {
|
||||
"title": "즐겨찾기만 표시",
|
||||
"action": "즐겨찾기"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "{count}개의 중복 그룹 발견",
|
||||
@@ -519,23 +667,54 @@
|
||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "레시피 메타데이터 복구 중...",
|
||||
"success": "레시피 메타데이터가 성공적으로 복구되었습니다",
|
||||
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
|
||||
"failed": "레시피 복구 실패: {message}",
|
||||
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "Checkpoint 모델"
|
||||
"title": "Checkpoint 모델",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "{otherType} 폴더로 이동"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "Embedding 모델"
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] VAE & Upscaler Models",
|
||||
"modelTypes": {
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "모델 루트",
|
||||
"modelRoot": "루트",
|
||||
"collapseAll": "모든 폴더 접기",
|
||||
"pinSidebar": "사이드바 고정",
|
||||
"unpinSidebar": "사이드바 고정 해제",
|
||||
"switchToListView": "목록 보기로 전환",
|
||||
"switchToTreeView": "트리 보기로 전환",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다"
|
||||
"recursiveOn": "하위 폴더 검색",
|
||||
"recursiveOff": "현재 폴더만 검색",
|
||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "통계",
|
||||
@@ -610,6 +789,14 @@
|
||||
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
||||
"downloadingFile": "{type} 파일 다운로드 중",
|
||||
"finalizing": "다운로드 완료 중..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "현재 파일:",
|
||||
"downloading": "다운로드 중: {name}",
|
||||
"transferred": "다운로드됨: {downloaded} / {total}",
|
||||
"transferredSimple": "다운로드됨: {downloaded}",
|
||||
"transferredUnknown": "다운로드됨: --",
|
||||
"speed": "속도: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -657,6 +844,12 @@
|
||||
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
||||
"action": "모두 삭제"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||
"tip": "나눠서 진행하고 싶다면 벌크 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
|
||||
"action": "전체 확인"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "여러 모델에 태그 추가",
|
||||
"description": "다음에 태그를 추가합니다:",
|
||||
@@ -730,7 +923,9 @@
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||
"failed": "파일 위치 열기에 실패했습니다"
|
||||
"failed": "파일 위치 열기에 실패했습니다",
|
||||
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||
"clipboardFallback": "경로: {{path}}"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "버전",
|
||||
@@ -753,11 +948,13 @@
|
||||
"addPresetParameter": "프리셋 매개변수 추가...",
|
||||
"strengthMin": "최소 강도",
|
||||
"strengthMax": "최대 강도",
|
||||
"strengthRange": "강도 범위",
|
||||
"strength": "강도",
|
||||
"clipStrength": "클립 강도",
|
||||
"clipSkip": "클립 스킵",
|
||||
"valuePlaceholder": "값",
|
||||
"add": "추가"
|
||||
"add": "추가",
|
||||
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "트리거 단어",
|
||||
@@ -793,13 +990,84 @@
|
||||
"tabs": {
|
||||
"examples": "예시",
|
||||
"description": "모델 설명",
|
||||
"recipes": "레시피"
|
||||
"recipes": "레시피",
|
||||
"versions": "버전"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "모델 탐색",
|
||||
"previousWithShortcut": "이전 모델(←)",
|
||||
"nextWithShortcut": "다음 모델(→)",
|
||||
"noPrevious": "이전 모델이 없습니다",
|
||||
"noNext": "다음 모델이 없습니다"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "제작자 크레딧 필요",
|
||||
"noDerivatives": "공유 병합 불가",
|
||||
"noReLicense": "동일한 권한 필요",
|
||||
"restrictionsLabel": "라이선스 제한"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "예시 이미지 로딩 중...",
|
||||
"description": "모델 설명 로딩 중...",
|
||||
"recipes": "레시피 로딩 중...",
|
||||
"examples": "예시 로딩 중..."
|
||||
"examples": "예시 로딩 중...",
|
||||
"versions": "버전 로딩 중..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "모델 버전",
|
||||
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
|
||||
"media": {
|
||||
"placeholder": "미리보기 없음"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "이름 없는 버전",
|
||||
"noDetails": "추가 정보 없음"
|
||||
},
|
||||
"badges": {
|
||||
"current": "현재 버전",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"newer": "최신 버전",
|
||||
"ignored": "무시됨"
|
||||
},
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
"delete": "삭제",
|
||||
"ignore": "무시",
|
||||
"unignore": "무시 해제",
|
||||
"resumeModelUpdates": "이 모델 업데이트 재개",
|
||||
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
||||
"viewLocalVersions": "로컬 버전 모두 보기",
|
||||
"viewLocalTooltip": "곧 제공 예정"
|
||||
},
|
||||
"filters": {
|
||||
"label": "기본 필터",
|
||||
"state": {
|
||||
"showAll": "모든 버전",
|
||||
"showSameBase": "같은 베이스"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "모든 버전을 표시하도록 전환",
|
||||
"showSameBaseVersions": "같은 베이스 모델 버전만 표시하도록 전환"
|
||||
},
|
||||
"empty": "현재 베이스 모델 필터와 일치하는 버전이 없습니다."
|
||||
},
|
||||
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
||||
"error": "버전을 불러오지 못했습니다.",
|
||||
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
|
||||
"confirm": {
|
||||
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "이 모델의 업데이트가 무시됩니다",
|
||||
"modelResumed": "업데이트 추적이 재개되었습니다",
|
||||
"versionIgnored": "이 버전의 업데이트가 무시됩니다",
|
||||
"versionUnignored": "버전이 다시 활성화되었습니다",
|
||||
"versionDeleted": "버전이 삭제되었습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,6 +1116,10 @@
|
||||
"title": "통계 초기화 중",
|
||||
"message": "통계를 위한 모델 데이터를 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
||||
},
|
||||
"tips": {
|
||||
"title": "팁 & 요령",
|
||||
"civitai": {
|
||||
@@ -906,11 +1178,19 @@
|
||||
"loraFailedToSend": "LoRA를 워크플로로 전송하지 못했습니다",
|
||||
"recipeAdded": "레시피가 워크플로에 추가되었습니다",
|
||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다"
|
||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
|
||||
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
|
||||
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
|
||||
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
|
||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "레시피",
|
||||
"lora": "LoRA",
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler",
|
||||
"replace": "교체",
|
||||
"append": "추가",
|
||||
"selectTargetNode": "대상 노드 선택",
|
||||
@@ -919,7 +1199,11 @@
|
||||
"exampleImages": {
|
||||
"opened": "예시 이미지 폴더가 열렸습니다",
|
||||
"openingFolder": "예시 이미지 폴더를 여는 중",
|
||||
"failedToOpen": "예시 이미지 폴더 열기 실패"
|
||||
"failedToOpen": "예시 이미지 폴더 열기 실패",
|
||||
"setupRequired": "예시 이미지 저장소",
|
||||
"setupDescription": "사용자 지정 예시 이미지를 추가하려면 먼저 다운로드 위치를 설정해야 합니다.",
|
||||
"setupUsage": "이 경로는 다운로드한 예시 이미지와 사용자 지정 이미지 모두에 사용됩니다.",
|
||||
"openSettings": "설정 열기"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -951,6 +1235,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "업데이트 확인",
|
||||
"notificationsTitle": "알림 센터",
|
||||
"tabs": {
|
||||
"updates": "업데이트",
|
||||
"messages": "메시지"
|
||||
},
|
||||
"updateAvailable": "업데이트 사용 가능",
|
||||
"noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.",
|
||||
"currentVersion": "현재 버전",
|
||||
@@ -963,6 +1252,7 @@
|
||||
"checkingUpdates": "업데이트 확인 중...",
|
||||
"checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.",
|
||||
"showNotifications": "업데이트 알림 표시",
|
||||
"latestBadge": "최신",
|
||||
"updateProgress": {
|
||||
"preparing": "업데이트 준비 중...",
|
||||
"installing": "업데이트 설치 중...",
|
||||
@@ -982,6 +1272,13 @@
|
||||
"nightly": {
|
||||
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
|
||||
"enable": "나이틀리 업데이트 활성화"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "최근 알림",
|
||||
"empty": "최근 배너가 없습니다.",
|
||||
"shown": "{time}에 표시",
|
||||
"dismissed": "{time}에 닫힘",
|
||||
"active": "활성"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1061,6 +1358,9 @@
|
||||
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
|
||||
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
|
||||
"sendError": "레시피를 워크플로로 전송하는 중 오류",
|
||||
"missingCheckpointPath": "체크포인트 경로를 사용할 수 없습니다",
|
||||
"missingCheckpointInfo": "체크포인트 정보가 부족합니다",
|
||||
"downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}",
|
||||
"cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락",
|
||||
"deleteConfirmationError": "삭제 확인 표시 오류",
|
||||
"deletedSuccessfully": "레시피가 성공적으로 삭제되었습니다",
|
||||
@@ -1101,6 +1401,12 @@
|
||||
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
||||
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
||||
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
||||
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
||||
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
||||
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
||||
"bulkUpdatesMissing": "선택한 {type}이 Civitai 업데이트에 연결되어 있지 않습니다",
|
||||
"bulkUpdatesPartialMissing": "Civitai 링크가 없는 {missing}개의 {type}을 건너뛰었습니다",
|
||||
"bulkUpdatesFailed": "선택한 {type}의 업데이트 확인에 실패했습니다: {message}",
|
||||
"invalidCharactersRemoved": "파일명에서 잘못된 문자가 제거되었습니다",
|
||||
"filenameCannotBeEmpty": "파일 이름은 비어있을 수 없습니다",
|
||||
"renameFailed": "파일 이름 변경 실패: {message}",
|
||||
@@ -1112,6 +1418,7 @@
|
||||
"verificationCompleteSuccess": "검증 완료. 모든 파일이 중복임을 확인했습니다.",
|
||||
"verificationFailed": "해시 검증 실패: {message}",
|
||||
"noTagsToAdd": "추가할 태그가 없습니다",
|
||||
"bulkTagsUpdating": "{count}개 모델의 태그를 업데이트 중입니다...",
|
||||
"tagsAddedSuccessfully": "{count}개의 {type}에 {tagCount}개의 태그가 성공적으로 추가되었습니다",
|
||||
"tagsReplacedSuccessfully": "{count}개의 {type}의 태그가 {tagCount}개의 태그로 성공적으로 교체되었습니다",
|
||||
"tagsAddFailed": "{count}개의 모델에 태그 추가에 실패했습니다",
|
||||
@@ -1125,6 +1432,7 @@
|
||||
"settings": {
|
||||
"loraRootsFailed": "LoRA 루트 로딩 실패: {message}",
|
||||
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
||||
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
|
||||
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
||||
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
||||
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
||||
@@ -1145,7 +1453,26 @@
|
||||
"filters": {
|
||||
"applied": "{message}",
|
||||
"cleared": "필터가 지워졌습니다",
|
||||
"noCustomFilterToClear": "지울 사용자 정의 필터가 없습니다"
|
||||
"noCustomFilterToClear": "지울 사용자 정의 필터가 없습니다",
|
||||
"noActiveFilters": "저장할 활성 필터가 없습니다"
|
||||
},
|
||||
"presets": {
|
||||
"created": "프리셋 \"{name}\" 생성됨",
|
||||
"deleted": "프리셋 \"{name}\" 삭제됨",
|
||||
"applied": "프리셋 \"{name}\" 적용됨",
|
||||
"overwritten": "프리셋 \"{name}\" 덮어쓰기 완료",
|
||||
"restored": "기본 프리셋 복원 완료"
|
||||
},
|
||||
"error": {
|
||||
"presetNameEmpty": "프리셋 이름을 입력하세요",
|
||||
"presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다",
|
||||
"presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다",
|
||||
"presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다",
|
||||
"maxPresetsReached": "최대 {max}개의 프리셋만 허용됩니다. 더 추가하려면 기존 것을 삭제하세요.",
|
||||
"presetNotFound": "프리셋을 찾을 수 없습니다",
|
||||
"invalidPreset": "잘못된 프리셋 데이터입니다",
|
||||
"deletePresetFailed": "프리셋 삭제에 실패했습니다",
|
||||
"applyPresetFailed": "프리셋 적용에 실패했습니다"
|
||||
},
|
||||
"downloads": {
|
||||
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
|
||||
@@ -1161,7 +1488,7 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "학습된 단어를 로딩할 수 없습니다",
|
||||
"tooLong": "트리거 단어는 30단어를 초과할 수 없습니다",
|
||||
"tooLong": "트리거 단어는 100단어를 초과할 수 없습니다",
|
||||
"tooMany": "최대 30개의 트리거 단어만 허용됩니다",
|
||||
"alreadyExists": "이 트리거 단어는 이미 존재합니다",
|
||||
"updateSuccess": "트리거 단어가 성공적으로 업데이트되었습니다",
|
||||
@@ -1210,6 +1537,8 @@
|
||||
"pauseFailed": "다운로드 일시정지 실패: {error}",
|
||||
"downloadResumed": "다운로드가 재개되었습니다",
|
||||
"resumeFailed": "다운로드 재개 실패: {error}",
|
||||
"downloadStopped": "다운로드가 취소되었습니다",
|
||||
"stopFailed": "다운로드 취소 실패: {error}",
|
||||
"deleted": "예시 이미지가 삭제되었습니다",
|
||||
"deleteFailed": "예시 이미지 삭제 실패",
|
||||
"setPreviewFailed": "미리보기 이미지 설정 실패"
|
||||
@@ -1230,6 +1559,8 @@
|
||||
"metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다",
|
||||
"metadataRefreshFailed": "메타데이터 새로고침 실패: {message}",
|
||||
"metadataUpdateComplete": "메타데이터 업데이트 완료",
|
||||
"operationCancelled": "사용자에 의해 작업이 취소되었습니다",
|
||||
"operationCancelledPartial": "작업이 취소되었습니다. {success}개 항목이 처리되었습니다.",
|
||||
"metadataFetchFailed": "메타데이터 가져오기 실패: {message}",
|
||||
"bulkMetadataCompleteAll": "모든 {count}개 {type}이(가) 성공적으로 새로고침되었습니다",
|
||||
"bulkMetadataCompletePartial": "{total}개 중 {success}개 {type}이(가) 새로고침되었습니다",
|
||||
@@ -1246,7 +1577,8 @@
|
||||
"bulkMoveFailures": "실패한 이동:\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}"
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
394
locales/ru.json
394
locales/ru.json
@@ -10,7 +10,8 @@
|
||||
"next": "Далее",
|
||||
"backToTop": "Наверх",
|
||||
"settings": "Настройки",
|
||||
"help": "Справка"
|
||||
"help": "Справка",
|
||||
"add": "Добавить"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
@@ -32,7 +33,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Байт",
|
||||
@@ -101,7 +102,12 @@
|
||||
"checkpointNameCopied": "Имя checkpoint скопировано",
|
||||
"toggleBlur": "Переключить размытие",
|
||||
"show": "Показать",
|
||||
"openExampleImages": "Открыть папку с примерами"
|
||||
"openExampleImages": "Открыть папку с примерами",
|
||||
"replacePreview": "Заменить превью",
|
||||
"copyCheckpointName": "Копировать имя checkpoint",
|
||||
"copyEmbeddingName": "Копировать имя embedding",
|
||||
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Контент для взрослых",
|
||||
@@ -115,12 +121,20 @@
|
||||
"updateFailed": "Не удалось обновить статус избранного"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Отправка checkpoint в workflow - функция будет реализована"
|
||||
"checkpointNotImplemented": "Отправка checkpoint в workflow - функция будет реализована",
|
||||
"missingPath": "Невозможно определить путь модели для этой карточки"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Ошибка проверки примеров изображений",
|
||||
"missingHash": "Отсутствует хеш модели.",
|
||||
"noRemoteImagesAvailable": "Нет удаленных примеров изображений для этой модели на Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Обновление",
|
||||
"updateAvailable": "Доступно обновление"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Количество использований"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,12 +143,33 @@
|
||||
"missingPath": "Укажите место загрузки перед загрузкой примеров изображений.",
|
||||
"unavailable": "Загрузка примеров изображений пока недоступна. Попробуйте снова после полной загрузки страницы."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Проверить обновления",
|
||||
"loading": "Проверка обновлений для {type}...",
|
||||
"success": "Найдено {count} обновлений для {type}",
|
||||
"none": "Все {type} актуальны",
|
||||
"error": "Не удалось проверить обновления для {type}: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Очистить папки с примерами изображений",
|
||||
"success": "Перемещено {count} папок в папку удалённых",
|
||||
"none": "Нет папок с примерами изображений, требующих очистки",
|
||||
"partial": "Очистка завершена, пропущено {failures} папок",
|
||||
"error": "Не удалось очистить папки с примерами изображений: {message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "Восстановить данные рецептов",
|
||||
"loading": "Восстановление данных рецептов...",
|
||||
"success": "Успешно восстановлено {count} рецептов.",
|
||||
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||
"error": "Ошибка восстановления рецептов: {message}"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -144,6 +179,7 @@
|
||||
"recipes": "Рецепты",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"misc": "[TODO: Translate] Misc",
|
||||
"statistics": "Статистика"
|
||||
},
|
||||
"search": {
|
||||
@@ -152,7 +188,8 @@
|
||||
"loras": "Поиск LoRAs...",
|
||||
"recipes": "Поиск рецептов...",
|
||||
"checkpoints": "Поиск checkpoints...",
|
||||
"embeddings": "Поиск embeddings..."
|
||||
"embeddings": "Поиск embeddings...",
|
||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
||||
},
|
||||
"options": "Опции поиска",
|
||||
"searchIn": "Искать в:",
|
||||
@@ -164,13 +201,30 @@
|
||||
"creator": "Автор",
|
||||
"title": "Название рецепта",
|
||||
"loraName": "Имя файла LoRA",
|
||||
"loraModel": "Название модели LoRA"
|
||||
"loraModel": "Название модели LoRA",
|
||||
"prompt": "Запрос"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "Фильтр моделей",
|
||||
"presets": "Пресеты",
|
||||
"savePreset": "Сохранить текущие активные фильтры как новый пресет.",
|
||||
"savePresetDisabledActive": "Невозможно сохранить: Пресет уже активен. Измените фильтры, чтобы сохранить новый пресет",
|
||||
"savePresetDisabledNoFilters": "Сначала выберите фильтры для сохранения как пресет",
|
||||
"savePresetPrompt": "Введите имя пресета:",
|
||||
"presetClickTooltip": "Нажмите чтобы применить пресет \"{name}\"",
|
||||
"presetDeleteTooltip": "Удалить пресет",
|
||||
"presetDeleteConfirm": "Удалить пресет \"{name}\"?",
|
||||
"presetDeleteConfirmClick": "Нажмите еще раз для подтверждения",
|
||||
"presetOverwriteConfirm": "Пресет \"{name}\" уже существует. Перезаписать?",
|
||||
"presetNamePlaceholder": "Имя пресета...",
|
||||
"baseModel": "Базовая модель",
|
||||
"modelTags": "Теги (Топ 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
"noTags": "Без тегов",
|
||||
"clearAll": "Очистить все фильтры"
|
||||
},
|
||||
"theme": {
|
||||
@@ -181,6 +235,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Проверить обновления",
|
||||
"notifications": "Уведомления",
|
||||
"support": "Поддержка"
|
||||
}
|
||||
},
|
||||
@@ -192,19 +247,29 @@
|
||||
"label": "Открыть папку настроек",
|
||||
"tooltip": "Открыть папку, содержащую settings.json",
|
||||
"success": "Папка settings.json открыта",
|
||||
"failed": "Не удалось открыть папку settings.json"
|
||||
"failed": "Не удалось открыть папку settings.json",
|
||||
"copied": "Путь настроек скопирован в буфер обмена: {{path}}",
|
||||
"clipboardFallback": "Путь настроек: {{path}}"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Фильтрация контента",
|
||||
"videoSettings": "Настройки видео",
|
||||
"layoutSettings": "Настройки макета",
|
||||
"folderSettings": "Настройки папок",
|
||||
"priorityTags": "Приоритетные теги",
|
||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||
"exampleImages": "Примеры изображений",
|
||||
"updateFlags": "Метки обновлений",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"misc": "Разное",
|
||||
"metadataArchive": "Архив метаданных",
|
||||
"storageLocation": "Расположение настроек",
|
||||
"proxySettings": "Настройки прокси"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "Портативный режим",
|
||||
"locationHelp": "Включите, чтобы хранить settings.json в репозитории; выключите, чтобы сохранить его в папке конфигурации пользователя."
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "Размывать NSFW контент",
|
||||
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
|
||||
@@ -215,6 +280,15 @@
|
||||
"autoplayOnHover": "Автовоспроизведение видео при наведении",
|
||||
"autoplayOnHoverHelp": "Воспроизводить превью видео только при наведении курсора"
|
||||
},
|
||||
"autoOrganizeExclusions": {
|
||||
"label": "Исключения автосортировки",
|
||||
"placeholder": "Пример: curated/*, */backups/*; *_temp.safetensors",
|
||||
"help": "Пропускать перемещение файлов, соответствующих этим шаблонам. Разделяйте несколько шаблонов запятыми или точками с запятой.",
|
||||
"validation": {
|
||||
"noPatterns": "Введите хотя бы один шаблон, разделенный запятыми или точками с запятой.",
|
||||
"saveFailed": "Не удалось сохранить исключения: {message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Плотность отображения",
|
||||
"displayDensityOptions": {
|
||||
@@ -224,21 +298,31 @@
|
||||
},
|
||||
"displayDensityHelp": "Выберите количество карточек для отображения в ряду:",
|
||||
"displayDensityDetails": {
|
||||
"default": "По умолчанию: 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "Средняя: 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "Компактная: 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
|
||||
"showFolderSidebar": "Показывать боковую панель папок",
|
||||
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
|
||||
"cardInfoDisplay": "Отображение информации карточки",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Всегда видимо",
|
||||
"hover": "Показать при наведении"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Всегда видимо: Заголовки и подписи всегда видны",
|
||||
"hover": "Показать при наведении: Заголовки и подписи появляются только при наведении на карточку"
|
||||
}
|
||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Открыть примеры изображений",
|
||||
"replacePreview": "Заменить превью"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Выберите, что делает кнопка в правом нижнем углу карточки",
|
||||
"modelNameDisplay": "Отображение названия модели",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Название модели",
|
||||
"fileName": "Имя файла"
|
||||
},
|
||||
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Активная библиотека",
|
||||
@@ -249,10 +333,32 @@
|
||||
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию",
|
||||
"defaultCheckpointRootHelp": "Установить корневую папку checkpoint по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultUnetRoot": "Корневая папка Diffusion Model по умолчанию",
|
||||
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultEmbeddingRoot": "Корневая папка Embedding по умолчанию",
|
||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||
"noDefault": "Не задано"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Приоритетные теги",
|
||||
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "Открыть справку по приоритетным тегам",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Чекпойнт",
|
||||
"embedding": "Эмбеддинг"
|
||||
},
|
||||
"saveSuccess": "Приоритетные теги обновлены.",
|
||||
"saveError": "Не удалось обновить приоритетные теги.",
|
||||
"loadingSuggestions": "Загрузка подсказок...",
|
||||
"validation": {
|
||||
"missingClosingParen": "В записи {index} отсутствует закрывающая скобка.",
|
||||
"missingCanonical": "Запись {index} должна содержать каноническое имя тега.",
|
||||
"duplicateCanonical": "Канонический тег \"{tag}\" встречается более одного раза.",
|
||||
"unknown": "Недопустимая конфигурация приоритетных тегов."
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "Шаблоны путей загрузки",
|
||||
"help": "Настройте структуру папок для разных типов моделей при загрузке с Civitai.",
|
||||
@@ -300,6 +406,14 @@
|
||||
"download": "Загрузить",
|
||||
"restartRequired": "Требует перезапуска"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "Стратегия меток обновлений",
|
||||
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
|
||||
"options": {
|
||||
"sameBase": "Совпадение обновлений по базовой модели",
|
||||
"any": "Отмечать любые доступные обновления"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
|
||||
@@ -359,12 +473,17 @@
|
||||
"dateAsc": "Старейшим",
|
||||
"size": "Размеру файла",
|
||||
"sizeDesc": "Наибольшим",
|
||||
"sizeAsc": "Наименьшим"
|
||||
"sizeAsc": "Наименьшим",
|
||||
"usage": "Число использований",
|
||||
"usageDesc": "Больше",
|
||||
"usageAsc": "Меньше"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список моделей",
|
||||
"quick": "Быстрое обновление (инкрементальное)",
|
||||
"full": "Полная перестройка (полное)"
|
||||
"quick": "Синхронизировать изменения",
|
||||
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Получить метаданные с Civitai",
|
||||
@@ -385,6 +504,13 @@
|
||||
"favorites": {
|
||||
"title": "Показать только избранное",
|
||||
"action": "Избранное"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Показывать только модели с доступными обновлениями",
|
||||
"action": "Обновления",
|
||||
"menuLabel": "Показать параметры обновления",
|
||||
"check": "Проверить обновления",
|
||||
"checkTooltip": "Проверка может занять время."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -396,6 +522,7 @@
|
||||
"setContentRating": "Установить рейтинг контента для всех",
|
||||
"copyAll": "Копировать весь синтаксис",
|
||||
"refreshAll": "Обновить все метаданные",
|
||||
"checkUpdates": "Проверить обновления для выбранных",
|
||||
"moveAll": "Переместить все в папку",
|
||||
"autoOrganize": "Автоматически организовать выбранные",
|
||||
"deleteAll": "Удалить все модели",
|
||||
@@ -412,6 +539,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Обновить данные Civitai",
|
||||
"checkUpdates": "Проверить обновления",
|
||||
"relinkCivitai": "Пересвязать с Civitai",
|
||||
"copySyntax": "Копировать синтаксис LoRA",
|
||||
"copyFilename": "Копировать имя файла модели",
|
||||
@@ -423,6 +551,7 @@
|
||||
"replacePreview": "Заменить превью",
|
||||
"setContentRating": "Установить рейтинг контента",
|
||||
"moveToFolder": "Переместить в папку",
|
||||
"repairMetadata": "Восстановить метаданные",
|
||||
"excludeModel": "Исключить модель",
|
||||
"deleteModel": "Удалить модель",
|
||||
"shareRecipe": "Поделиться рецептом",
|
||||
@@ -433,6 +562,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Рецепты LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Отправить в ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Импортировать",
|
||||
@@ -490,10 +622,26 @@
|
||||
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "Сортировка рецептов...",
|
||||
"name": "Имя",
|
||||
"nameAsc": "А - Я",
|
||||
"nameDesc": "Я - А",
|
||||
"date": "Дата",
|
||||
"dateDesc": "Сначала новые",
|
||||
"dateAsc": "Сначала старые",
|
||||
"lorasCount": "Кол-во LoRA",
|
||||
"lorasCountDesc": "Больше всего",
|
||||
"lorasCountAsc": "Меньше всего"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список рецептов"
|
||||
},
|
||||
"filteredByLora": "Фильтр по LoRA"
|
||||
"filteredByLora": "Фильтр по LoRA",
|
||||
"favorites": {
|
||||
"title": "Только избранные",
|
||||
"action": "Избранное"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "Найдено {count} групп дубликатов",
|
||||
@@ -519,23 +667,54 @@
|
||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "Восстановление метаданных рецепта...",
|
||||
"success": "Метаданные рецепта успешно восстановлены",
|
||||
"skipped": "Рецепт уже последней версии, восстановление не требуется",
|
||||
"failed": "Не удалось восстановить рецепт: {message}",
|
||||
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "Модели Checkpoint"
|
||||
"title": "Модели Checkpoint",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Переместить в папку {otherType}"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "Модели Embedding"
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] VAE & Upscaler Models",
|
||||
"modelTypes": {
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Корень моделей",
|
||||
"modelRoot": "Корень",
|
||||
"collapseAll": "Свернуть все папки",
|
||||
"pinSidebar": "Закрепить боковую панель",
|
||||
"unpinSidebar": "Открепить боковую панель",
|
||||
"switchToListView": "Переключить на вид списка",
|
||||
"switchToTreeView": "Переключить на древовидный вид",
|
||||
"collapseAllDisabled": "Недоступно в виде списка"
|
||||
"recursiveOn": "Искать во вложенных папках",
|
||||
"recursiveOff": "Искать только в текущей папке",
|
||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Статистика",
|
||||
@@ -610,6 +789,14 @@
|
||||
"downloadedPreview": "Превью изображение загружено",
|
||||
"downloadingFile": "Загрузка файла {type}",
|
||||
"finalizing": "Завершение загрузки..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Текущий файл:",
|
||||
"downloading": "Скачивается: {name}",
|
||||
"transferred": "Скачано: {downloaded} / {total}",
|
||||
"transferredSimple": "Скачано: {downloaded}",
|
||||
"transferredUnknown": "Скачано: --",
|
||||
"speed": "Скорость: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -657,6 +844,12 @@
|
||||
"countMessage": "моделей будут удалены навсегда.",
|
||||
"action": "Удалить все"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Проверить обновления для всех {typePlural}?",
|
||||
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
||||
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
|
||||
"action": "Проверить всё"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Добавить теги к нескольким моделям",
|
||||
"description": "Добавить теги к",
|
||||
@@ -730,7 +923,9 @@
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Расположение файла успешно открыто",
|
||||
"failed": "Не удалось открыть расположение файла"
|
||||
"failed": "Не удалось открыть расположение файла",
|
||||
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||
"clipboardFallback": "Путь: {{path}}"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Версия",
|
||||
@@ -753,11 +948,13 @@
|
||||
"addPresetParameter": "Добавить предустановленный параметр...",
|
||||
"strengthMin": "Мин. сила",
|
||||
"strengthMax": "Макс. сила",
|
||||
"strengthRange": "Диапазон силы",
|
||||
"strength": "Сила",
|
||||
"clipStrength": "Сила клипа",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "Значение",
|
||||
"add": "Добавить"
|
||||
"add": "Добавить",
|
||||
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "Триггерные слова",
|
||||
@@ -793,13 +990,84 @@
|
||||
"tabs": {
|
||||
"examples": "Примеры",
|
||||
"description": "Описание модели",
|
||||
"recipes": "Рецепты"
|
||||
"recipes": "Рецепты",
|
||||
"versions": "Версии"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Навигация по моделям",
|
||||
"previousWithShortcut": "Предыдущая модель (←)",
|
||||
"nextWithShortcut": "Следующая модель (→)",
|
||||
"noPrevious": "Предыдущая модель отсутствует",
|
||||
"noNext": "Следующая модель отсутствует"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "Требуется указание авторства",
|
||||
"noDerivatives": "Запрет на совместное использование производных работ",
|
||||
"noReLicense": "Требуются те же права",
|
||||
"restrictionsLabel": "Лицензионные ограничения"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Загрузка примеров изображений...",
|
||||
"description": "Загрузка описания модели...",
|
||||
"recipes": "Загрузка рецептов...",
|
||||
"examples": "Загрузка примеров..."
|
||||
"examples": "Загрузка примеров...",
|
||||
"versions": "Загрузка версий..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Версии модели",
|
||||
"copy": "Управляйте всеми версиями этой модели в одном месте.",
|
||||
"media": {
|
||||
"placeholder": "Нет превью"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Версия без названия",
|
||||
"noDetails": "Дополнительная информация отсутствует"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Текущая версия",
|
||||
"inLibrary": "В библиотеке",
|
||||
"newer": "Более новая версия",
|
||||
"ignored": "Игнорируется"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Скачать",
|
||||
"delete": "Удалить",
|
||||
"ignore": "Игнорировать",
|
||||
"unignore": "Перестать игнорировать",
|
||||
"resumeModelUpdates": "Возобновить обновления для этой модели",
|
||||
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
|
||||
"viewLocalVersions": "Показать все локальные версии",
|
||||
"viewLocalTooltip": "Скоро появится"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Фильтр по базе",
|
||||
"state": {
|
||||
"showAll": "Все версии",
|
||||
"showSameBase": "Тот же базовый"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "Переключиться на отображение всех версий",
|
||||
"showSameBaseVersions": "Переключиться на отображение только версий с тем же базовым"
|
||||
},
|
||||
"empty": "Нет версий, соответствующих текущему фильтру базовой модели."
|
||||
},
|
||||
"empty": "Для этой модели пока нет истории версий.",
|
||||
"error": "Не удалось загрузить версии.",
|
||||
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
|
||||
"confirm": {
|
||||
"delete": "Удалить эту версию из библиотеки?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Обновления для этой модели игнорируются",
|
||||
"modelResumed": "Отслеживание обновлений возобновлено",
|
||||
"versionIgnored": "Обновления для этой версии игнорируются",
|
||||
"versionUnignored": "Версия снова активна",
|
||||
"versionDeleted": "Версия удалена"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,6 +1116,10 @@
|
||||
"title": "Инициализация статистики",
|
||||
"message": "Обработка данных моделей для статистики. Это может занять несколько минут..."
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
||||
},
|
||||
"tips": {
|
||||
"title": "Советы и хитрости",
|
||||
"civitai": {
|
||||
@@ -906,11 +1178,19 @@
|
||||
"loraFailedToSend": "Не удалось отправить LoRA в workflow",
|
||||
"recipeAdded": "Рецепт добавлен в workflow",
|
||||
"recipeReplaced": "Рецепт заменён в workflow",
|
||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow"
|
||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
||||
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
|
||||
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
|
||||
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
|
||||
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
|
||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||
"noTargetNodeSelected": "Целевой узел не выбран"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Рецепт",
|
||||
"lora": "LoRA",
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler",
|
||||
"replace": "Заменить",
|
||||
"append": "Добавить",
|
||||
"selectTargetNode": "Выберите целевой узел",
|
||||
@@ -919,7 +1199,11 @@
|
||||
"exampleImages": {
|
||||
"opened": "Папка с примерами изображений открыта",
|
||||
"openingFolder": "Открытие папки с примерами изображений",
|
||||
"failedToOpen": "Не удалось открыть папку с примерами изображений"
|
||||
"failedToOpen": "Не удалось открыть папку с примерами изображений",
|
||||
"setupRequired": "Хранилище примеров изображений",
|
||||
"setupDescription": "Чтобы добавить собственные примеры изображений, сначала нужно установить место загрузки.",
|
||||
"setupUsage": "Этот путь используется как для загруженных, так и для пользовательских примеров изображений.",
|
||||
"openSettings": "Открыть настройки"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -951,6 +1235,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Проверить обновления",
|
||||
"notificationsTitle": "Центр уведомлений",
|
||||
"tabs": {
|
||||
"updates": "Обновления",
|
||||
"messages": "Сообщения"
|
||||
},
|
||||
"updateAvailable": "Доступно обновление",
|
||||
"noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.",
|
||||
"currentVersion": "Текущая версия",
|
||||
@@ -963,6 +1252,7 @@
|
||||
"checkingUpdates": "Проверка обновлений...",
|
||||
"checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.",
|
||||
"showNotifications": "Показывать уведомления об обновлениях",
|
||||
"latestBadge": "Последний",
|
||||
"updateProgress": {
|
||||
"preparing": "Подготовка обновления...",
|
||||
"installing": "Установка обновления...",
|
||||
@@ -982,6 +1272,13 @@
|
||||
"nightly": {
|
||||
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
|
||||
"enable": "Включить ночные обновления"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Недавние уведомления",
|
||||
"empty": "Недавних баннеров нет.",
|
||||
"shown": "Показано {time}",
|
||||
"dismissed": "Закрыто {time}",
|
||||
"active": "Активно"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1061,6 +1358,9 @@
|
||||
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
|
||||
"sendFailed": "Не удалось отправить рецепт в workflow",
|
||||
"sendError": "Ошибка отправки рецепта в workflow",
|
||||
"missingCheckpointPath": "Путь к чекпойнту недоступен",
|
||||
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
|
||||
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
|
||||
"cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта",
|
||||
"deleteConfirmationError": "Ошибка отображения подтверждения удаления",
|
||||
"deletedSuccessfully": "Рецепт успешно удален",
|
||||
@@ -1101,6 +1401,12 @@
|
||||
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
||||
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
||||
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
||||
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
||||
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
||||
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
||||
"bulkUpdatesMissing": "Выбранные {type} не привязаны к обновлениям Civitai",
|
||||
"bulkUpdatesPartialMissing": "Пропущено {missing} выбранных {type} без привязки Civitai",
|
||||
"bulkUpdatesFailed": "Не удалось проверить обновления для выбранных {type}: {message}",
|
||||
"invalidCharactersRemoved": "Недопустимые символы удалены из имени файла",
|
||||
"filenameCannotBeEmpty": "Имя файла не может быть пустым",
|
||||
"renameFailed": "Не удалось переименовать файл: {message}",
|
||||
@@ -1112,6 +1418,7 @@
|
||||
"verificationCompleteSuccess": "Проверка завершена. Все файлы подтверждены как дубликаты.",
|
||||
"verificationFailed": "Не удалось проверить хеши: {message}",
|
||||
"noTagsToAdd": "Нет тегов для добавления",
|
||||
"bulkTagsUpdating": "Обновление тегов для {count} модел(ей)...",
|
||||
"tagsAddedSuccessfully": "Успешно добавлено {tagCount} тег(ов) к {count} {type}(ам)",
|
||||
"tagsReplacedSuccessfully": "Успешно заменены теги для {count} {type}(ов) на {tagCount} тег(ов)",
|
||||
"tagsAddFailed": "Не удалось добавить теги к {count} модель(ям)",
|
||||
@@ -1125,6 +1432,7 @@
|
||||
"settings": {
|
||||
"loraRootsFailed": "Не удалось загрузить корни LoRA: {message}",
|
||||
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
||||
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
|
||||
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
||||
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count} сопоставлени{plural})",
|
||||
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
||||
@@ -1145,7 +1453,26 @@
|
||||
"filters": {
|
||||
"applied": "{message}",
|
||||
"cleared": "Фильтры очищены",
|
||||
"noCustomFilterToClear": "Нет пользовательского фильтра для очистки"
|
||||
"noCustomFilterToClear": "Нет пользовательского фильтра для очистки",
|
||||
"noActiveFilters": "Нет активных фильтров для сохранения"
|
||||
},
|
||||
"presets": {
|
||||
"created": "Пресет \"{name}\" создан",
|
||||
"deleted": "Пресет \"{name}\" удален",
|
||||
"applied": "Пресет \"{name}\" применен",
|
||||
"overwritten": "Пресет \"{name}\" перезаписан",
|
||||
"restored": "Пресеты по умолчанию восстановлены"
|
||||
},
|
||||
"error": {
|
||||
"presetNameEmpty": "Имя пресета не может быть пустым",
|
||||
"presetNameTooLong": "Имя пресета должно содержать не более {max} символов",
|
||||
"presetNameInvalidChars": "Имя пресета содержит недопустимые символы",
|
||||
"presetNameExists": "Пресет с таким именем уже существует",
|
||||
"maxPresetsReached": "Допустимо максимум {max} пресетов. Удалите один, чтобы добавить больше.",
|
||||
"presetNotFound": "Пресет не найден",
|
||||
"invalidPreset": "Недопустимые данные пресета",
|
||||
"deletePresetFailed": "Не удалось удалить пресет",
|
||||
"applyPresetFailed": "Не удалось применить пресет"
|
||||
},
|
||||
"downloads": {
|
||||
"imagesCompleted": "Примеры изображений {action} завершены",
|
||||
@@ -1161,7 +1488,7 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "Не удалось загрузить обученные слова",
|
||||
"tooLong": "Триггерное слово не должно превышать 30 слов",
|
||||
"tooLong": "Триггерное слово не должно превышать 100 слов",
|
||||
"tooMany": "Максимум 30 триггерных слов разрешено",
|
||||
"alreadyExists": "Это триггерное слово уже существует",
|
||||
"updateSuccess": "Триггерные слова успешно обновлены",
|
||||
@@ -1210,6 +1537,8 @@
|
||||
"pauseFailed": "Не удалось приостановить загрузку: {error}",
|
||||
"downloadResumed": "Загрузка возобновлена",
|
||||
"resumeFailed": "Не удалось возобновить загрузку: {error}",
|
||||
"downloadStopped": "Загрузка отменена",
|
||||
"stopFailed": "Не удалось отменить загрузку: {error}",
|
||||
"deleted": "Пример изображения удален",
|
||||
"deleteFailed": "Не удалось удалить пример изображения",
|
||||
"setPreviewFailed": "Не удалось установить превью изображение"
|
||||
@@ -1230,6 +1559,8 @@
|
||||
"metadataRefreshed": "Метаданные успешно обновлены",
|
||||
"metadataRefreshFailed": "Не удалось обновить метаданные: {message}",
|
||||
"metadataUpdateComplete": "Обновление метаданных завершено",
|
||||
"operationCancelled": "Операция отменена пользователем",
|
||||
"operationCancelledPartial": "Операция отменена. Обработано {success} элементов.",
|
||||
"metadataFetchFailed": "Не удалось получить метаданные: {message}",
|
||||
"bulkMetadataCompleteAll": "Успешно обновлены все {count} {type}s",
|
||||
"bulkMetadataCompletePartial": "Обновлено {success} из {total} {type}s",
|
||||
@@ -1246,7 +1577,8 @@
|
||||
"bulkMoveFailures": "Неудачные перемещения:\n{failures}",
|
||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}"
|
||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"next": "下一步",
|
||||
"backToTop": "返回顶部",
|
||||
"settings": "设置",
|
||||
"help": "帮助"
|
||||
"help": "帮助",
|
||||
"add": "添加"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
@@ -26,19 +27,13 @@
|
||||
"english": "English",
|
||||
"chinese_simplified": "中文(简体)",
|
||||
"chinese_traditional": "中文(繁体)",
|
||||
"russian": "俄语",
|
||||
"german": "德语",
|
||||
"japanese": "日语",
|
||||
"korean": "韩语",
|
||||
"french": "法语",
|
||||
"spanish": "西班牙语",
|
||||
"Hebrew": "עברית",
|
||||
"russian": "Русский",
|
||||
"german": "Deutsch",
|
||||
"japanese": "日本語",
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español"
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 字节",
|
||||
@@ -107,7 +102,12 @@
|
||||
"checkpointNameCopied": "检查点名称已复制",
|
||||
"toggleBlur": "切换模糊",
|
||||
"show": "显示",
|
||||
"openExampleImages": "打开示例图片文件夹"
|
||||
"openExampleImages": "打开示例图片文件夹",
|
||||
"replacePreview": "替换预览",
|
||||
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||
"copyEmbeddingName": "复制 Embedding 名称",
|
||||
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "成熟内容",
|
||||
@@ -121,12 +121,20 @@
|
||||
"updateFailed": "收藏状态更新失败"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现"
|
||||
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现",
|
||||
"missingPath": "无法确定此卡片的模型路径"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "检查示例图片时出错",
|
||||
"missingHash": "缺少模型哈希信息。",
|
||||
"noRemoteImagesAvailable": "此模型在 Civitai 上没有远程示例图片"
|
||||
},
|
||||
"badges": {
|
||||
"update": "更新",
|
||||
"updateAvailable": "有可用更新"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次数"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -135,12 +143,33 @@
|
||||
"missingPath": "请先设置下载位置后再下载示例图片。",
|
||||
"unavailable": "示例图片下载当前不可用。请在页面加载完成后重试。"
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "检查更新",
|
||||
"loading": "正在检查 {type} 更新...",
|
||||
"success": "找到 {count} 条 {type} 更新",
|
||||
"none": "所有 {type} 均已是最新版本",
|
||||
"error": "检查 {type} 更新失败:{message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "清理示例图片文件夹",
|
||||
"success": "已将 {count} 个文件夹移动到已删除文件夹",
|
||||
"none": "没有需要清理的示例图片文件夹",
|
||||
"partial": "清理完成,有 {failures} 个文件夹跳过",
|
||||
"error": "清理示例图片文件夹失败:{message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "修复配方数据",
|
||||
"loading": "正在修复配方数据...",
|
||||
"success": "成功修复了 {count} 个配方。",
|
||||
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||
"error": "配方修复失败:{message}"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -150,6 +179,7 @@
|
||||
"recipes": "配方",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"misc": "[TODO: Translate] Misc",
|
||||
"statistics": "统计"
|
||||
},
|
||||
"search": {
|
||||
@@ -158,7 +188,8 @@
|
||||
"loras": "搜索 LoRA...",
|
||||
"recipes": "搜索配方...",
|
||||
"checkpoints": "搜索 Checkpoint...",
|
||||
"embeddings": "搜索 Embedding..."
|
||||
"embeddings": "搜索 Embedding...",
|
||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
||||
},
|
||||
"options": "搜索选项",
|
||||
"searchIn": "搜索范围:",
|
||||
@@ -170,13 +201,30 @@
|
||||
"creator": "创作者",
|
||||
"title": "配方标题",
|
||||
"loraName": "LoRA 文件名",
|
||||
"loraModel": "LoRA 模型名称"
|
||||
"loraModel": "LoRA 模型名称",
|
||||
"prompt": "提示词"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "筛选模型",
|
||||
"presets": "预设",
|
||||
"savePreset": "将当前激活的筛选器保存为新预设。",
|
||||
"savePresetDisabledActive": "无法保存:已有预设处于激活状态。修改筛选器后可保存新预设",
|
||||
"savePresetDisabledNoFilters": "先选择筛选器,然后保存为预设",
|
||||
"savePresetPrompt": "输入预设名称:",
|
||||
"presetClickTooltip": "点击应用预设 \"{name}\"",
|
||||
"presetDeleteTooltip": "删除预设",
|
||||
"presetDeleteConfirm": "删除预设 \"{name}\"?",
|
||||
"presetDeleteConfirmClick": "再次点击确认",
|
||||
"presetOverwriteConfirm": "预设 \"{name}\" 已存在。是否覆盖?",
|
||||
"presetNamePlaceholder": "预设名称...",
|
||||
"baseModel": "基础模型",
|
||||
"modelTags": "标签(前20)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
"noTags": "无标签",
|
||||
"clearAll": "清除所有筛选"
|
||||
},
|
||||
"theme": {
|
||||
@@ -187,6 +235,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "检查更新",
|
||||
"notifications": "通知",
|
||||
"support": "支持"
|
||||
}
|
||||
},
|
||||
@@ -198,19 +247,29 @@
|
||||
"label": "打开设置文件夹",
|
||||
"tooltip": "打开包含 settings.json 的文件夹",
|
||||
"success": "已打开 settings.json 文件夹",
|
||||
"failed": "无法打开 settings.json 文件夹"
|
||||
"failed": "无法打开 settings.json 文件夹",
|
||||
"copied": "设置路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "设置路径:{{path}}"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "内容过滤",
|
||||
"videoSettings": "视频设置",
|
||||
"layoutSettings": "布局设置",
|
||||
"folderSettings": "文件夹设置",
|
||||
"priorityTags": "优先标签",
|
||||
"downloadPathTemplates": "下载路径模板",
|
||||
"exampleImages": "示例图片",
|
||||
"updateFlags": "更新标记",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"misc": "其他",
|
||||
"metadataArchive": "元数据归档数据库",
|
||||
"storageLocation": "设置位置",
|
||||
"proxySettings": "代理设置"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "便携模式",
|
||||
"locationHelp": "开启可将 settings.json 保存在仓库中;关闭则保存在用户配置目录。"
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "模糊 NSFW 内容",
|
||||
"blurNsfwContentHelp": "模糊成熟(NSFW)内容预览图片",
|
||||
@@ -221,6 +280,15 @@
|
||||
"autoplayOnHover": "悬停时自动播放视频",
|
||||
"autoplayOnHoverHelp": "仅在悬停时播放视频预览"
|
||||
},
|
||||
"autoOrganizeExclusions": {
|
||||
"label": "自动整理排除项",
|
||||
"placeholder": "示例: curated/*, */backups/*; *_temp.safetensors",
|
||||
"help": "跳过与这些通配符模式匹配的文件。多个模式用逗号或分号分隔。",
|
||||
"validation": {
|
||||
"noPatterns": "请输入至少一个用逗号或分号分隔的模式。",
|
||||
"saveFailed": "无法保存排除项:{message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "显示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -230,21 +298,31 @@
|
||||
},
|
||||
"displayDensityHelp": "选择每行显示卡片数量:",
|
||||
"displayDensityDetails": {
|
||||
"default": "默认:5(1080p),6(2K),8(4K)",
|
||||
"medium": "中等:6(1080p),7(2K),9(4K)",
|
||||
"compact": "紧凑:7(1080p),8(2K),10(4K)"
|
||||
"default": "5(1080p),6(2K),8(4K)",
|
||||
"medium": "6(1080p),7(2K),9(4K)",
|
||||
"compact": "7(1080p),8(2K),10(4K)"
|
||||
},
|
||||
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
|
||||
"showFolderSidebar": "显示文件夹侧边栏",
|
||||
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
|
||||
"cardInfoDisplay": "卡片信息显示",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "始终可见",
|
||||
"hover": "悬停时显示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "始终可见:标题和底部始终显示",
|
||||
"hover": "悬停时显示:仅在悬停卡片时显示标题和底部"
|
||||
}
|
||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||
"modelCardFooterAction": "模型卡片按钮操作",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "打开示例图片",
|
||||
"replacePreview": "替换预览"
|
||||
},
|
||||
"modelCardFooterActionHelp": "选择右下角卡片按钮的功能",
|
||||
"modelNameDisplay": "模型名称显示",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "模型名称",
|
||||
"fileName": "文件名"
|
||||
},
|
||||
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "活动库",
|
||||
@@ -255,10 +333,32 @@
|
||||
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
|
||||
"defaultCheckpointRoot": "默认 Checkpoint 根目录",
|
||||
"defaultCheckpointRootHelp": "设置下载、导入和移动时的默认 Checkpoint 根目录",
|
||||
"defaultUnetRoot": "默认 Diffusion Model 根目录",
|
||||
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
||||
"defaultEmbeddingRoot": "默认 Embedding 根目录",
|
||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||
"noDefault": "无默认"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "优先标签",
|
||||
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "打开优先标签帮助",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "优先标签已更新。",
|
||||
"saveError": "优先标签更新失败。",
|
||||
"loadingSuggestions": "正在加载建议...",
|
||||
"validation": {
|
||||
"missingClosingParen": "条目 {index} 缺少右括号。",
|
||||
"missingCanonical": "条目 {index} 必须包含规范标签名称。",
|
||||
"duplicateCanonical": "规范标签 \"{tag}\" 出现多次。",
|
||||
"unknown": "优先标签配置无效。"
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "下载路径模板",
|
||||
"help": "配置从 Civitai 下载不同模型类型的文件夹结构。",
|
||||
@@ -306,6 +406,14 @@
|
||||
"download": "下载",
|
||||
"restartRequired": "需要重启"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "更新标记策略",
|
||||
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。",
|
||||
"options": {
|
||||
"sameBase": "按基础模型匹配更新",
|
||||
"any": "显示任何可用更新"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
|
||||
@@ -365,12 +473,17 @@
|
||||
"dateAsc": "最旧",
|
||||
"size": "文件大小",
|
||||
"sizeDesc": "最大",
|
||||
"sizeAsc": "最小"
|
||||
"sizeAsc": "最小",
|
||||
"usage": "使用次数",
|
||||
"usageDesc": "最多",
|
||||
"usageAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新模型列表",
|
||||
"quick": "快速刷新(增量)",
|
||||
"full": "完全重建(完整)"
|
||||
"quick": "同步变更",
|
||||
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "从 Civitai 获取元数据",
|
||||
@@ -391,20 +504,28 @@
|
||||
"favorites": {
|
||||
"title": "仅显示收藏",
|
||||
"action": "收藏"
|
||||
},
|
||||
"updates": {
|
||||
"title": "仅显示可用更新的模型",
|
||||
"action": "更新",
|
||||
"menuLabel": "显示更新选项",
|
||||
"check": "检查更新",
|
||||
"checkTooltip": "检查更新可能耗时。"
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
"selected": "已选中 {count} 项",
|
||||
"selectedSuffix": "已选中",
|
||||
"viewSelected": "查看已选中",
|
||||
"addTags": "为所有添加标签",
|
||||
"setBaseModel": "为所有设置基础模型",
|
||||
"setContentRating": "为全部设置内容评级",
|
||||
"copyAll": "复制全部语法",
|
||||
"refreshAll": "刷新全部元数据",
|
||||
"moveAll": "全部移动到文件夹",
|
||||
"addTags": "为所选中添加标签",
|
||||
"setBaseModel": "为所选中设置基础模型",
|
||||
"setContentRating": "为所选中设置内容评级",
|
||||
"copyAll": "复制所选中语法",
|
||||
"refreshAll": "刷新所选中元数据",
|
||||
"checkUpdates": "检查所选更新",
|
||||
"moveAll": "移动所选中到文件夹",
|
||||
"autoOrganize": "自动整理所选模型",
|
||||
"deleteAll": "删除所有模型",
|
||||
"deleteAll": "删除选中模型",
|
||||
"clear": "清除选择",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "正在初始化自动整理...",
|
||||
@@ -418,6 +539,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 Civitai 数据",
|
||||
"checkUpdates": "检查更新",
|
||||
"relinkCivitai": "重新关联到 Civitai",
|
||||
"copySyntax": "复制 LoRA 语法",
|
||||
"copyFilename": "复制模型文件名",
|
||||
@@ -429,6 +551,7 @@
|
||||
"replacePreview": "替换预览",
|
||||
"setContentRating": "设置内容评级",
|
||||
"moveToFolder": "移动到文件夹",
|
||||
"repairMetadata": "修复元数据",
|
||||
"excludeModel": "排除模型",
|
||||
"deleteModel": "删除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
@@ -439,6 +562,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA 配方",
|
||||
"actions": {
|
||||
"sendCheckpoint": "发送到 ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "导入",
|
||||
@@ -496,10 +622,26 @@
|
||||
"selectLoraRoot": "请选择 LoRA 根目录"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "配方排序...",
|
||||
"name": "名称",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "时间",
|
||||
"dateDesc": "最新",
|
||||
"dateAsc": "最早",
|
||||
"lorasCount": "LoRA 数量",
|
||||
"lorasCountDesc": "最多",
|
||||
"lorasCountAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新配方列表"
|
||||
},
|
||||
"filteredByLora": "按 LoRA 筛选"
|
||||
"filteredByLora": "按 LoRA 筛选",
|
||||
"favorites": {
|
||||
"title": "仅显示收藏",
|
||||
"action": "收藏"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "发现 {count} 个重复组",
|
||||
@@ -525,23 +667,54 @@
|
||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
"prepareError": "准备下载 LoRA 时出错:{message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "正在修复配方元数据...",
|
||||
"success": "配方元数据修复成功",
|
||||
"skipped": "配方已是最新版本,无需修复",
|
||||
"failed": "修复配方失败:{message}",
|
||||
"missingId": "无法修复配方:缺少配方 ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "Checkpoint 模型"
|
||||
"title": "Checkpoint 模型",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "Embedding 模型"
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] VAE & Upscaler Models",
|
||||
"modelTypes": {
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "模型根目录",
|
||||
"modelRoot": "根目录",
|
||||
"collapseAll": "折叠所有文件夹",
|
||||
"pinSidebar": "固定侧边栏",
|
||||
"unpinSidebar": "取消固定侧边栏",
|
||||
"switchToListView": "切换到列表视图",
|
||||
"switchToTreeView": "切换到树状视图",
|
||||
"collapseAllDisabled": "列表视图下不可用"
|
||||
"recursiveOn": "搜索子文件夹",
|
||||
"recursiveOff": "仅搜索当前文件夹",
|
||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "统计",
|
||||
@@ -616,6 +789,14 @@
|
||||
"downloadedPreview": "预览图片已下载",
|
||||
"downloadingFile": "正在下载 {type} 文件",
|
||||
"finalizing": "正在完成下载..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "当前文件:",
|
||||
"downloading": "下载中:{name}",
|
||||
"transferred": "已下载:{downloaded} / {total}",
|
||||
"transferredSimple": "已下载:{downloaded}",
|
||||
"transferredUnknown": "已下载:--",
|
||||
"speed": "速度:{speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -663,6 +844,12 @@
|
||||
"countMessage": "模型将被永久删除。",
|
||||
"action": "全部删除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "检查所有 {type} 的更新?",
|
||||
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
|
||||
"action": "检查全部"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "批量添加标签",
|
||||
"description": "为多个模型添加标签",
|
||||
@@ -736,7 +923,9 @@
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "文件位置已成功打开",
|
||||
"failed": "打开文件位置失败"
|
||||
"failed": "打开文件位置失败",
|
||||
"copied": "路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "路径:{{path}}"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "版本",
|
||||
@@ -759,11 +948,13 @@
|
||||
"addPresetParameter": "添加预设参数...",
|
||||
"strengthMin": "最小强度",
|
||||
"strengthMax": "最大强度",
|
||||
"strengthRange": "强度范围",
|
||||
"strength": "强度",
|
||||
"clipStrength": "Clip 强度",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "数值",
|
||||
"add": "添加"
|
||||
"add": "添加",
|
||||
"invalidRange": "无效的范围格式。请使用 x.x-y.y"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "触发词",
|
||||
@@ -799,13 +990,84 @@
|
||||
"tabs": {
|
||||
"examples": "示例",
|
||||
"description": "模型描述",
|
||||
"recipes": "配方"
|
||||
"recipes": "配方",
|
||||
"versions": "版本"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "模型导航",
|
||||
"previousWithShortcut": "上一个模型(←)",
|
||||
"nextWithShortcut": "下一个模型(→)",
|
||||
"noPrevious": "没有上一个模型",
|
||||
"noNext": "没有下一个模型"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "需要创作者署名",
|
||||
"noDerivatives": "禁止分享合并作品",
|
||||
"noReLicense": "需要相同权限",
|
||||
"restrictionsLabel": "许可证限制"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "正在加载示例图片...",
|
||||
"description": "正在加载模型描述...",
|
||||
"recipes": "正在加载配方...",
|
||||
"examples": "正在加载示例..."
|
||||
"examples": "正在加载示例...",
|
||||
"versions": "正在加载版本..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在一个位置管理该模型的所有版本。",
|
||||
"media": {
|
||||
"placeholder": "无预览"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "未命名版本",
|
||||
"noDetails": "暂无更多信息"
|
||||
},
|
||||
"badges": {
|
||||
"current": "当前版本",
|
||||
"inLibrary": "已在库中",
|
||||
"newer": "较新的版本",
|
||||
"ignored": "已忽略"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"delete": "删除",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"resumeModelUpdates": "继续跟踪该模型的更新",
|
||||
"ignoreModelUpdates": "忽略该模型的更新",
|
||||
"viewLocalVersions": "查看所有本地版本",
|
||||
"viewLocalTooltip": "敬请期待"
|
||||
},
|
||||
"filters": {
|
||||
"label": "基础筛选",
|
||||
"state": {
|
||||
"showAll": "全部版本",
|
||||
"showSameBase": "相同基模型"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "切换为显示所有版本",
|
||||
"showSameBaseVersions": "仅显示与当前基模型匹配的版本"
|
||||
},
|
||||
"empty": "没有与当前基模型筛选匹配的版本。"
|
||||
},
|
||||
"empty": "该模型还没有版本历史。",
|
||||
"error": "加载版本失败。",
|
||||
"missingModelId": "该模型缺少 Civitai 模型 ID。",
|
||||
"confirm": {
|
||||
"delete": "从库中删除此版本?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "已忽略该模型的更新",
|
||||
"modelResumed": "已恢复更新跟踪",
|
||||
"versionIgnored": "已忽略该版本的更新",
|
||||
"versionUnignored": "已重新启用该版本",
|
||||
"versionDeleted": "版本已删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -854,6 +1116,10 @@
|
||||
"title": "初始化统计",
|
||||
"message": "正在处理模型数据以生成统计信息。这可能需要几分钟..."
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
||||
},
|
||||
"tips": {
|
||||
"title": "技巧与提示",
|
||||
"civitai": {
|
||||
@@ -912,11 +1178,19 @@
|
||||
"loraFailedToSend": "发送 LoRA 到工作流失败",
|
||||
"recipeAdded": "配方已追加到工作流",
|
||||
"recipeReplaced": "配方已替换到工作流",
|
||||
"recipeFailedToSend": "发送配方到工作流失败"
|
||||
"recipeFailedToSend": "发送配方到工作流失败",
|
||||
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
|
||||
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
|
||||
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
|
||||
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
|
||||
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||
"noTargetNodeSelected": "未选择目标节点"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
"lora": "LoRA",
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler",
|
||||
"replace": "替换",
|
||||
"append": "追加",
|
||||
"selectTargetNode": "选择目标节点",
|
||||
@@ -925,7 +1199,11 @@
|
||||
"exampleImages": {
|
||||
"opened": "示例图片文件夹已打开",
|
||||
"openingFolder": "正在打开示例图片文件夹",
|
||||
"failedToOpen": "打开示例图片文件夹失败"
|
||||
"failedToOpen": "打开示例图片文件夹失败",
|
||||
"setupRequired": "示例图片存储",
|
||||
"setupDescription": "要添加自定义示例图片,您需要先设置下载位置。",
|
||||
"setupUsage": "此路径用于存储下载的示例图片和自定义图片。",
|
||||
"openSettings": "打开设置"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -957,6 +1235,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "检查更新",
|
||||
"notificationsTitle": "通知中心",
|
||||
"tabs": {
|
||||
"updates": "更新",
|
||||
"messages": "消息"
|
||||
},
|
||||
"updateAvailable": "更新可用",
|
||||
"noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。",
|
||||
"currentVersion": "当前版本",
|
||||
@@ -969,6 +1252,7 @@
|
||||
"checkingUpdates": "正在检查更新...",
|
||||
"checkingMessage": "请稍候,正在检查最新版本。",
|
||||
"showNotifications": "显示更新通知",
|
||||
"latestBadge": "最新",
|
||||
"updateProgress": {
|
||||
"preparing": "正在准备更新...",
|
||||
"installing": "正在安装更新...",
|
||||
@@ -988,6 +1272,13 @@
|
||||
"nightly": {
|
||||
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
|
||||
"enable": "启用 Nightly 更新"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最近的通知",
|
||||
"empty": "暂无最近的横幅通知。",
|
||||
"shown": "{time} 显示",
|
||||
"dismissed": "{time} 关闭",
|
||||
"active": "仍在显示"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1067,6 +1358,9 @@
|
||||
"cannotSend": "无法发送配方:缺少配方 ID",
|
||||
"sendFailed": "发送配方到工作流失败",
|
||||
"sendError": "发送配方到工作流出错",
|
||||
"missingCheckpointPath": "缺少检查点路径",
|
||||
"missingCheckpointInfo": "缺少检查点信息",
|
||||
"downloadCheckpointFailed": "下载检查点失败:{message}",
|
||||
"cannotDelete": "无法删除配方:缺少配方 ID",
|
||||
"deleteConfirmationError": "显示删除确认出错",
|
||||
"deletedSuccessfully": "配方删除成功",
|
||||
@@ -1107,6 +1401,12 @@
|
||||
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
||||
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
||||
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
||||
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
||||
"bulkUpdatesMissing": "所选 {type} 未关联 Civitai 更新",
|
||||
"bulkUpdatesPartialMissing": "已跳过 {missing} 个未关联 Civitai 的所选 {type}",
|
||||
"bulkUpdatesFailed": "检查所选 {type} 的更新失败:{message}",
|
||||
"invalidCharactersRemoved": "文件名中的无效字符已移除",
|
||||
"filenameCannotBeEmpty": "文件名不能为空",
|
||||
"renameFailed": "重命名文件失败:{message}",
|
||||
@@ -1118,6 +1418,7 @@
|
||||
"verificationCompleteSuccess": "验证完成。所有文件均为重复项。",
|
||||
"verificationFailed": "验证哈希失败:{message}",
|
||||
"noTagsToAdd": "没有可添加的标签",
|
||||
"bulkTagsUpdating": "正在更新 {count} 个模型的标签...",
|
||||
"tagsAddedSuccessfully": "已成功为 {count} 个 {type} 添加 {tagCount} 个标签",
|
||||
"tagsReplacedSuccessfully": "已成功为 {count} 个 {type} 替换为 {tagCount} 个标签",
|
||||
"tagsAddFailed": "为 {count} 个模型添加标签失败",
|
||||
@@ -1131,6 +1432,7 @@
|
||||
"settings": {
|
||||
"loraRootsFailed": "加载 LoRA 根目录失败:{message}",
|
||||
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
||||
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
||||
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
||||
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射{plural})",
|
||||
"mappingsCleared": "基础模型路径映射已清除",
|
||||
@@ -1151,7 +1453,26 @@
|
||||
"filters": {
|
||||
"applied": "{message}",
|
||||
"cleared": "筛选已清除",
|
||||
"noCustomFilterToClear": "没有自定义筛选可清除"
|
||||
"noCustomFilterToClear": "没有自定义筛选可清除",
|
||||
"noActiveFilters": "没有可保存的激活筛选"
|
||||
},
|
||||
"presets": {
|
||||
"created": "预设 \"{name}\" 已创建",
|
||||
"deleted": "预设 \"{name}\" 已删除",
|
||||
"applied": "预设 \"{name}\" 已应用",
|
||||
"overwritten": "预设 \"{name}\" 已覆盖",
|
||||
"restored": "默认预设已恢复"
|
||||
},
|
||||
"error": {
|
||||
"presetNameEmpty": "预设名称不能为空",
|
||||
"presetNameTooLong": "预设名称不能超过 {max} 个字符",
|
||||
"presetNameInvalidChars": "预设名称包含无效字符",
|
||||
"presetNameExists": "已存在同名预设",
|
||||
"maxPresetsReached": "最多允许 {max} 个预设。删除一个以添加更多。",
|
||||
"presetNotFound": "预设未找到",
|
||||
"invalidPreset": "无效的预设数据",
|
||||
"deletePresetFailed": "删除预设失败",
|
||||
"applyPresetFailed": "应用预设失败"
|
||||
},
|
||||
"downloads": {
|
||||
"imagesCompleted": "示例图片{action}完成",
|
||||
@@ -1167,7 +1488,7 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "无法加载训练词",
|
||||
"tooLong": "触发词不能超过30个词",
|
||||
"tooLong": "触发词不能超过100个词",
|
||||
"tooMany": "最多允许30个触发词",
|
||||
"alreadyExists": "该触发词已存在",
|
||||
"updateSuccess": "触发词更新成功",
|
||||
@@ -1216,6 +1537,8 @@
|
||||
"pauseFailed": "暂停下载失败:{error}",
|
||||
"downloadResumed": "下载已恢复",
|
||||
"resumeFailed": "恢复下载失败:{error}",
|
||||
"downloadStopped": "下载已取消",
|
||||
"stopFailed": "取消下载失败:{error}",
|
||||
"deleted": "示例图片已删除",
|
||||
"deleteFailed": "删除示例图片失败",
|
||||
"setPreviewFailed": "设置预览图片失败"
|
||||
@@ -1236,6 +1559,8 @@
|
||||
"metadataRefreshed": "元数据刷新成功",
|
||||
"metadataRefreshFailed": "刷新元数据失败:{message}",
|
||||
"metadataUpdateComplete": "元数据更新完成",
|
||||
"operationCancelled": "操作已由用户取消",
|
||||
"operationCancelledPartial": "操作已取消。已处理 {success} 个项目。",
|
||||
"metadataFetchFailed": "获取元数据失败:{message}",
|
||||
"bulkMetadataCompleteAll": "全部 {count} 个 {type} 元数据刷新成功",
|
||||
"bulkMetadataCompletePartial": "已刷新 {success}/{total} 个 {type} 元数据",
|
||||
@@ -1252,7 +1577,8 @@
|
||||
"bulkMoveFailures": "移动失败:\n{failures}",
|
||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}"
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1264,10 +1590,10 @@
|
||||
"seconds": "秒后刷新"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
"title": "LM 浏览器插件限时优惠 ⚡",
|
||||
"content": "来爱发电为Lora Manager项目发电,支持项目持续开发的同时,获取浏览器插件验证码,按季支付更优惠!支付宝/微信方便支付。感谢支持!🚀",
|
||||
"supportCta": "为LM发电",
|
||||
"learnMore": "浏览器插件教程"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"next": "下一步",
|
||||
"backToTop": "回到頂部",
|
||||
"settings": "設定",
|
||||
"help": "說明"
|
||||
"help": "說明",
|
||||
"add": "新增"
|
||||
},
|
||||
"status": {
|
||||
"loading": "載入中...",
|
||||
@@ -32,7 +33,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 位元組",
|
||||
@@ -101,7 +102,12 @@
|
||||
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
||||
"toggleBlur": "切換模糊",
|
||||
"show": "顯示",
|
||||
"openExampleImages": "開啟範例圖片資料夾"
|
||||
"openExampleImages": "開啟範例圖片資料夾",
|
||||
"replacePreview": "更換預覽圖",
|
||||
"copyCheckpointName": "複製檢查點名稱",
|
||||
"copyEmbeddingName": "複製嵌入名稱",
|
||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "成熟內容",
|
||||
@@ -115,12 +121,20 @@
|
||||
"updateFailed": "更新收藏狀態失敗"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "傳送 checkpoint 到工作流 - 功能尚未實現"
|
||||
"checkpointNotImplemented": "傳送 checkpoint 到工作流 - 功能尚未實現",
|
||||
"missingPath": "無法確定此卡片的模型路徑"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "檢查範例圖片時發生錯誤",
|
||||
"missingHash": "缺少模型雜湊資訊。",
|
||||
"noRemoteImagesAvailable": "此模型在 Civitai 上無遠端範例圖片"
|
||||
},
|
||||
"badges": {
|
||||
"update": "更新",
|
||||
"updateAvailable": "有可用更新"
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次數"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,12 +143,33 @@
|
||||
"missingPath": "請先設定下載位置再下載範例圖片。",
|
||||
"unavailable": "範例圖片下載目前尚不可用。請在頁面載入完成後再試一次。"
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "檢查更新",
|
||||
"loading": "正在檢查 {type} 更新...",
|
||||
"success": "找到 {count} 個 {type} 更新",
|
||||
"none": "所有 {type} 都是最新版本",
|
||||
"error": "檢查 {type} 更新失敗:{message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "清理範例圖片資料夾",
|
||||
"success": "已將 {count} 個資料夾移至已刪除資料夾",
|
||||
"none": "沒有需要清理的範例圖片資料夾",
|
||||
"partial": "清理完成,有 {failures} 個資料夾略過",
|
||||
"error": "清理範例圖片資料夾失敗:{message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "重新整理授權中繼資料",
|
||||
"loading": "正在重新整理 {typePlural} 的授權中繼資料...",
|
||||
"success": "已更新 {count} 個 {typePlural} 的授權中繼資料",
|
||||
"none": "所有 {typePlural} 已具備授權中繼資料",
|
||||
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "修復配方資料",
|
||||
"loading": "正在修復配方資料...",
|
||||
"success": "成功修復 {count} 個配方。",
|
||||
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||
"error": "配方修復失敗:{message}"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -144,6 +179,7 @@
|
||||
"recipes": "配方",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"misc": "[TODO: Translate] Misc",
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
@@ -152,7 +188,8 @@
|
||||
"loras": "搜尋 LoRA...",
|
||||
"recipes": "搜尋配方...",
|
||||
"checkpoints": "搜尋 checkpoint...",
|
||||
"embeddings": "搜尋 embedding..."
|
||||
"embeddings": "搜尋 embedding...",
|
||||
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
|
||||
},
|
||||
"options": "搜尋選項",
|
||||
"searchIn": "搜尋範圍:",
|
||||
@@ -164,13 +201,30 @@
|
||||
"creator": "創作者",
|
||||
"title": "配方標題",
|
||||
"loraName": "LoRA 檔案名稱",
|
||||
"loraModel": "LoRA 模型名稱"
|
||||
"loraModel": "LoRA 模型名稱",
|
||||
"prompt": "提示詞"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "篩選模型",
|
||||
"presets": "預設",
|
||||
"savePreset": "將目前啟用的篩選器儲存為新預設。",
|
||||
"savePresetDisabledActive": "無法儲存:已有預設處於啟用狀態。修改篩選器後可儲存新預設",
|
||||
"savePresetDisabledNoFilters": "先選擇篩選器,然後儲存為預設",
|
||||
"savePresetPrompt": "輸入預設名稱:",
|
||||
"presetClickTooltip": "點擊套用預設 \"{name}\"",
|
||||
"presetDeleteTooltip": "刪除預設",
|
||||
"presetDeleteConfirm": "刪除預設 \"{name}\"?",
|
||||
"presetDeleteConfirmClick": "再次點擊確認",
|
||||
"presetOverwriteConfirm": "預設 \"{name}\" 已存在。是否覆蓋?",
|
||||
"presetNamePlaceholder": "預設名稱...",
|
||||
"baseModel": "基礎模型",
|
||||
"modelTags": "標籤(前 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
"noTags": "無標籤",
|
||||
"clearAll": "清除所有篩選"
|
||||
},
|
||||
"theme": {
|
||||
@@ -181,6 +235,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "檢查更新",
|
||||
"notifications": "通知",
|
||||
"support": "支援"
|
||||
}
|
||||
},
|
||||
@@ -192,19 +247,29 @@
|
||||
"label": "開啟設定資料夾",
|
||||
"tooltip": "開啟包含 settings.json 的資料夾",
|
||||
"success": "已開啟 settings.json 資料夾",
|
||||
"failed": "無法開啟 settings.json 資料夾"
|
||||
"failed": "無法開啟 settings.json 資料夾",
|
||||
"copied": "設定路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "設定路徑:{{path}}"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "內容過濾",
|
||||
"videoSettings": "影片設定",
|
||||
"layoutSettings": "版面設定",
|
||||
"folderSettings": "資料夾設定",
|
||||
"priorityTags": "優先標籤",
|
||||
"downloadPathTemplates": "下載路徑範本",
|
||||
"exampleImages": "範例圖片",
|
||||
"updateFlags": "更新標記",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"misc": "其他",
|
||||
"metadataArchive": "中繼資料封存資料庫",
|
||||
"storageLocation": "設定位置",
|
||||
"proxySettings": "代理設定"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "可攜式模式",
|
||||
"locationHelp": "啟用可將 settings.json 保存在儲存庫中;停用則保存在使用者設定目錄。"
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "模糊 NSFW 內容",
|
||||
"blurNsfwContentHelp": "模糊成熟(NSFW)內容預覽圖片",
|
||||
@@ -215,6 +280,15 @@
|
||||
"autoplayOnHover": "滑鼠懸停自動播放影片",
|
||||
"autoplayOnHoverHelp": "僅在滑鼠懸停時播放影片預覽"
|
||||
},
|
||||
"autoOrganizeExclusions": {
|
||||
"label": "自動整理排除項目",
|
||||
"placeholder": "範例: curated/*, */backups/*; *_temp.safetensors",
|
||||
"help": "跳過符合這些萬用字元模式的檔案。多個模式請用逗號或分號分隔。",
|
||||
"validation": {
|
||||
"noPatterns": "請輸入至少一個以逗號或分號分隔的模式。",
|
||||
"saveFailed": "無法儲存排除項目:{message}"
|
||||
}
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "顯示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -224,21 +298,31 @@
|
||||
},
|
||||
"displayDensityHelp": "選擇每行顯示卡片數量:",
|
||||
"displayDensityDetails": {
|
||||
"default": "預設:5(1080p)、6(2K)、8(4K)",
|
||||
"medium": "中等:6(1080p)、7(2K)、9(4K)",
|
||||
"compact": "緊湊:7(1080p)、8(2K)、10(4K)"
|
||||
"default": "5(1080p)、6(2K)、8(4K)",
|
||||
"medium": "6(1080p)、7(2K)、9(4K)",
|
||||
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||
},
|
||||
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
|
||||
"showFolderSidebar": "顯示資料夾側邊欄",
|
||||
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
|
||||
"cardInfoDisplay": "卡片資訊顯示",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "永遠顯示",
|
||||
"hover": "滑鼠懸停顯示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "永遠顯示:標題與頁腳始終可見",
|
||||
"hover": "滑鼠懸停顯示:標題與頁腳僅在滑鼠懸停時顯示"
|
||||
}
|
||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "開啟範例圖片",
|
||||
"replacePreview": "更換預覽圖"
|
||||
},
|
||||
"modelCardFooterActionHelp": "選擇右下角卡片按鈕的功能",
|
||||
"modelNameDisplay": "模型名稱顯示",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "模型名稱",
|
||||
"fileName": "檔案名稱"
|
||||
},
|
||||
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "使用中的資料庫",
|
||||
@@ -249,10 +333,32 @@
|
||||
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
|
||||
"defaultCheckpointRoot": "預設 Checkpoint 根目錄",
|
||||
"defaultCheckpointRootHelp": "設定下載、匯入和移動時的預設 Checkpoint 根目錄",
|
||||
"defaultUnetRoot": "預設 Diffusion Model 根目錄",
|
||||
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
||||
"defaultEmbeddingRoot": "預設 Embedding 根目錄",
|
||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||
"noDefault": "未設定預設"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "優先標籤",
|
||||
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "開啟優先標籤說明",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "優先標籤已更新。",
|
||||
"saveError": "更新優先標籤失敗。",
|
||||
"loadingSuggestions": "正在載入建議...",
|
||||
"validation": {
|
||||
"missingClosingParen": "項目 {index} 缺少右括號。",
|
||||
"missingCanonical": "項目 {index} 必須包含正規標籤名稱。",
|
||||
"duplicateCanonical": "正規標籤 \"{tag}\" 出現多於一次。",
|
||||
"unknown": "優先標籤設定無效。"
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "下載路徑範本",
|
||||
"help": "設定從 Civitai 下載時不同模型類型的資料夾結構。",
|
||||
@@ -300,6 +406,14 @@
|
||||
"download": "下載",
|
||||
"restartRequired": "需要重新啟動"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "更新標記策略",
|
||||
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
|
||||
"options": {
|
||||
"sameBase": "依基礎模型匹配更新",
|
||||
"any": "顯示任何可用更新"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
|
||||
@@ -359,12 +473,17 @@
|
||||
"dateAsc": "最舊",
|
||||
"size": "檔案大小",
|
||||
"sizeDesc": "最大",
|
||||
"sizeAsc": "最小"
|
||||
"sizeAsc": "最小",
|
||||
"usage": "使用次數",
|
||||
"usageDesc": "最多",
|
||||
"usageAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理模型列表",
|
||||
"quick": "快速刷新(增量)",
|
||||
"full": "完整重建(全部)"
|
||||
"quick": "同步變更",
|
||||
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "從 Civitai 取得 metadata",
|
||||
@@ -385,6 +504,13 @@
|
||||
"favorites": {
|
||||
"title": "僅顯示收藏",
|
||||
"action": "收藏"
|
||||
},
|
||||
"updates": {
|
||||
"title": "僅顯示可用更新的模型",
|
||||
"action": "更新",
|
||||
"menuLabel": "顯示更新選項",
|
||||
"check": "檢查更新",
|
||||
"checkTooltip": "檢查更新可能耗時。"
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -396,6 +522,7 @@
|
||||
"setContentRating": "為全部設定內容分級",
|
||||
"copyAll": "複製全部語法",
|
||||
"refreshAll": "刷新全部 metadata",
|
||||
"checkUpdates": "檢查所選更新",
|
||||
"moveAll": "全部移動到資料夾",
|
||||
"autoOrganize": "自動整理所選模型",
|
||||
"deleteAll": "刪除全部模型",
|
||||
@@ -412,6 +539,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 Civitai 資料",
|
||||
"checkUpdates": "檢查更新",
|
||||
"relinkCivitai": "重新連結 Civitai",
|
||||
"copySyntax": "複製 LoRA 語法",
|
||||
"copyFilename": "複製模型檔名",
|
||||
@@ -423,6 +551,7 @@
|
||||
"replacePreview": "更換預覽圖",
|
||||
"setContentRating": "設定內容分級",
|
||||
"moveToFolder": "移動到資料夾",
|
||||
"repairMetadata": "修復元數據",
|
||||
"excludeModel": "排除模型",
|
||||
"deleteModel": "刪除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
@@ -433,6 +562,9 @@
|
||||
},
|
||||
"recipes": {
|
||||
"title": "LoRA 配方",
|
||||
"actions": {
|
||||
"sendCheckpoint": "傳送到 ComfyUI"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "匯入",
|
||||
@@ -490,10 +622,26 @@
|
||||
"selectLoraRoot": "請選擇 LoRA 根目錄"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "配方排序...",
|
||||
"name": "名稱",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "時間",
|
||||
"dateDesc": "最新",
|
||||
"dateAsc": "最舊",
|
||||
"lorasCount": "LoRA 數量",
|
||||
"lorasCountDesc": "最多",
|
||||
"lorasCountAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理配方列表"
|
||||
},
|
||||
"filteredByLora": "已依 LoRA 篩選"
|
||||
"filteredByLora": "已依 LoRA 篩選",
|
||||
"favorites": {
|
||||
"title": "僅顯示收藏",
|
||||
"action": "收藏"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "發現 {count} 組重複項",
|
||||
@@ -519,23 +667,54 @@
|
||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "正在修復配方元數據...",
|
||||
"success": "配方元數據修復成功",
|
||||
"skipped": "配方已是最新版本,無需修復",
|
||||
"failed": "修復配方失敗:{message}",
|
||||
"missingId": "無法修復配方:缺少配方 ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "Checkpoint 模型"
|
||||
"title": "Checkpoint 模型",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "Embedding 模型"
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] VAE & Upscaler Models",
|
||||
"modelTypes": {
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "模型根目錄",
|
||||
"modelRoot": "根目錄",
|
||||
"collapseAll": "全部摺疊資料夾",
|
||||
"pinSidebar": "固定側邊欄",
|
||||
"unpinSidebar": "取消固定側邊欄",
|
||||
"switchToListView": "切換至列表檢視",
|
||||
"switchToTreeView": "切換至樹狀檢視",
|
||||
"collapseAllDisabled": "列表檢視下不可用"
|
||||
"switchToTreeView": "切換到樹狀檢視",
|
||||
"recursiveOn": "搜尋子資料夾",
|
||||
"recursiveOff": "僅搜尋目前資料夾",
|
||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||
"collapseAllDisabled": "列表檢視下不可用",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "統計",
|
||||
@@ -610,6 +789,14 @@
|
||||
"downloadedPreview": "已下載預覽圖片",
|
||||
"downloadingFile": "正在下載 {type} 檔案",
|
||||
"finalizing": "完成下載中..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "目前檔案:",
|
||||
"downloading": "下載中:{name}",
|
||||
"transferred": "已下載:{downloaded} / {total}",
|
||||
"transferredSimple": "已下載:{downloaded}",
|
||||
"transferredUnknown": "已下載:--",
|
||||
"speed": "速度:{speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -657,6 +844,12 @@
|
||||
"countMessage": "模型將被永久刪除。",
|
||||
"action": "全部刪除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "要檢查所有 {type} 的更新嗎?",
|
||||
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
|
||||
"action": "全部檢查"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "新增標籤到多個模型",
|
||||
"description": "新增標籤到",
|
||||
@@ -730,7 +923,9 @@
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "檔案位置已成功開啟",
|
||||
"failed": "開啟檔案位置失敗"
|
||||
"failed": "開啟檔案位置失敗",
|
||||
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "路徑:{{path}}"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "版本",
|
||||
@@ -753,11 +948,13 @@
|
||||
"addPresetParameter": "新增預設參數...",
|
||||
"strengthMin": "最小強度",
|
||||
"strengthMax": "最大強度",
|
||||
"strengthRange": "強度範圍",
|
||||
"strength": "強度",
|
||||
"clipStrength": "Clip 強度",
|
||||
"clipSkip": "Clip Skip",
|
||||
"valuePlaceholder": "數值",
|
||||
"add": "新增"
|
||||
"add": "新增",
|
||||
"invalidRange": "無效的範圍格式。請使用 x.x-y.y"
|
||||
},
|
||||
"triggerWords": {
|
||||
"label": "觸發詞",
|
||||
@@ -793,13 +990,84 @@
|
||||
"tabs": {
|
||||
"examples": "範例圖片",
|
||||
"description": "模型描述",
|
||||
"recipes": "配方"
|
||||
"recipes": "配方",
|
||||
"versions": "版本"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "模型導覽",
|
||||
"previousWithShortcut": "上一個模型(←)",
|
||||
"nextWithShortcut": "下一個模型(→)",
|
||||
"noPrevious": "沒有上一個模型",
|
||||
"noNext": "沒有下一個模型"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "需要創作者標示",
|
||||
"noDerivatives": "禁止分享合併作品",
|
||||
"noReLicense": "需要相同授權",
|
||||
"restrictionsLabel": "授權限制"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "載入範例圖片中...",
|
||||
"description": "載入模型描述中...",
|
||||
"recipes": "載入配方中...",
|
||||
"examples": "載入範例中..."
|
||||
"examples": "載入範例中...",
|
||||
"versions": "載入版本中..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在同一位置追蹤並管理此模型的所有版本。",
|
||||
"media": {
|
||||
"placeholder": "無預覽"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "未命名版本",
|
||||
"noDetails": "沒有其他資訊"
|
||||
},
|
||||
"badges": {
|
||||
"current": "目前版本",
|
||||
"inLibrary": "已在庫中",
|
||||
"newer": "較新版本",
|
||||
"ignored": "已忽略"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"delete": "刪除",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
||||
"ignoreModelUpdates": "忽略此模型的更新",
|
||||
"viewLocalVersions": "檢視所有本地版本",
|
||||
"viewLocalTooltip": "敬請期待"
|
||||
},
|
||||
"filters": {
|
||||
"label": "基礎篩選",
|
||||
"state": {
|
||||
"showAll": "所有版本",
|
||||
"showSameBase": "相同基礎模型"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "切換為顯示所有版本",
|
||||
"showSameBaseVersions": "僅顯示與目前基礎模型相符的版本"
|
||||
},
|
||||
"empty": "沒有符合目前基礎模型篩選的版本。"
|
||||
},
|
||||
"empty": "此模型尚無版本歷史。",
|
||||
"error": "載入版本失敗。",
|
||||
"missingModelId": "此模型缺少 Civitai 模型 ID。",
|
||||
"confirm": {
|
||||
"delete": "要從庫中刪除此版本嗎?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "已忽略此模型的更新",
|
||||
"modelResumed": "已恢復更新追蹤",
|
||||
"versionIgnored": "已忽略此版本的更新",
|
||||
"versionUnignored": "已重新啟用此版本",
|
||||
"versionDeleted": "已刪除此版本"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,6 +1116,10 @@
|
||||
"title": "初始化統計",
|
||||
"message": "正在處理模型資料以產生統計,可能需要幾分鐘..."
|
||||
},
|
||||
"misc": {
|
||||
"title": "[TODO: Translate] Initializing Misc Model Manager",
|
||||
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
|
||||
},
|
||||
"tips": {
|
||||
"title": "小技巧",
|
||||
"civitai": {
|
||||
@@ -906,11 +1178,19 @@
|
||||
"loraFailedToSend": "傳送 LoRA 到工作流失敗",
|
||||
"recipeAdded": "配方已附加到工作流",
|
||||
"recipeReplaced": "配方已取代於工作流",
|
||||
"recipeFailedToSend": "傳送配方到工作流失敗"
|
||||
"recipeFailedToSend": "傳送配方到工作流失敗",
|
||||
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
|
||||
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
|
||||
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
|
||||
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
|
||||
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||
"noTargetNodeSelected": "未選擇目標節點"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
"lora": "LoRA",
|
||||
"vae": "[TODO: Translate] VAE",
|
||||
"upscaler": "[TODO: Translate] Upscaler",
|
||||
"replace": "取代",
|
||||
"append": "附加",
|
||||
"selectTargetNode": "選擇目標節點",
|
||||
@@ -919,7 +1199,11 @@
|
||||
"exampleImages": {
|
||||
"opened": "範例圖片資料夾已開啟",
|
||||
"openingFolder": "正在開啟範例圖片資料夾",
|
||||
"failedToOpen": "開啟範例圖片資料夾失敗"
|
||||
"failedToOpen": "開啟範例圖片資料夾失敗",
|
||||
"setupRequired": "範例圖片儲存",
|
||||
"setupDescription": "要新增自訂範例圖片,您需要先設定下載位置。",
|
||||
"setupUsage": "此路徑用於儲存下載的範例圖片和自訂圖片。",
|
||||
"openSettings": "開啟設定"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
@@ -951,6 +1235,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "檢查更新",
|
||||
"notificationsTitle": "通知中心",
|
||||
"tabs": {
|
||||
"updates": "更新",
|
||||
"messages": "訊息"
|
||||
},
|
||||
"updateAvailable": "有新版本可用",
|
||||
"noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。",
|
||||
"currentVersion": "目前版本",
|
||||
@@ -963,6 +1252,7 @@
|
||||
"checkingUpdates": "正在檢查更新...",
|
||||
"checkingMessage": "請稍候,正在檢查最新版本。",
|
||||
"showNotifications": "顯示更新通知",
|
||||
"latestBadge": "最新",
|
||||
"updateProgress": {
|
||||
"preparing": "正在準備更新...",
|
||||
"installing": "正在安裝更新...",
|
||||
@@ -982,6 +1272,13 @@
|
||||
"nightly": {
|
||||
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
|
||||
"enable": "啟用 Nightly 更新"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最新通知",
|
||||
"empty": "目前沒有最近的橫幅通知。",
|
||||
"shown": "{time} 顯示",
|
||||
"dismissed": "{time} 關閉",
|
||||
"active": "仍在顯示"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1061,6 +1358,9 @@
|
||||
"cannotSend": "無法傳送配方:缺少配方 ID",
|
||||
"sendFailed": "傳送配方到工作流失敗",
|
||||
"sendError": "傳送配方到工作流錯誤",
|
||||
"missingCheckpointPath": "缺少檢查點路徑",
|
||||
"missingCheckpointInfo": "缺少檢查點資訊",
|
||||
"downloadCheckpointFailed": "下載檢查點失敗:{message}",
|
||||
"cannotDelete": "無法刪除配方:缺少配方 ID",
|
||||
"deleteConfirmationError": "顯示刪除確認時發生錯誤",
|
||||
"deletedSuccessfully": "配方已成功刪除",
|
||||
@@ -1101,6 +1401,12 @@
|
||||
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
||||
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
||||
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
||||
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
||||
"bulkUpdatesMissing": "所選 {type} 未連結 Civitai 更新",
|
||||
"bulkUpdatesPartialMissing": "已略過 {missing} 個未連結 Civitai 的所選 {type}",
|
||||
"bulkUpdatesFailed": "檢查所選 {type} 更新失敗:{message}",
|
||||
"invalidCharactersRemoved": "已移除檔名中的無效字元",
|
||||
"filenameCannotBeEmpty": "檔案名稱不可為空",
|
||||
"renameFailed": "重新命名檔案失敗:{message}",
|
||||
@@ -1112,6 +1418,7 @@
|
||||
"verificationCompleteSuccess": "驗證完成。所有檔案均確認為重複項。",
|
||||
"verificationFailed": "驗證雜湊失敗:{message}",
|
||||
"noTagsToAdd": "沒有可新增的標籤",
|
||||
"bulkTagsUpdating": "正在更新 {count} 個模型的標籤...",
|
||||
"tagsAddedSuccessfully": "已成功將 {tagCount} 個標籤新增到 {count} 個 {type}",
|
||||
"tagsReplacedSuccessfully": "已成功以 {tagCount} 個標籤取代 {count} 個 {type} 的標籤",
|
||||
"tagsAddFailed": "新增標籤到 {count} 個模型失敗",
|
||||
@@ -1125,6 +1432,7 @@
|
||||
"settings": {
|
||||
"loraRootsFailed": "載入 LoRA 根目錄失敗:{message}",
|
||||
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
||||
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
|
||||
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
||||
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
||||
"mappingsCleared": "基礎模型路徑對應已清除",
|
||||
@@ -1145,7 +1453,26 @@
|
||||
"filters": {
|
||||
"applied": "{message}",
|
||||
"cleared": "篩選已清除",
|
||||
"noCustomFilterToClear": "無自訂篩選可清除"
|
||||
"noCustomFilterToClear": "無自訂篩選可清除",
|
||||
"noActiveFilters": "沒有可儲存的啟用篩選"
|
||||
},
|
||||
"presets": {
|
||||
"created": "預設 \"{name}\" 已建立",
|
||||
"deleted": "預設 \"{name}\" 已刪除",
|
||||
"applied": "預設 \"{name}\" 已套用",
|
||||
"overwritten": "預設 \"{name}\" 已覆蓋",
|
||||
"restored": "預設設定已恢復"
|
||||
},
|
||||
"error": {
|
||||
"presetNameEmpty": "預設名稱不能為空",
|
||||
"presetNameTooLong": "預設名稱不能超過 {max} 個字元",
|
||||
"presetNameInvalidChars": "預設名稱包含無效字元",
|
||||
"presetNameExists": "已存在同名預設",
|
||||
"maxPresetsReached": "最多允許 {max} 個預設。刪除一個以新增更多。",
|
||||
"presetNotFound": "預設未找到",
|
||||
"invalidPreset": "無效的預設資料",
|
||||
"deletePresetFailed": "刪除預設失敗",
|
||||
"applyPresetFailed": "套用預設失敗"
|
||||
},
|
||||
"downloads": {
|
||||
"imagesCompleted": "範例圖片{action}完成",
|
||||
@@ -1161,7 +1488,7 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "無法載入訓練詞",
|
||||
"tooLong": "觸發詞不可超過 30 個字",
|
||||
"tooLong": "觸發詞不可超過 100 個字",
|
||||
"tooMany": "最多允許 30 個觸發詞",
|
||||
"alreadyExists": "此觸發詞已存在",
|
||||
"updateSuccess": "觸發詞已更新",
|
||||
@@ -1210,6 +1537,8 @@
|
||||
"pauseFailed": "暫停下載失敗:{error}",
|
||||
"downloadResumed": "下載已恢復",
|
||||
"resumeFailed": "恢復下載失敗:{error}",
|
||||
"downloadStopped": "下載已取消",
|
||||
"stopFailed": "取消下載失敗:{error}",
|
||||
"deleted": "範例圖片已刪除",
|
||||
"deleteFailed": "刪除範例圖片失敗",
|
||||
"setPreviewFailed": "設定預覽圖片失敗"
|
||||
@@ -1230,6 +1559,8 @@
|
||||
"metadataRefreshed": "metadata 已成功刷新",
|
||||
"metadataRefreshFailed": "刷新 metadata 失敗:{message}",
|
||||
"metadataUpdateComplete": "metadata 更新完成",
|
||||
"operationCancelled": "操作已由用戶取消",
|
||||
"operationCancelledPartial": "操作已取消。已處理 {success} 個項目。",
|
||||
"metadataFetchFailed": "取得 metadata 失敗:{message}",
|
||||
"bulkMetadataCompleteAll": "已成功刷新全部 {count} 個 {type}",
|
||||
"bulkMetadataCompletePartial": "已刷新 {success} / {total} 個 {type}",
|
||||
@@ -1246,7 +1577,8 @@
|
||||
"bulkMoveFailures": "移動失敗:\n{failures}",
|
||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}"
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -114,6 +114,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -137,6 +138,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1611,6 +1613,7 @@
|
||||
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.0.1",
|
||||
"data-urls": "^5.0.0",
|
||||
|
||||
532
py/config.py
532
py/config.py
@@ -1,13 +1,16 @@
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import folder_paths # type: ignore
|
||||
from typing import Dict, Iterable, List, Mapping, Set
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
||||
import logging
|
||||
import json
|
||||
import urllib.parse
|
||||
import time
|
||||
|
||||
from .utils.settings_paths import ensure_settings_file
|
||||
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
||||
from .utils.settings_paths import ensure_settings_file, get_settings_dir, load_settings_template
|
||||
|
||||
# Use an environment variable to control standalone mode
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
@@ -45,6 +48,30 @@ def _normalize_folder_paths_for_comparison(
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_library_folder_paths(
|
||||
library_payload: Mapping[str, Any]
|
||||
) -> Dict[str, Set[str]]:
|
||||
"""Return normalized folder paths extracted from a library payload."""
|
||||
|
||||
folder_paths = library_payload.get("folder_paths")
|
||||
if isinstance(folder_paths, Mapping):
|
||||
return _normalize_folder_paths_for_comparison(folder_paths)
|
||||
return {}
|
||||
|
||||
|
||||
def _get_template_folder_paths() -> Dict[str, Set[str]]:
|
||||
"""Return normalized folder paths defined in the bundled template."""
|
||||
|
||||
template_payload = load_settings_template()
|
||||
if not template_payload:
|
||||
return {}
|
||||
|
||||
folder_paths = template_payload.get("folder_paths")
|
||||
if isinstance(folder_paths, Mapping):
|
||||
return _normalize_folder_paths_for_comparison(folder_paths)
|
||||
return {}
|
||||
|
||||
|
||||
class Config:
|
||||
"""Global configuration for LoRA Manager"""
|
||||
|
||||
@@ -56,15 +83,19 @@ class Config:
|
||||
self._path_mappings: Dict[str, str] = {}
|
||||
# Normalized preview root directories used to validate preview access
|
||||
self._preview_root_paths: Set[Path] = set()
|
||||
# Fingerprint of the symlink layout from the last successful scan
|
||||
self._cached_fingerprint: Optional[Dict[str, object]] = None
|
||||
self.loras_roots = self._init_lora_paths()
|
||||
self.checkpoints_roots = None
|
||||
self.unet_roots = None
|
||||
self.embeddings_roots = None
|
||||
self.vae_roots = None
|
||||
self.upscaler_roots = None
|
||||
self.base_models_roots = self._init_checkpoint_paths()
|
||||
self.embeddings_roots = self._init_embedding_paths()
|
||||
self.misc_roots = self._init_misc_paths()
|
||||
# Scan symbolic links during initialization
|
||||
self._scan_symbolic_links()
|
||||
self._rebuild_preview_roots()
|
||||
self._initialize_symlink_mappings()
|
||||
|
||||
if not standalone_mode:
|
||||
# Save the paths to settings.json when running in ComfyUI mode
|
||||
@@ -74,24 +105,71 @@ class Config:
|
||||
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
||||
try:
|
||||
ensure_settings_file(logger)
|
||||
from .services.settings_manager import settings as settings_service
|
||||
from .services.settings_manager import get_settings_manager
|
||||
|
||||
settings_service = get_settings_manager()
|
||||
libraries = settings_service.get_libraries()
|
||||
comfy_library = libraries.get("comfyui", {})
|
||||
default_library = libraries.get("default", {})
|
||||
|
||||
template_folder_paths = _get_template_folder_paths()
|
||||
default_library_paths: Dict[str, Set[str]] = {}
|
||||
if isinstance(default_library, Mapping):
|
||||
default_library_paths = _normalize_library_folder_paths(default_library)
|
||||
|
||||
libraries_changed = False
|
||||
if (
|
||||
isinstance(default_library, Mapping)
|
||||
and template_folder_paths
|
||||
and default_library_paths == template_folder_paths
|
||||
):
|
||||
if "comfyui" in libraries:
|
||||
try:
|
||||
settings_service.delete_library("default")
|
||||
libraries_changed = True
|
||||
logger.info("Removed template 'default' library entry")
|
||||
except Exception as delete_error:
|
||||
logger.debug(
|
||||
"Failed to delete template 'default' library: %s",
|
||||
delete_error,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
settings_service.rename_library("default", "comfyui")
|
||||
libraries_changed = True
|
||||
logger.info("Renamed template 'default' library to 'comfyui'")
|
||||
except Exception as rename_error:
|
||||
logger.debug(
|
||||
"Failed to rename template 'default' library: %s",
|
||||
rename_error,
|
||||
)
|
||||
|
||||
if libraries_changed:
|
||||
libraries = settings_service.get_libraries()
|
||||
comfy_library = libraries.get("comfyui", {})
|
||||
default_library = libraries.get("default", {})
|
||||
|
||||
target_folder_paths = {
|
||||
'loras': list(self.loras_roots),
|
||||
'checkpoints': list(self.checkpoints_roots or []),
|
||||
'unet': list(self.unet_roots or []),
|
||||
'embeddings': list(self.embeddings_roots or []),
|
||||
'vae': list(self.vae_roots or []),
|
||||
'upscale_models': list(self.upscaler_roots or []),
|
||||
}
|
||||
|
||||
normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths)
|
||||
|
||||
if (not comfy_library and default_library and normalized_target_paths and
|
||||
_normalize_folder_paths_for_comparison(default_library.get("folder_paths", {})) ==
|
||||
normalized_target_paths):
|
||||
normalized_default_paths: Optional[Dict[str, Set[str]]] = None
|
||||
if isinstance(default_library, Mapping):
|
||||
normalized_default_paths = _normalize_library_folder_paths(default_library)
|
||||
|
||||
if (
|
||||
not comfy_library
|
||||
and default_library
|
||||
and normalized_target_paths
|
||||
and normalized_default_paths == normalized_target_paths
|
||||
):
|
||||
try:
|
||||
settings_service.rename_library("default", "comfyui")
|
||||
logger.info("Renamed legacy 'default' library to 'comfyui'")
|
||||
@@ -151,45 +229,332 @@ class Config:
|
||||
logger.error(f"Error checking link status for {path}: {e}")
|
||||
return False
|
||||
|
||||
def _entry_is_symlink(self, entry: os.DirEntry) -> bool:
|
||||
"""Check if a directory entry is a symlink, including Windows junctions."""
|
||||
if entry.is_symlink():
|
||||
return True
|
||||
if platform.system() == 'Windows':
|
||||
try:
|
||||
import ctypes
|
||||
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
||||
attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path)
|
||||
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _normalize_path(self, path: str) -> str:
|
||||
return os.path.normpath(path).replace(os.sep, '/')
|
||||
|
||||
def _get_symlink_cache_path(self) -> Path:
|
||||
canonical_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True)
|
||||
return Path(canonical_path)
|
||||
|
||||
def _symlink_roots(self) -> List[str]:
|
||||
roots: List[str] = []
|
||||
roots.extend(self.loras_roots or [])
|
||||
roots.extend(self.base_models_roots or [])
|
||||
roots.extend(self.embeddings_roots or [])
|
||||
roots.extend(self.misc_roots or [])
|
||||
return roots
|
||||
|
||||
def _build_symlink_fingerprint(self) -> Dict[str, object]:
|
||||
roots = [self._normalize_path(path) for path in self._symlink_roots() if path]
|
||||
unique_roots = sorted(set(roots))
|
||||
|
||||
# Include first-level symlinks in fingerprint for change detection.
|
||||
# This ensures new symlinks under roots trigger a cache invalidation.
|
||||
# Use lists (not tuples) for JSON serialization compatibility.
|
||||
direct_symlinks: List[List[str]] = []
|
||||
for root in unique_roots:
|
||||
try:
|
||||
if os.path.isdir(root):
|
||||
with os.scandir(root) as it:
|
||||
for entry in it:
|
||||
if self._entry_is_symlink(entry):
|
||||
try:
|
||||
target = os.path.realpath(entry.path)
|
||||
direct_symlinks.append([
|
||||
self._normalize_path(entry.path),
|
||||
self._normalize_path(target)
|
||||
])
|
||||
except OSError:
|
||||
pass
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"roots": unique_roots,
|
||||
"direct_symlinks": sorted(direct_symlinks)
|
||||
}
|
||||
|
||||
def _initialize_symlink_mappings(self) -> None:
|
||||
start = time.perf_counter()
|
||||
cache_loaded = self._load_persisted_cache_into_mappings()
|
||||
|
||||
if cache_loaded:
|
||||
logger.info(
|
||||
"Symlink mappings restored from cache in %.2f ms",
|
||||
(time.perf_counter() - start) * 1000,
|
||||
)
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
current_fingerprint = self._build_symlink_fingerprint()
|
||||
cached_fingerprint = self._cached_fingerprint
|
||||
|
||||
# Check 1: First-level symlinks unchanged (catches new symlinks at root)
|
||||
fingerprint_valid = cached_fingerprint and current_fingerprint == cached_fingerprint
|
||||
|
||||
# Check 2: All cached mappings still valid (catches changes at any depth)
|
||||
mappings_valid = self._validate_cached_mappings() if fingerprint_valid else False
|
||||
|
||||
if fingerprint_valid and mappings_valid:
|
||||
return
|
||||
|
||||
logger.info("Symlink configuration changed; rescanning symbolic links")
|
||||
|
||||
self.rebuild_symlink_cache()
|
||||
logger.info(
|
||||
"Symlink mappings rebuilt and cached in %.2f ms",
|
||||
(time.perf_counter() - start) * 1000,
|
||||
)
|
||||
|
||||
def rebuild_symlink_cache(self) -> None:
|
||||
"""Force a fresh scan of all symbolic links and update the persistent cache."""
|
||||
self._scan_symbolic_links()
|
||||
self._save_symlink_cache()
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
def _load_persisted_cache_into_mappings(self) -> bool:
|
||||
"""Load the symlink cache and store its fingerprint for comparison."""
|
||||
cache_path = self._get_symlink_cache_path()
|
||||
|
||||
# Check canonical path first, then legacy paths for migration
|
||||
paths_to_check = [cache_path]
|
||||
legacy_paths = get_legacy_cache_paths(CacheType.SYMLINK)
|
||||
paths_to_check.extend(Path(p) for p in legacy_paths if p != str(cache_path))
|
||||
|
||||
loaded_path = None
|
||||
payload = None
|
||||
|
||||
for check_path in paths_to_check:
|
||||
if not check_path.exists():
|
||||
continue
|
||||
try:
|
||||
with check_path.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
loaded_path = check_path
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.info("Failed to load symlink cache %s: %s", check_path, exc)
|
||||
continue
|
||||
|
||||
if payload is None:
|
||||
return False
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
|
||||
cached_mappings = payload.get("path_mappings")
|
||||
if not isinstance(cached_mappings, Mapping):
|
||||
return False
|
||||
|
||||
# Store the cached fingerprint for comparison during initialization
|
||||
self._cached_fingerprint = payload.get("fingerprint")
|
||||
|
||||
normalized_mappings: Dict[str, str] = {}
|
||||
for target, link in cached_mappings.items():
|
||||
if not isinstance(target, str) or not isinstance(link, str):
|
||||
continue
|
||||
normalized_mappings[self._normalize_path(target)] = self._normalize_path(link)
|
||||
|
||||
self._path_mappings = normalized_mappings
|
||||
|
||||
# Log migration if loaded from legacy path
|
||||
if loaded_path is not None and loaded_path != cache_path:
|
||||
logger.info(
|
||||
"Symlink cache migrated from %s (will save to %s)",
|
||||
loaded_path,
|
||||
cache_path,
|
||||
)
|
||||
|
||||
try:
|
||||
if loaded_path.exists():
|
||||
loaded_path.unlink()
|
||||
logger.info("Cleaned up legacy symlink cache: %s", loaded_path)
|
||||
|
||||
try:
|
||||
parent_dir = loaded_path.parent
|
||||
if parent_dir.name == "cache" and not any(parent_dir.iterdir()):
|
||||
parent_dir.rmdir()
|
||||
logger.info("Removed empty legacy cache directory: %s", parent_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to cleanup legacy symlink cache %s: %s",
|
||||
loaded_path,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
logger.info("Symlink cache loaded with %d mappings", len(self._path_mappings))
|
||||
|
||||
return True
|
||||
|
||||
def _validate_cached_mappings(self) -> bool:
|
||||
"""Verify all cached symlink mappings are still valid.
|
||||
|
||||
Returns True if all mappings are valid, False if rescan is needed.
|
||||
This catches removed or retargeted symlinks at ANY depth.
|
||||
"""
|
||||
for target, link in self._path_mappings.items():
|
||||
# Convert normalized paths back to OS paths
|
||||
link_path = link.replace('/', os.sep)
|
||||
|
||||
# Check if symlink still exists
|
||||
if not self._is_link(link_path):
|
||||
logger.debug("Cached symlink no longer exists: %s", link_path)
|
||||
return False
|
||||
|
||||
# Check if target is still the same
|
||||
try:
|
||||
actual_target = self._normalize_path(os.path.realpath(link_path))
|
||||
if actual_target != target:
|
||||
logger.debug(
|
||||
"Symlink target changed: %s -> %s (cached: %s)",
|
||||
link_path, actual_target, target
|
||||
)
|
||||
return False
|
||||
except OSError:
|
||||
logger.debug("Cannot resolve symlink: %s", link_path)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _save_symlink_cache(self) -> None:
|
||||
cache_path = self._get_symlink_cache_path()
|
||||
payload = {
|
||||
"fingerprint": self._build_symlink_fingerprint(),
|
||||
"path_mappings": self._path_mappings,
|
||||
}
|
||||
|
||||
try:
|
||||
with cache_path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||
logger.debug("Symlink cache saved to %s with %d mappings", cache_path, len(self._path_mappings))
|
||||
except Exception as exc:
|
||||
logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
|
||||
|
||||
def _scan_symbolic_links(self):
|
||||
"""Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories"""
|
||||
for root in self.loras_roots:
|
||||
self._scan_directory_links(root)
|
||||
start = time.perf_counter()
|
||||
|
||||
for root in self.base_models_roots:
|
||||
self._scan_directory_links(root)
|
||||
|
||||
for root in self.embeddings_roots:
|
||||
self._scan_directory_links(root)
|
||||
# Reset mappings before rescanning to avoid stale entries
|
||||
self._path_mappings.clear()
|
||||
self._seed_root_symlink_mappings()
|
||||
visited_dirs: Set[str] = set()
|
||||
for root in self._symlink_roots():
|
||||
self._scan_directory_links(root, visited_dirs)
|
||||
logger.debug(
|
||||
"Symlink scan finished in %.2f ms with %d mappings",
|
||||
(time.perf_counter() - start) * 1000,
|
||||
len(self._path_mappings),
|
||||
)
|
||||
|
||||
def _scan_directory_links(self, root: str):
|
||||
"""Recursively scan symbolic links in a directory"""
|
||||
def _scan_directory_links(self, root: str, visited_dirs: Set[str]):
|
||||
"""Iteratively scan directory symlinks to avoid deep recursion."""
|
||||
try:
|
||||
with os.scandir(root) as it:
|
||||
for entry in it:
|
||||
if self._is_link(entry.path):
|
||||
target_path = os.path.realpath(entry.path)
|
||||
if os.path.isdir(target_path):
|
||||
self.add_path_mapping(entry.path, target_path)
|
||||
self._scan_directory_links(target_path)
|
||||
elif entry.is_dir(follow_symlinks=False):
|
||||
self._scan_directory_links(entry.path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning links in {root}: {e}")
|
||||
# Note: We only use realpath for the initial root if it's not already resolved
|
||||
# to ensure we have a valid entry point.
|
||||
root_real = self._normalize_path(os.path.realpath(root))
|
||||
except OSError:
|
||||
root_real = self._normalize_path(root)
|
||||
|
||||
if root_real in visited_dirs:
|
||||
return
|
||||
|
||||
visited_dirs.add(root_real)
|
||||
# Stack entries: (display_path, real_resolved_path)
|
||||
stack: List[Tuple[str, str]] = [(root, root_real)]
|
||||
|
||||
while stack:
|
||||
current_display, current_real = stack.pop()
|
||||
try:
|
||||
with os.scandir(current_display) as it:
|
||||
for entry in it:
|
||||
try:
|
||||
# 1. Detect symlinks including Windows junctions
|
||||
is_link = self._entry_is_symlink(entry)
|
||||
|
||||
if is_link:
|
||||
# Only resolve realpath when we actually find a link
|
||||
target_path = os.path.realpath(entry.path)
|
||||
if not os.path.isdir(target_path):
|
||||
continue
|
||||
|
||||
normalized_target = self._normalize_path(target_path)
|
||||
self.add_path_mapping(entry.path, target_path)
|
||||
|
||||
if normalized_target in visited_dirs:
|
||||
continue
|
||||
|
||||
visited_dirs.add(normalized_target)
|
||||
stack.append((target_path, normalized_target))
|
||||
continue
|
||||
|
||||
# 2. Process normal directories
|
||||
if not entry.is_dir(follow_symlinks=False):
|
||||
continue
|
||||
|
||||
# For normal directories, we avoid realpath() call by
|
||||
# incrementally building the real path relative to current_real.
|
||||
# This is safe because 'entry' is NOT a symlink.
|
||||
entry_real = self._normalize_path(os.path.join(current_real, entry.name))
|
||||
|
||||
if entry_real in visited_dirs:
|
||||
continue
|
||||
|
||||
visited_dirs.add(entry_real)
|
||||
stack.append((entry.path, entry_real))
|
||||
except Exception as inner_exc:
|
||||
logger.debug(
|
||||
"Error processing directory entry %s: %s", entry.path, inner_exc
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning links in {current_display}: {e}")
|
||||
|
||||
|
||||
|
||||
def add_path_mapping(self, link_path: str, target_path: str):
|
||||
"""Add a symbolic link path mapping
|
||||
target_path: actual target path
|
||||
link_path: symbolic link path
|
||||
"""
|
||||
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
|
||||
normalized_target = os.path.normpath(target_path).replace(os.sep, '/')
|
||||
normalized_link = self._normalize_path(link_path)
|
||||
normalized_target = self._normalize_path(target_path)
|
||||
# Keep the original mapping: target path -> link path
|
||||
self._path_mappings[normalized_target] = normalized_link
|
||||
logger.info(f"Added path mapping: {normalized_target} -> {normalized_link}")
|
||||
self._preview_root_paths.update(self._expand_preview_root(normalized_target))
|
||||
self._preview_root_paths.update(self._expand_preview_root(normalized_link))
|
||||
|
||||
def _seed_root_symlink_mappings(self) -> None:
|
||||
"""Ensure symlinked root folders are recorded before deep scanning."""
|
||||
|
||||
for root in self._symlink_roots():
|
||||
if not root:
|
||||
continue
|
||||
try:
|
||||
if not self._is_link(root):
|
||||
continue
|
||||
target_path = os.path.realpath(root)
|
||||
if not os.path.isdir(target_path):
|
||||
continue
|
||||
self.add_path_mapping(root, target_path)
|
||||
except Exception as exc:
|
||||
logger.debug("Skipping root symlink %s: %s", root, exc)
|
||||
|
||||
def _expand_preview_root(self, path: str) -> Set[Path]:
|
||||
"""Return normalized ``Path`` objects representing a preview root."""
|
||||
|
||||
@@ -240,34 +605,53 @@ class Config:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
for root in self.embeddings_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
for root in self.misc_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
|
||||
for target, link in self._path_mappings.items():
|
||||
preview_roots.update(self._expand_preview_root(target))
|
||||
preview_roots.update(self._expand_preview_root(link))
|
||||
|
||||
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
|
||||
logger.debug(
|
||||
"Preview roots rebuilt: %d paths from %d lora roots, %d checkpoint roots, %d embedding roots, %d misc roots, %d symlink mappings",
|
||||
len(self._preview_root_paths),
|
||||
len(self.loras_roots or []),
|
||||
len(self.base_models_roots or []),
|
||||
len(self.embeddings_roots or []),
|
||||
len(self.misc_roots or []),
|
||||
len(self._path_mappings),
|
||||
)
|
||||
|
||||
def map_path_to_link(self, path: str) -> str:
|
||||
"""Map a target path back to its symbolic link path"""
|
||||
normalized_path = os.path.normpath(path).replace(os.sep, '/')
|
||||
# Check if the path is contained in any mapped target path
|
||||
for target_path, link_path in self._path_mappings.items():
|
||||
if normalized_path.startswith(target_path):
|
||||
# Match whole path components to avoid prefix collisions (e.g., /a/b vs /a/bc)
|
||||
if normalized_path == target_path:
|
||||
return link_path
|
||||
|
||||
if normalized_path.startswith(target_path + '/'):
|
||||
# If the path starts with the target path, replace with link path
|
||||
mapped_path = normalized_path.replace(target_path, link_path, 1)
|
||||
return mapped_path
|
||||
return path
|
||||
return normalized_path
|
||||
|
||||
def map_link_to_path(self, link_path: str) -> str:
|
||||
"""Map a symbolic link path back to the actual path"""
|
||||
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
|
||||
# Check if the path is contained in any mapped target path
|
||||
for target_path, link_path in self._path_mappings.items():
|
||||
if normalized_link.startswith(target_path):
|
||||
# If the path starts with the target path, replace with actual path
|
||||
mapped_path = normalized_link.replace(target_path, link_path, 1)
|
||||
for target_path, link_path_mapped in self._path_mappings.items():
|
||||
# Match whole path components
|
||||
if normalized_link == link_path_mapped:
|
||||
return target_path
|
||||
|
||||
if normalized_link.startswith(link_path_mapped + '/'):
|
||||
# If the path starts with the link path, replace with actual path
|
||||
mapped_path = normalized_link.replace(link_path_mapped, target_path, 1)
|
||||
return mapped_path
|
||||
return link_path
|
||||
return normalized_link
|
||||
|
||||
def _dedupe_existing_paths(self, raw_paths: Iterable[str]) -> Dict[str, str]:
|
||||
dedup: Dict[str, str] = {}
|
||||
@@ -342,8 +726,7 @@ class Config:
|
||||
self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
||||
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
||||
|
||||
self._scan_symbolic_links()
|
||||
self._rebuild_preview_roots()
|
||||
self._initialize_symlink_mappings()
|
||||
|
||||
def _init_lora_paths(self) -> List[str]:
|
||||
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
||||
@@ -395,6 +778,49 @@ class Config:
|
||||
logger.warning(f"Error initializing embedding paths: {e}")
|
||||
return []
|
||||
|
||||
def _init_misc_paths(self) -> List[str]:
|
||||
"""Initialize and validate misc (VAE and upscaler) paths from ComfyUI settings"""
|
||||
try:
|
||||
raw_vae_paths = folder_paths.get_folder_paths("vae")
|
||||
raw_upscaler_paths = folder_paths.get_folder_paths("upscale_models")
|
||||
unique_paths = self._prepare_misc_paths(raw_vae_paths, raw_upscaler_paths)
|
||||
|
||||
logger.info("Found misc roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
|
||||
|
||||
if not unique_paths:
|
||||
logger.warning("No valid VAE or upscaler folders found in ComfyUI configuration")
|
||||
return []
|
||||
|
||||
return unique_paths
|
||||
except Exception as e:
|
||||
logger.warning(f"Error initializing misc paths: {e}")
|
||||
return []
|
||||
|
||||
def _prepare_misc_paths(
|
||||
self, vae_paths: Iterable[str], upscaler_paths: Iterable[str]
|
||||
) -> List[str]:
|
||||
vae_map = self._dedupe_existing_paths(vae_paths)
|
||||
upscaler_map = self._dedupe_existing_paths(upscaler_paths)
|
||||
|
||||
merged_map: Dict[str, str] = {}
|
||||
for real_path, original in {**vae_map, **upscaler_map}.items():
|
||||
if real_path not in merged_map:
|
||||
merged_map[real_path] = original
|
||||
|
||||
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
|
||||
|
||||
vae_values = set(vae_map.values())
|
||||
upscaler_values = set(upscaler_map.values())
|
||||
self.vae_roots = [p for p in unique_paths if p in vae_values]
|
||||
self.upscaler_roots = [p for p in unique_paths if p in upscaler_values]
|
||||
|
||||
for original_path in unique_paths:
|
||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
||||
if real_path != original_path:
|
||||
self.add_path_mapping(original_path, real_path)
|
||||
|
||||
return unique_paths
|
||||
|
||||
def get_preview_static_url(self, preview_path: str) -> str:
|
||||
if not preview_path:
|
||||
return ""
|
||||
@@ -414,12 +840,29 @@ class Config:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Use os.path.normcase for case-insensitive comparison on Windows.
|
||||
# On Windows, Path.relative_to() is case-sensitive for drive letters,
|
||||
# causing paths like 'a:/folder' to not match 'A:/folder'.
|
||||
candidate_str = os.path.normcase(str(candidate))
|
||||
for root in self._preview_root_paths:
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
root_str = os.path.normcase(str(root))
|
||||
# Check if candidate is equal to or under the root directory
|
||||
if candidate_str == root_str or candidate_str.startswith(root_str + os.sep):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if self._preview_root_paths:
|
||||
logger.debug(
|
||||
"Preview path rejected: %s (candidate=%s, num_roots=%d, first_root=%s)",
|
||||
preview_path,
|
||||
candidate_str,
|
||||
len(self._preview_root_paths),
|
||||
os.path.normcase(str(next(iter(self._preview_root_paths)))),
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Preview path rejected (no roots configured): %s",
|
||||
preview_path,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
@@ -442,8 +885,9 @@ class Config:
|
||||
"""Return the current library registry and active library name."""
|
||||
|
||||
try:
|
||||
from .services.settings_manager import settings as settings_service
|
||||
from .services.settings_manager import get_settings_manager
|
||||
|
||||
settings_service = get_settings_manager()
|
||||
libraries = settings_service.get_libraries()
|
||||
active_library = settings_service.get_active_library_name()
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,15 @@ import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from .utils.logging_config import setup_logging
|
||||
|
||||
# Check if we're in standalone mode
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
|
||||
# Only setup logging prefix if not in standalone mode
|
||||
if not standalone_mode:
|
||||
setup_logging()
|
||||
|
||||
from server import PromptServer # type: ignore
|
||||
|
||||
from .config import config
|
||||
@@ -13,15 +22,44 @@ from .routes.misc_routes import MiscRoutes
|
||||
from .routes.preview_routes import PreviewRoutes
|
||||
from .routes.example_images_routes import ExampleImagesRoutes
|
||||
from .services.service_registry import ServiceRegistry
|
||||
from .services.settings_manager import settings
|
||||
from .services.settings_manager import get_settings_manager
|
||||
from .utils.example_images_migration import ExampleImagesMigration
|
||||
from .services.websocket_manager import ws_manager
|
||||
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||
from .middleware.csp_middleware import relax_csp_for_remote_media
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if we're in standalone mode
|
||||
STANDALONE_MODE = 'nodes' not in sys.modules
|
||||
HEADER_SIZE_LIMIT = 16384
|
||||
|
||||
|
||||
def _sanitize_size_limit(value):
|
||||
"""Return a non-negative integer size for ``handler_args`` comparisons."""
|
||||
|
||||
try:
|
||||
coerced = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return coerced if coerced >= 0 else 0
|
||||
|
||||
|
||||
class _SettingsProxy:
|
||||
def __init__(self):
|
||||
self._manager = None
|
||||
|
||||
def _resolve(self):
|
||||
if self._manager is None:
|
||||
self._manager = get_settings_manager()
|
||||
return self._manager
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return self._resolve().get(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._resolve(), item)
|
||||
|
||||
|
||||
settings = _SettingsProxy()
|
||||
|
||||
class LoraManager:
|
||||
"""Main entry point for LoRA Manager plugin"""
|
||||
@@ -31,6 +69,41 @@ class LoraManager:
|
||||
"""Initialize and register all routes using the new refactored architecture"""
|
||||
app = PromptServer.instance.app
|
||||
|
||||
if relax_csp_for_remote_media not in app.middlewares:
|
||||
# Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can
|
||||
# see and extend the restrictive header instead of being overwritten by it.
|
||||
block_middleware_index = next(
|
||||
(
|
||||
idx
|
||||
for idx, middleware in enumerate(app.middlewares)
|
||||
if getattr(middleware, "__name__", "") == "block_external_middleware"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if block_middleware_index is None:
|
||||
app.middlewares.append(relax_csp_for_remote_media)
|
||||
else:
|
||||
app.middlewares.insert(block_middleware_index, relax_csp_for_remote_media)
|
||||
|
||||
# Increase allowed header sizes so browsers with large localhost cookie
|
||||
# jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default
|
||||
# limits. Cookies for unrelated apps are still sent to the plugin and
|
||||
# may otherwise raise LineTooLong errors when the request parser reads
|
||||
# them. Preserve any previously configured handler arguments while
|
||||
# ensuring our minimum sizes are applied.
|
||||
handler_args = getattr(app, "_handler_args", {}) or {}
|
||||
updated_handler_args = dict(handler_args)
|
||||
updated_handler_args["max_field_size"] = max(
|
||||
_sanitize_size_limit(handler_args.get("max_field_size", 0)),
|
||||
HEADER_SIZE_LIMIT,
|
||||
)
|
||||
updated_handler_args["max_line_size"] = max(
|
||||
_sanitize_size_limit(handler_args.get("max_line_size", 0)),
|
||||
HEADER_SIZE_LIMIT,
|
||||
)
|
||||
app._handler_args = updated_handler_args
|
||||
|
||||
# Configure aiohttp access logger to be less verbose
|
||||
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
|
||||
|
||||
@@ -91,8 +164,6 @@ class LoraManager:
|
||||
# Add cleanup
|
||||
app.on_shutdown.append(cls._cleanup)
|
||||
|
||||
logger.info(f"LoRA Manager: Set up routes for {len(ModelServiceFactory.get_registered_types())} model types: {', '.join(ModelServiceFactory.get_registered_types())}")
|
||||
|
||||
@classmethod
|
||||
async def _initialize_services(cls):
|
||||
"""Initialize all services using the ServiceRegistry"""
|
||||
@@ -113,15 +184,17 @@ class LoraManager:
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
|
||||
misc_scanner = await ServiceRegistry.get_misc_scanner()
|
||||
|
||||
# Initialize recipe scanner if needed
|
||||
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||
|
||||
|
||||
# Create low-priority initialization tasks
|
||||
init_tasks = [
|
||||
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init'),
|
||||
asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init'),
|
||||
asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_cache_init'),
|
||||
asyncio.create_task(misc_scanner.initialize_in_background(), name='misc_cache_init'),
|
||||
asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init')
|
||||
]
|
||||
|
||||
@@ -181,8 +254,9 @@ class LoraManager:
|
||||
# Collect all model roots
|
||||
all_roots = set()
|
||||
all_roots.update(config.loras_roots)
|
||||
all_roots.update(config.base_models_roots)
|
||||
all_roots.update(config.base_models_roots)
|
||||
all_roots.update(config.embeddings_roots)
|
||||
all_roots.update(config.misc_roots or [])
|
||||
|
||||
total_deleted = 0
|
||||
total_size_freed = 0
|
||||
|
||||
@@ -39,8 +39,39 @@ class MetadataProcessor:
|
||||
if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
|
||||
candidate_samplers[node_id] = metadata[SAMPLING][node_id]
|
||||
|
||||
# If we found candidate samplers, apply primary sampler logic to these candidates only
|
||||
if candidate_samplers:
|
||||
# If we found candidate samplers, apply primary sampler logic to these candidates only
|
||||
|
||||
# PRE-PROCESS: Ensure all candidate samplers have their parameters populated
|
||||
# This is especially important for SamplerCustomAdvanced which needs tracing
|
||||
prompt = metadata.get("current_prompt")
|
||||
for node_id in candidate_samplers:
|
||||
# If a sampler is missing common parameters like steps or denoise,
|
||||
# try to populate them using tracing before ranking
|
||||
sampler_info = candidate_samplers[node_id]
|
||||
params = sampler_info.get("parameters", {})
|
||||
|
||||
if prompt and (params.get("steps") is None or params.get("denoise") is None):
|
||||
# Create a temporary params dict to use the handler
|
||||
temp_params = {
|
||||
"steps": params.get("steps"),
|
||||
"denoise": params.get("denoise"),
|
||||
"sampler": params.get("sampler_name"),
|
||||
"scheduler": params.get("scheduler")
|
||||
}
|
||||
|
||||
# Check if it's SamplerCustomAdvanced
|
||||
if prompt.original_prompt and node_id in prompt.original_prompt:
|
||||
if prompt.original_prompt[node_id].get("class_type") == "SamplerCustomAdvanced":
|
||||
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, node_id, temp_params)
|
||||
|
||||
# Update the actual parameters with found values
|
||||
params["steps"] = temp_params.get("steps")
|
||||
params["denoise"] = temp_params.get("denoise")
|
||||
if temp_params.get("sampler"):
|
||||
params["sampler_name"] = temp_params.get("sampler")
|
||||
if temp_params.get("scheduler"):
|
||||
params["scheduler"] = temp_params.get("scheduler")
|
||||
|
||||
# Collect potential primary samplers based on different criteria
|
||||
custom_advanced_samplers = []
|
||||
advanced_add_noise_samplers = []
|
||||
@@ -49,7 +80,6 @@ class MetadataProcessor:
|
||||
high_denoise_id = None
|
||||
|
||||
# First, check for SamplerCustomAdvanced among candidates
|
||||
prompt = metadata.get("current_prompt")
|
||||
if prompt and prompt.original_prompt:
|
||||
for node_id in candidate_samplers:
|
||||
node_info = prompt.original_prompt.get(node_id, {})
|
||||
@@ -77,15 +107,16 @@ class MetadataProcessor:
|
||||
# Combine all potential primary samplers
|
||||
potential_samplers = custom_advanced_samplers + advanced_add_noise_samplers + high_denoise_samplers
|
||||
|
||||
# Find the most recent potential primary sampler (closest to downstream node)
|
||||
for i in range(downstream_index - 1, -1, -1):
|
||||
# Find the first potential primary sampler (prefer base sampler over refine)
|
||||
# Use forward search to prioritize the first one in execution order
|
||||
for i in range(downstream_index):
|
||||
node_id = execution_order[i]
|
||||
if node_id in potential_samplers:
|
||||
return node_id, candidate_samplers[node_id]
|
||||
|
||||
# If no potential sampler found from our criteria, return the most recent sampler
|
||||
# If no potential sampler found from our criteria, return the first sampler
|
||||
if candidate_samplers:
|
||||
for i in range(downstream_index - 1, -1, -1):
|
||||
for i in range(downstream_index):
|
||||
node_id = execution_order[i]
|
||||
if node_id in candidate_samplers:
|
||||
return node_id, candidate_samplers[node_id]
|
||||
@@ -176,8 +207,11 @@ class MetadataProcessor:
|
||||
found_node_id = input_value[0] # Connected node_id
|
||||
|
||||
# If we're looking for a specific node class
|
||||
if target_class and prompt.original_prompt[found_node_id].get("class_type") == target_class:
|
||||
return found_node_id
|
||||
if target_class:
|
||||
if found_node_id not in prompt.original_prompt:
|
||||
return None
|
||||
if prompt.original_prompt[found_node_id].get("class_type") == target_class:
|
||||
return found_node_id
|
||||
|
||||
# If we're not looking for a specific class, update the last valid node
|
||||
if not target_class:
|
||||
@@ -185,11 +219,19 @@ class MetadataProcessor:
|
||||
|
||||
# Continue tracing through intermediate nodes
|
||||
current_node_id = found_node_id
|
||||
# For most conditioning nodes, the input we want to follow is named "conditioning"
|
||||
if "conditioning" in prompt.original_prompt[current_node_id].get("inputs", {}):
|
||||
|
||||
# Check if current source node exists
|
||||
if current_node_id not in prompt.original_prompt:
|
||||
return found_node_id if not target_class else None
|
||||
|
||||
# Determine which input to follow next on the source node
|
||||
source_node_inputs = prompt.original_prompt[current_node_id].get("inputs", {})
|
||||
if input_name in source_node_inputs:
|
||||
current_input = input_name
|
||||
elif "conditioning" in source_node_inputs:
|
||||
current_input = "conditioning"
|
||||
else:
|
||||
# If there's no "conditioning" input, return the current node
|
||||
# If there's no suitable input to follow, return the current node
|
||||
# if we're not looking for a specific target_class
|
||||
return found_node_id if not target_class else None
|
||||
else:
|
||||
@@ -202,12 +244,89 @@ class MetadataProcessor:
|
||||
return last_valid_node if not target_class else None
|
||||
|
||||
@staticmethod
|
||||
def find_primary_checkpoint(metadata):
|
||||
"""Find the primary checkpoint model in the workflow"""
|
||||
if not metadata.get(MODELS):
|
||||
def trace_model_path(metadata, prompt, start_node_id):
|
||||
"""
|
||||
Trace the model connection path upstream to find the checkpoint
|
||||
"""
|
||||
if not prompt or not prompt.original_prompt:
|
||||
return None
|
||||
|
||||
# In most workflows, there's only one checkpoint, so we can just take the first one
|
||||
current_node_id = start_node_id
|
||||
depth = 0
|
||||
max_depth = 50
|
||||
|
||||
while depth < max_depth:
|
||||
# Check if current node is a registered checkpoint in our metadata
|
||||
# This handles cached nodes correctly because metadata contains info for all nodes in the graph
|
||||
if current_node_id in metadata.get(MODELS, {}):
|
||||
if metadata[MODELS][current_node_id].get("type") == "checkpoint":
|
||||
return current_node_id
|
||||
|
||||
if current_node_id not in prompt.original_prompt:
|
||||
return None
|
||||
|
||||
node = prompt.original_prompt[current_node_id]
|
||||
inputs = node.get("inputs", {})
|
||||
class_type = node.get("class_type", "")
|
||||
|
||||
# Determine which input to follow next
|
||||
next_input_name = "model"
|
||||
|
||||
# Special handling for initial node
|
||||
if depth == 0:
|
||||
if class_type == "SamplerCustomAdvanced":
|
||||
next_input_name = "guider"
|
||||
|
||||
# If the specific input doesn't exist, try generic 'model'
|
||||
if next_input_name not in inputs:
|
||||
if "model" in inputs:
|
||||
next_input_name = "model"
|
||||
elif "basic_pipe" in inputs:
|
||||
# Handle pipe nodes like FromBasicPipe by following the pipeline
|
||||
next_input_name = "basic_pipe"
|
||||
else:
|
||||
# Dead end - no model input to follow
|
||||
return None
|
||||
|
||||
# Get connected node
|
||||
input_val = inputs[next_input_name]
|
||||
if isinstance(input_val, list) and len(input_val) > 0:
|
||||
current_node_id = input_val[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
depth += 1
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_primary_checkpoint(metadata, downstream_id=None, primary_sampler_id=None):
|
||||
"""
|
||||
Find the primary checkpoint model in the workflow
|
||||
|
||||
Parameters:
|
||||
- metadata: The workflow metadata
|
||||
- downstream_id: Optional ID of a downstream node to help identify the specific primary sampler
|
||||
- primary_sampler_id: Optional ID of the primary sampler if already known
|
||||
"""
|
||||
if not metadata.get(MODELS):
|
||||
return None
|
||||
|
||||
# Method 1: Topology-based tracing (More accurate for complex workflows)
|
||||
# First, find the primary sampler if not provided
|
||||
if not primary_sampler_id:
|
||||
primary_sampler_id, _ = MetadataProcessor.find_primary_sampler(metadata, downstream_id)
|
||||
|
||||
if primary_sampler_id:
|
||||
prompt = metadata.get("current_prompt")
|
||||
if prompt:
|
||||
# Trace back from the sampler to find the checkpoint
|
||||
checkpoint_id = MetadataProcessor.trace_model_path(metadata, prompt, primary_sampler_id)
|
||||
if checkpoint_id and checkpoint_id in metadata.get(MODELS, {}):
|
||||
return metadata[MODELS][checkpoint_id].get("name")
|
||||
|
||||
# Method 2: Fallback to the first available checkpoint (Original behavior)
|
||||
# In most simple workflows, there's only one checkpoint, so we can just take the first one
|
||||
for node_id, model_info in metadata.get(MODELS, {}).items():
|
||||
if model_info.get("type") == "checkpoint":
|
||||
return model_info.get("name")
|
||||
@@ -311,7 +430,8 @@ class MetadataProcessor:
|
||||
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
||||
|
||||
# Directly get checkpoint from metadata instead of tracing
|
||||
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata)
|
||||
# Pass primary_sampler_id to avoid redundant calculation
|
||||
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
|
||||
if checkpoint:
|
||||
params["checkpoint"] = checkpoint
|
||||
|
||||
@@ -445,6 +565,7 @@ class MetadataProcessor:
|
||||
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
|
||||
params["steps"] = scheduler_params.get("steps")
|
||||
params["scheduler"] = scheduler_params.get("scheduler")
|
||||
params["denoise"] = scheduler_params.get("denoise")
|
||||
|
||||
# 2. Trace sampler input to find KSamplerSelect (only if sampler input exists)
|
||||
if "sampler" in sampler_inputs:
|
||||
|
||||
@@ -196,9 +196,11 @@ class MetadataRegistry:
|
||||
node_metadata[category] = {}
|
||||
node_metadata[category][node_id] = current_metadata[category][node_id]
|
||||
|
||||
# Save to cache if we have any metadata for this node
|
||||
# Save new metadata or clear stale cache entries when metadata is empty
|
||||
if any(node_metadata.values()):
|
||||
self.node_cache[cache_key] = node_metadata
|
||||
else:
|
||||
self.node_cache.pop(cache_key, None)
|
||||
|
||||
def clear_unused_cache(self):
|
||||
"""Clean up node_cache entries that are no longer in use"""
|
||||
|
||||
@@ -3,6 +3,18 @@ import os
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
||||
|
||||
|
||||
def _store_checkpoint_metadata(metadata, node_id, model_name):
|
||||
"""Store checkpoint model information when available."""
|
||||
if not model_name:
|
||||
return
|
||||
metadata.setdefault(MODELS, {})
|
||||
metadata[MODELS][node_id] = {
|
||||
"name": model_name,
|
||||
"type": "checkpoint",
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
|
||||
class NodeMetadataExtractor:
|
||||
"""Base class for node-specific metadata extraction"""
|
||||
|
||||
@@ -29,12 +41,48 @@ class CheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
return
|
||||
|
||||
model_name = inputs.get("ckpt_name")
|
||||
if model_name:
|
||||
metadata[MODELS][node_id] = {
|
||||
"name": model_name,
|
||||
"type": "checkpoint",
|
||||
"node_id": node_id
|
||||
}
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
|
||||
class NunchakuFluxDiTLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "model_path" not in inputs:
|
||||
return
|
||||
|
||||
model_name = inputs.get("model_path")
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
|
||||
class NunchakuQwenImageDiTLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "model_name" not in inputs:
|
||||
return
|
||||
|
||||
model_name = inputs.get("model_name")
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
class GGUFLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "gguf_name" not in inputs:
|
||||
return
|
||||
|
||||
model_name = inputs.get("gguf_name")
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
|
||||
class KJNodesModelLoaderExtractor(NodeMetadataExtractor):
|
||||
"""Extract metadata from KJNodes loaders that expose `model_name`."""
|
||||
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "model_name" not in inputs:
|
||||
return
|
||||
|
||||
model_name = inputs.get("model_name")
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
@@ -43,12 +91,7 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
return
|
||||
|
||||
model_name = inputs.get("ckpt_name")
|
||||
if model_name:
|
||||
metadata[MODELS][node_id] = {
|
||||
"name": model_name,
|
||||
"type": "checkpoint",
|
||||
"node_id": node_id
|
||||
}
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
# For loader node has lora_stack input, like Efficient Loader from Efficient Nodes
|
||||
active_loras = []
|
||||
@@ -651,6 +694,7 @@ NODE_EXTRACTORS = {
|
||||
"KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack
|
||||
"KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack
|
||||
"KSamplerAdvanced_inspire_pipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-inspire-pack
|
||||
"KSampler_inspire": SamplerExtractor, # comfyui-inspire-pack
|
||||
# Sampling Selectors
|
||||
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
||||
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
||||
@@ -660,12 +704,20 @@ NODE_EXTRACTORS = {
|
||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
||||
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||
"NunchakuQwenImageDiTLoader": NunchakuQwenImageDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||
"LoaderGGUF": GGUFLoaderExtractor, # calcuis gguf
|
||||
"LoaderGGUFAdvanced": GGUFLoaderExtractor, # calcuis gguf
|
||||
"GGUFLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
|
||||
"DiffusionModelLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
|
||||
"CheckpointLoaderKJ": CheckpointLoaderExtractor, # KJNodes
|
||||
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"LoraLoader": LoraLoaderExtractor,
|
||||
"LoraManagerLoader": LoraLoaderManagerExtractor,
|
||||
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
||||
# Conditioning
|
||||
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
||||
"PromptLM": CLIPTextEncodeExtractor,
|
||||
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
||||
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
||||
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb
|
||||
|
||||
65
py/middleware/csp_middleware.py
Normal file
65
py/middleware/csp_middleware.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Middleware helpers for adjusting Content Security Policy headers."""
|
||||
|
||||
from typing import Awaitable, Callable, Dict, List
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
REMOTE_MEDIA_SOURCES = (
|
||||
"https://image.civitai.com",
|
||||
"https://img.genur.art",
|
||||
)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def relax_csp_for_remote_media(
|
||||
request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]
|
||||
) -> web.StreamResponse:
|
||||
"""Allow LoRA Manager media previews to load from trusted remote domains.
|
||||
|
||||
When ComfyUI is started with ``--disable-api-nodes`` it injects a restrictive
|
||||
``Content-Security-Policy`` header that blocks remote images and videos. The
|
||||
LoRA Manager UI legitimately needs to fetch previews from Civitai and Genur,
|
||||
so this middleware augments the existing CSP to whitelist those hosts while
|
||||
preserving all other directives.
|
||||
"""
|
||||
|
||||
response: web.StreamResponse = await handler(request)
|
||||
header_value = response.headers.get("Content-Security-Policy")
|
||||
|
||||
if not header_value:
|
||||
return response
|
||||
|
||||
directive_order: List[str] = []
|
||||
directives: Dict[str, List[str]] = {}
|
||||
|
||||
for raw_directive in header_value.split(";"):
|
||||
directive = raw_directive.strip()
|
||||
if not directive:
|
||||
continue
|
||||
|
||||
parts = directive.split()
|
||||
name, values = parts[0], parts[1:]
|
||||
if name not in directive_order:
|
||||
directive_order.append(name)
|
||||
directives[name] = values
|
||||
|
||||
def merge_sources(name: str, sources: List[str], defaults: List[str] | None = None) -> None:
|
||||
existing = directives.get(name, list(defaults or []))
|
||||
|
||||
for source in sources:
|
||||
if source not in existing:
|
||||
existing.append(source)
|
||||
|
||||
directives[name] = existing
|
||||
if name not in directive_order:
|
||||
directive_order.append(name)
|
||||
|
||||
merge_sources("img-src", list(REMOTE_MEDIA_SOURCES))
|
||||
merge_sources("media-src", ["'self'", *REMOTE_MEDIA_SOURCES], defaults=["'self'"])
|
||||
|
||||
updated_header = "; ".join(
|
||||
f"{name} {' '.join(directives[name])}".rstrip() for name in directive_order
|
||||
)
|
||||
|
||||
response.headers["Content-Security-Policy"] = f"{updated_header};"
|
||||
return response
|
||||
@@ -1,15 +1,15 @@
|
||||
import logging
|
||||
from server import PromptServer # type: ignore
|
||||
from ..metadata_collector.metadata_processor import MetadataProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DebugMetadata:
|
||||
|
||||
class DebugMetadataLM:
|
||||
NAME = "Debug Metadata (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = "Debug node to verify metadata_processor functionality"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
@@ -25,21 +25,37 @@ class DebugMetadata:
|
||||
FUNCTION = "process_metadata"
|
||||
|
||||
def process_metadata(self, images, id):
|
||||
"""
|
||||
Process metadata from the execution context and return it for UI display.
|
||||
|
||||
The metadata is returned via the 'ui' key in the return dict, which triggers
|
||||
node.onExecuted on the frontend to update the JsonDisplayWidget.
|
||||
|
||||
Args:
|
||||
images: Input images (required for execution flow)
|
||||
id: Node's unique ID (hidden)
|
||||
|
||||
Returns:
|
||||
Dict with 'result' (empty tuple) and 'ui' (metadata dict for widget display)
|
||||
"""
|
||||
try:
|
||||
# Get the current execution context's metadata
|
||||
from ..metadata_collector import get_metadata
|
||||
|
||||
metadata = get_metadata()
|
||||
|
||||
# Use the MetadataProcessor to convert it to JSON string
|
||||
metadata_json = MetadataProcessor.to_json(metadata, id)
|
||||
|
||||
# Send metadata to frontend for display
|
||||
PromptServer.instance.send_sync("metadata_update", {
|
||||
"id": id,
|
||||
"metadata": metadata_json
|
||||
})
|
||||
|
||||
|
||||
# Use the MetadataProcessor to convert it to dict
|
||||
metadata_dict = MetadataProcessor.to_dict(metadata, id)
|
||||
|
||||
return {
|
||||
"result": (),
|
||||
# ComfyUI expects ui values to be lists, wrap the dict in a list
|
||||
"ui": {"metadata": [metadata_dict]},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing metadata: {e}")
|
||||
|
||||
return ()
|
||||
return {
|
||||
"result": (),
|
||||
"ui": {"metadata": [{"error": str(e)}]},
|
||||
}
|
||||
|
||||
136
py/nodes/lora_cycler.py
Normal file
136
py/nodes/lora_cycler.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Lora Cycler Node - Sequentially cycles through LoRAs from a pool.
|
||||
|
||||
This node accepts optional pool_config input to filter available LoRAs, and outputs
|
||||
a LORA_STACK with one LoRA at a time. Returns UI updates with current/next LoRA info
|
||||
and tracks the cycle progress which persists across workflow save/load.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoraCyclerLM:
|
||||
"""Node that sequentially cycles through LoRAs from a pool"""
|
||||
|
||||
NAME = "Lora Cycler (LoraManager)"
|
||||
CATEGORY = "Lora Manager/randomizer"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"cycler_config": ("CYCLER_CONFIG", {}),
|
||||
},
|
||||
"optional": {
|
||||
"pool_config": ("POOL_CONFIG", {}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LORA_STACK",)
|
||||
RETURN_NAMES = ("LORA_STACK",)
|
||||
|
||||
FUNCTION = "cycle"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
async def cycle(self, cycler_config, pool_config=None):
|
||||
"""
|
||||
Cycle through LoRAs based on configuration and pool filters.
|
||||
|
||||
Args:
|
||||
cycler_config: Dict with cycler settings (current_index, model_strength, clip_strength, sort_by)
|
||||
pool_config: Optional config from LoRA Pool node for filtering
|
||||
|
||||
Returns:
|
||||
Dictionary with 'result' (LORA_STACK tuple) and 'ui' (for widget display)
|
||||
"""
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.lora_service import LoraService
|
||||
|
||||
# Extract settings from cycler_config
|
||||
current_index = cycler_config.get("current_index", 1) # 1-based
|
||||
model_strength = float(cycler_config.get("model_strength", 1.0))
|
||||
clip_strength = float(cycler_config.get("clip_strength", 1.0))
|
||||
sort_by = "filename"
|
||||
|
||||
# Dual-index mechanism for batch queue synchronization
|
||||
execution_index = cycler_config.get("execution_index") # Can be None
|
||||
# next_index_from_config = cycler_config.get("next_index") # Not used on backend
|
||||
|
||||
# Get scanner and service
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
lora_service = LoraService(scanner)
|
||||
|
||||
# Get filtered and sorted LoRA list
|
||||
lora_list = await lora_service.get_cycler_list(
|
||||
pool_config=pool_config, sort_by=sort_by
|
||||
)
|
||||
|
||||
total_count = len(lora_list)
|
||||
|
||||
if total_count == 0:
|
||||
logger.warning("[LoraCyclerLM] No LoRAs available in pool")
|
||||
return {
|
||||
"result": ([],),
|
||||
"ui": {
|
||||
"current_index": [1],
|
||||
"next_index": [1],
|
||||
"total_count": [0],
|
||||
"current_lora_name": [""],
|
||||
"current_lora_filename": [""],
|
||||
"error": ["No LoRAs available in pool"],
|
||||
},
|
||||
}
|
||||
|
||||
# Determine which index to use for this execution
|
||||
# If execution_index is provided (batch queue case), use it
|
||||
# Otherwise use current_index (first execution or non-batch case)
|
||||
if execution_index is not None:
|
||||
actual_index = execution_index
|
||||
else:
|
||||
actual_index = current_index
|
||||
|
||||
# Clamp index to valid range (1-based)
|
||||
clamped_index = max(1, min(actual_index, total_count))
|
||||
|
||||
# Get LoRA at current index (convert to 0-based for list access)
|
||||
current_lora = lora_list[clamped_index - 1]
|
||||
|
||||
# Build LORA_STACK with single LoRA
|
||||
lora_path, _ = get_lora_info(current_lora["file_name"])
|
||||
if not lora_path:
|
||||
logger.warning(
|
||||
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
|
||||
)
|
||||
lora_stack = []
|
||||
else:
|
||||
# Normalize path separators
|
||||
lora_path = lora_path.replace("/", os.sep)
|
||||
lora_stack = [(lora_path, model_strength, clip_strength)]
|
||||
|
||||
# Calculate next index (wrap to 1 if at end)
|
||||
next_index = clamped_index + 1
|
||||
if next_index > total_count:
|
||||
next_index = 1
|
||||
|
||||
# Get next LoRA for UI display (what will be used next generation)
|
||||
next_lora = lora_list[next_index - 1]
|
||||
next_display_name = next_lora["file_name"]
|
||||
|
||||
return {
|
||||
"result": (lora_stack,),
|
||||
"ui": {
|
||||
"current_index": [clamped_index],
|
||||
"next_index": [next_index],
|
||||
"total_count": [total_count],
|
||||
"current_lora_name": [
|
||||
current_lora.get("model_name", current_lora["file_name"])
|
||||
],
|
||||
"current_lora_filename": [current_lora["file_name"]],
|
||||
"next_lora_name": [next_display_name],
|
||||
"next_lora_filename": [next_lora["file_name"]],
|
||||
},
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import logging
|
||||
import re
|
||||
from nodes import LoraLoader
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LoraManagerLoader:
|
||||
class LoraLoaderLM:
|
||||
NAME = "Lora Loader (LoraManager)"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
|
||||
@@ -17,18 +16,15 @@ class LoraManagerLoader:
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
# "clip": ("CLIP",),
|
||||
"text": (IO.STRING, {
|
||||
"multiline": True,
|
||||
"pysssss.autocomplete": False,
|
||||
"dynamicPrompts": True,
|
||||
"text": ("AUTOCOMPLETE_TEXT_LORAS", {
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
||||
}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras"
|
||||
|
||||
@@ -132,7 +128,7 @@ class LoraManagerLoader:
|
||||
|
||||
return (model, clip, trigger_words_text, formatted_loras_text)
|
||||
|
||||
class LoraManagerTextLoader:
|
||||
class LoraTextLoaderLM:
|
||||
NAME = "LoRA Text Loader (LoraManager)"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
|
||||
@@ -141,8 +137,7 @@ class LoraManagerTextLoader:
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"lora_syntax": (IO.STRING, {
|
||||
"defaultInput": True,
|
||||
"lora_syntax": ("STRING", {
|
||||
"forceInput": True,
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation"
|
||||
}),
|
||||
@@ -153,7 +148,7 @@ class LoraManagerTextLoader:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras_from_text"
|
||||
|
||||
|
||||
87
py/nodes/lora_pool.py
Normal file
87
py/nodes/lora_pool.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
LoRA Pool Node - Defines filter configuration for LoRA selection.
|
||||
|
||||
This node provides a visual filter editor that generates a LORA_POOL_CONFIG
|
||||
object for use by downstream nodes (like LoRA Randomizer).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoraPoolLM:
|
||||
"""
|
||||
A node that defines LoRA filter criteria through a Vue-based widget.
|
||||
|
||||
Outputs a LORA_POOL_CONFIG that can be consumed by:
|
||||
- Frontend: LoRA Randomizer widget reads connected pool's widget value
|
||||
- Backend: LoRA Randomizer receives config during workflow execution
|
||||
"""
|
||||
|
||||
NAME = "Lora Pool (LoraManager)"
|
||||
CATEGORY = "Lora Manager/randomizer"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"pool_config": ("LORA_POOL_CONFIG", {}),
|
||||
},
|
||||
"hidden": {
|
||||
# Hidden input to pass through unique node ID for frontend
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("POOL_CONFIG",)
|
||||
RETURN_NAMES = ("POOL_CONFIG",)
|
||||
|
||||
FUNCTION = "process"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def process(self, pool_config, unique_id=None):
|
||||
"""
|
||||
Pass through the pool configuration filters.
|
||||
|
||||
The config is generated entirely by the frontend widget.
|
||||
This function validates and returns only the filters field.
|
||||
|
||||
Args:
|
||||
pool_config: Dict containing filter criteria from widget
|
||||
unique_id: Node's unique ID (hidden)
|
||||
|
||||
Returns:
|
||||
Tuple containing the filters dict from pool_config
|
||||
"""
|
||||
# Validate required structure
|
||||
if not isinstance(pool_config, dict):
|
||||
logger.warning("Invalid pool_config type, using empty config")
|
||||
pool_config = self._default_config()
|
||||
|
||||
# Ensure version field exists
|
||||
if "version" not in pool_config:
|
||||
pool_config["version"] = 1
|
||||
|
||||
# Extract filters field
|
||||
filters = pool_config.get("filters", self._default_config()["filters"])
|
||||
|
||||
# Log for debugging
|
||||
logger.debug(f"[LoraPoolLM] Processing filters: {filters}")
|
||||
|
||||
return (filters,)
|
||||
|
||||
@staticmethod
|
||||
def _default_config():
|
||||
"""Return default empty configuration."""
|
||||
return {
|
||||
"version": 1,
|
||||
"filters": {
|
||||
"baseModels": [],
|
||||
"tags": {"include": [], "exclude": []},
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"favoritesOnly": False,
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
},
|
||||
"preview": {"matchCount": 0, "lastUpdated": 0},
|
||||
}
|
||||
206
py/nodes/lora_randomizer.py
Normal file
206
py/nodes/lora_randomizer.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Lora Randomizer Node - Randomly selects LoRAs from a pool with configurable settings.
|
||||
|
||||
This node accepts optional pool_config input to filter available LoRAs, and outputs
|
||||
a LORA_STACK with randomly selected LoRAs. Returns UI updates with new random LoRAs
|
||||
and tracks the last used combination for reuse.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import extract_lora_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoraRandomizerLM:
|
||||
"""Node that randomly selects LoRAs from a pool"""
|
||||
|
||||
NAME = "Lora Randomizer (LoraManager)"
|
||||
CATEGORY = "Lora Manager/randomizer"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"randomizer_config": ("RANDOMIZER_CONFIG", {}),
|
||||
"loras": ("LORAS", {}),
|
||||
},
|
||||
"optional": {
|
||||
"pool_config": ("POOL_CONFIG", {}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LORA_STACK",)
|
||||
RETURN_NAMES = ("LORA_STACK",)
|
||||
|
||||
FUNCTION = "randomize"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def _preprocess_loras_input(self, loras):
|
||||
"""
|
||||
Preprocess loras input to handle different widget formats.
|
||||
|
||||
Args:
|
||||
loras: Input from widget, either:
|
||||
- List of LoRA dicts (expected format)
|
||||
- Dict with '__value__' key containing the list
|
||||
|
||||
Returns:
|
||||
List of LoRA dicts
|
||||
"""
|
||||
if isinstance(loras, dict) and "__value__" in loras:
|
||||
return loras["__value__"]
|
||||
return loras
|
||||
|
||||
async def randomize(self, randomizer_config, loras, pool_config=None):
|
||||
"""
|
||||
Randomize LoRAs based on configuration and pool filters.
|
||||
|
||||
Args:
|
||||
randomizer_config: Dict with randomizer settings (count, strength ranges, roll_mode)
|
||||
loras: List of LoRA dicts from LORAS widget (includes locked state)
|
||||
pool_config: Optional config from LoRA Pool node for filtering
|
||||
|
||||
Returns:
|
||||
Dictionary with 'result' (LORA_STACK tuple) and 'ui' (for widget display)
|
||||
"""
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
loras = self._preprocess_loras_input(loras)
|
||||
|
||||
roll_mode = randomizer_config.get("roll_mode", "always")
|
||||
logger.debug(f"[LoraRandomizerLM] roll_mode: {roll_mode}")
|
||||
|
||||
# Dual seed mechanism for batch queue synchronization
|
||||
# execution_seed: seed for generating execution_stack (= previous next_seed)
|
||||
# next_seed: seed for generating ui_loras (= what will be displayed after execution)
|
||||
execution_seed = randomizer_config.get("execution_seed", None)
|
||||
next_seed = randomizer_config.get("next_seed", None)
|
||||
|
||||
if roll_mode == "fixed":
|
||||
ui_loras = loras
|
||||
execution_loras = loras
|
||||
else:
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
|
||||
# Generate execution_loras from execution_seed (if available)
|
||||
if execution_seed is not None:
|
||||
# Use execution_seed to regenerate the same loras that were shown to user
|
||||
execution_loras = await self._generate_random_loras_for_ui(
|
||||
scanner, randomizer_config, loras, pool_config, seed=execution_seed
|
||||
)
|
||||
else:
|
||||
# First execution: use loras input (what user sees in the widget)
|
||||
execution_loras = loras
|
||||
|
||||
# Generate ui_loras from next_seed (for display after execution)
|
||||
ui_loras = await self._generate_random_loras_for_ui(
|
||||
scanner, randomizer_config, loras, pool_config, seed=next_seed
|
||||
)
|
||||
|
||||
execution_stack = self._build_execution_stack_from_input(execution_loras)
|
||||
|
||||
return {
|
||||
"result": (execution_stack,),
|
||||
"ui": {"loras": ui_loras, "last_used": execution_loras},
|
||||
}
|
||||
|
||||
def _build_execution_stack_from_input(self, loras):
|
||||
"""
|
||||
Build LORA_STACK tuple from input loras list for execution.
|
||||
|
||||
Args:
|
||||
loras: List of LoRA dicts with name, strength, clipStrength, active
|
||||
|
||||
Returns:
|
||||
List of tuples (lora_path, model_strength, clip_strength)
|
||||
"""
|
||||
lora_stack = []
|
||||
for lora in loras:
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
|
||||
# Get file path
|
||||
lora_path, trigger_words = get_lora_info(lora["name"])
|
||||
if not lora_path:
|
||||
logger.warning(
|
||||
f"[LoraRandomizerLM] Could not find path for LoRA: {lora['name']}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Normalize path separators
|
||||
lora_path = lora_path.replace("/", os.sep)
|
||||
|
||||
# Extract strengths (convert to float to prevent string subtraction errors)
|
||||
model_strength = float(lora.get("strength", 1.0))
|
||||
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||
|
||||
lora_stack.append((lora_path, model_strength, clip_strength))
|
||||
|
||||
return lora_stack
|
||||
|
||||
async def _generate_random_loras_for_ui(
|
||||
self, scanner, randomizer_config, input_loras, pool_config=None, seed=None
|
||||
):
|
||||
"""
|
||||
Generate new random loras for UI display.
|
||||
|
||||
Args:
|
||||
scanner: LoraScanner instance
|
||||
randomizer_config: Dict with randomizer settings
|
||||
input_loras: Current input loras (for extracting locked loras)
|
||||
pool_config: Optional pool filters
|
||||
seed: Optional seed for deterministic randomization
|
||||
|
||||
Returns:
|
||||
List of LoRA dicts for UI display
|
||||
"""
|
||||
from ..services.lora_service import LoraService
|
||||
|
||||
# Parse randomizer settings (convert numeric values to float to prevent type errors)
|
||||
count_mode = randomizer_config.get("count_mode", "range")
|
||||
count_fixed = int(randomizer_config.get("count_fixed", 5))
|
||||
count_min = int(randomizer_config.get("count_min", 3))
|
||||
count_max = int(randomizer_config.get("count_max", 7))
|
||||
model_strength_min = float(randomizer_config.get("model_strength_min", 0.0))
|
||||
model_strength_max = float(randomizer_config.get("model_strength_max", 1.0))
|
||||
use_same_clip_strength = randomizer_config.get("use_same_clip_strength", True)
|
||||
clip_strength_min = float(randomizer_config.get("clip_strength_min", 0.0))
|
||||
clip_strength_max = float(randomizer_config.get("clip_strength_max", 1.0))
|
||||
use_recommended_strength = randomizer_config.get(
|
||||
"use_recommended_strength", False
|
||||
)
|
||||
recommended_strength_scale_min = float(
|
||||
randomizer_config.get("recommended_strength_scale_min", 0.5)
|
||||
)
|
||||
recommended_strength_scale_max = float(
|
||||
randomizer_config.get("recommended_strength_scale_max", 1.0)
|
||||
)
|
||||
|
||||
# Extract locked LoRAs from input
|
||||
locked_loras = [lora for lora in input_loras if lora.get("locked", False)]
|
||||
|
||||
# Use LoraService to generate random LoRAs
|
||||
lora_service = LoraService(scanner)
|
||||
result_loras = await lora_service.get_random_loras(
|
||||
count=count_fixed,
|
||||
model_strength_min=model_strength_min,
|
||||
model_strength_max=model_strength_max,
|
||||
use_same_clip_strength=use_same_clip_strength,
|
||||
clip_strength_min=clip_strength_min,
|
||||
clip_strength_max=clip_strength_max,
|
||||
locked_loras=locked_loras,
|
||||
pool_config=pool_config,
|
||||
count_mode=count_mode,
|
||||
count_min=count_min,
|
||||
count_max=count_max,
|
||||
use_recommended_strength=use_recommended_strength,
|
||||
recommended_strength_scale_min=recommended_strength_scale_min,
|
||||
recommended_strength_scale_max=recommended_strength_scale_max,
|
||||
seed=seed,
|
||||
)
|
||||
|
||||
return result_loras
|
||||
@@ -1,4 +1,3 @@
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
|
||||
@@ -7,7 +6,7 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LoraStacker:
|
||||
class LoraStackerLM:
|
||||
NAME = "Lora Stacker (LoraManager)"
|
||||
CATEGORY = "Lora Manager/stackers"
|
||||
|
||||
@@ -15,18 +14,15 @@ class LoraStacker:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": (IO.STRING, {
|
||||
"multiline": True,
|
||||
"pysssss.autocomplete": False,
|
||||
"dynamicPrompts": True,
|
||||
"text": ("AUTOCOMPLETE_TEXT_LORAS", {
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
||||
}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LORA_STACK", IO.STRING, IO.STRING)
|
||||
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
|
||||
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
||||
FUNCTION = "stack_loras"
|
||||
|
||||
|
||||
58
py/nodes/prompt.py
Normal file
58
py/nodes/prompt.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
class PromptLM:
|
||||
"""Encodes text (and optional trigger words) into CLIP conditioning."""
|
||||
|
||||
NAME = "Prompt (LoraManager)"
|
||||
CATEGORY = "Lora Manager/conditioning"
|
||||
DESCRIPTION = (
|
||||
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||
"to guide the diffusion model towards generating specific images."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": (
|
||||
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
||||
{
|
||||
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
||||
"placeholder": "Enter prompt... /char, /artist for quick tag search",
|
||||
"tooltip": "The text to be encoded.",
|
||||
},
|
||||
),
|
||||
"clip": (
|
||||
'CLIP',
|
||||
{"tooltip": "The CLIP model used for encoding the text."},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"trigger_words": (
|
||||
'STRING',
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": (
|
||||
"Optional trigger words to prepend to the text before "
|
||||
"encoding."
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ('CONDITIONING', 'STRING',)
|
||||
RETURN_NAMES = ('CONDITIONING', 'PROMPT',)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"A conditioning containing the embedded text used to guide the diffusion model.",
|
||||
)
|
||||
FUNCTION = "encode"
|
||||
|
||||
def encode(self, text: str, clip: Any, trigger_words: Optional[str] = None):
|
||||
prompt = text
|
||||
if trigger_words:
|
||||
prompt = ", ".join([trigger_words, text])
|
||||
|
||||
from nodes import CLIPTextEncode # type: ignore
|
||||
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
|
||||
return (conditioning, prompt,)
|
||||
@@ -9,7 +9,7 @@ from ..metadata_collector import get_metadata
|
||||
from PIL import Image, PngImagePlugin
|
||||
import piexif
|
||||
|
||||
class SaveImage:
|
||||
class SaveImageLM:
|
||||
NAME = "Save Image (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = "Save images with embedded generation metadata in compatible format"
|
||||
@@ -273,9 +273,15 @@ class SaveImage:
|
||||
length = int(parts[1])
|
||||
prompt = prompt[:length]
|
||||
filename = filename.replace(segment, prompt.strip())
|
||||
elif key == "model" and 'checkpoint' in metadata_dict:
|
||||
model = metadata_dict.get('checkpoint', '')
|
||||
model = os.path.splitext(os.path.basename(model))[0]
|
||||
elif key == "model":
|
||||
model_value = metadata_dict.get('checkpoint')
|
||||
if isinstance(model_value, (bytes, os.PathLike)):
|
||||
model_value = str(model_value)
|
||||
|
||||
if not isinstance(model_value, str) or not model_value:
|
||||
model = "model_unavailable"
|
||||
else:
|
||||
model = os.path.splitext(os.path.basename(model_value))[0]
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
model = model[:length]
|
||||
@@ -442,4 +448,4 @@ class SaveImage:
|
||||
add_counter_to_filename
|
||||
)
|
||||
|
||||
return (images,)
|
||||
return (images,)
|
||||
|
||||
33
py/nodes/text.py
Normal file
33
py/nodes/text.py
Normal file
@@ -0,0 +1,33 @@
|
||||
class TextLM:
|
||||
"""A simple text node with autocomplete support."""
|
||||
|
||||
NAME = "Text (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = (
|
||||
"A simple text input node with autocomplete support for tags and styles."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": (
|
||||
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
||||
{
|
||||
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
||||
"placeholder": "Enter text... /char, /artist for quick tag search",
|
||||
"tooltip": "The text output.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("STRING",)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The text output.",
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
def process(self, text: str):
|
||||
return (text,)
|
||||
@@ -1,29 +1,41 @@
|
||||
import json
|
||||
import re
|
||||
from server import PromptServer # type: ignore
|
||||
from .utils import FlexibleOptionalInputType, any_type
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TriggerWordToggle:
|
||||
class TriggerWordToggleLM:
|
||||
NAME = "TriggerWord Toggle (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = "Toggle trigger words on/off"
|
||||
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"group_mode": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "When enabled, treats each group of trigger words as a single toggleable unit."
|
||||
}),
|
||||
"default_active": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Sets the default initial state (active or inactive) when trigger words are added."
|
||||
}),
|
||||
"group_mode": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "When enabled, treats each group of trigger words as a single toggleable unit.",
|
||||
},
|
||||
),
|
||||
"default_active": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Sets the default initial state (active or inactive) when trigger words are added.",
|
||||
},
|
||||
),
|
||||
"allow_strength_adjustment": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Enable mouse wheel adjustment of each trigger word's strength.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
"hidden": {
|
||||
@@ -35,63 +47,138 @@ class TriggerWordToggle:
|
||||
RETURN_NAMES = ("filtered_trigger_words",)
|
||||
FUNCTION = "process_trigger_words"
|
||||
|
||||
def _get_toggle_data(self, kwargs, key='toggle_trigger_words'):
|
||||
def _get_toggle_data(self, kwargs, key="toggle_trigger_words"):
|
||||
"""Helper to extract data from either old or new kwargs format"""
|
||||
if key not in kwargs:
|
||||
return None
|
||||
|
||||
|
||||
data = kwargs[key]
|
||||
# Handle new format: {'key': {'__value__': ...}}
|
||||
if isinstance(data, dict) and '__value__' in data:
|
||||
return data['__value__']
|
||||
if isinstance(data, dict) and "__value__" in data:
|
||||
return data["__value__"]
|
||||
# Handle old format: {'key': ...}
|
||||
else:
|
||||
return data
|
||||
|
||||
def process_trigger_words(self, id, group_mode, default_active, **kwargs):
|
||||
def process_trigger_words(
|
||||
self,
|
||||
id,
|
||||
group_mode,
|
||||
default_active,
|
||||
allow_strength_adjustment=False,
|
||||
**kwargs,
|
||||
):
|
||||
# Handle both old and new formats for trigger_words
|
||||
trigger_words_data = self._get_toggle_data(kwargs, 'orinalMessage')
|
||||
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""
|
||||
|
||||
trigger_words_data = self._get_toggle_data(kwargs, "orinalMessage")
|
||||
trigger_words = (
|
||||
trigger_words_data if isinstance(trigger_words_data, str) else ""
|
||||
)
|
||||
|
||||
filtered_triggers = trigger_words
|
||||
|
||||
|
||||
# Check if trigger_words is provided and different from orinalMessage
|
||||
trigger_words_override = self._get_toggle_data(kwargs, "trigger_words")
|
||||
if (
|
||||
trigger_words_override
|
||||
and isinstance(trigger_words_override, str)
|
||||
and trigger_words_override != trigger_words
|
||||
):
|
||||
filtered_triggers = trigger_words_override
|
||||
return (filtered_triggers,)
|
||||
|
||||
# Get toggle data with support for both formats
|
||||
trigger_data = self._get_toggle_data(kwargs, 'toggle_trigger_words')
|
||||
trigger_data = self._get_toggle_data(kwargs, "toggle_trigger_words")
|
||||
if trigger_data:
|
||||
try:
|
||||
# Convert to list if it's a JSON string
|
||||
if isinstance(trigger_data, str):
|
||||
trigger_data = json.loads(trigger_data)
|
||||
|
||||
# Create dictionaries to track active state of words or groups
|
||||
active_state = {item['text']: item.get('active', False) for item in trigger_data}
|
||||
|
||||
if group_mode:
|
||||
# Split by two or more consecutive commas to get groups
|
||||
groups = re.split(r',{2,}', trigger_words)
|
||||
# Remove leading/trailing whitespace from each group
|
||||
groups = [group.strip() for group in groups]
|
||||
|
||||
# Filter groups: keep those not in toggle_trigger_words or those that are active
|
||||
filtered_groups = [group for group in groups if group not in active_state or active_state[group]]
|
||||
|
||||
if filtered_groups:
|
||||
filtered_triggers = ', '.join(filtered_groups)
|
||||
|
||||
if isinstance(trigger_data, list):
|
||||
if group_mode:
|
||||
if allow_strength_adjustment:
|
||||
parsed_items = [
|
||||
self._parse_trigger_item(
|
||||
item, allow_strength_adjustment
|
||||
)
|
||||
for item in trigger_data
|
||||
]
|
||||
filtered_groups = [
|
||||
self._format_word_output(
|
||||
item["text"],
|
||||
item["strength"],
|
||||
allow_strength_adjustment,
|
||||
)
|
||||
for item in parsed_items
|
||||
if item["text"] and item["active"]
|
||||
]
|
||||
else:
|
||||
filtered_groups = [
|
||||
(item.get("text") or "").strip()
|
||||
for item in trigger_data
|
||||
if (item.get("text") or "").strip()
|
||||
and item.get("active", False)
|
||||
]
|
||||
filtered_triggers = (
|
||||
", ".join(filtered_groups) if filtered_groups else ""
|
||||
)
|
||||
else:
|
||||
filtered_triggers = ""
|
||||
parsed_items = [
|
||||
self._parse_trigger_item(item, allow_strength_adjustment)
|
||||
for item in trigger_data
|
||||
]
|
||||
filtered_words = [
|
||||
self._format_word_output(
|
||||
item["text"],
|
||||
item["strength"],
|
||||
allow_strength_adjustment,
|
||||
)
|
||||
for item in parsed_items
|
||||
if item["text"] and item["active"]
|
||||
]
|
||||
filtered_triggers = (
|
||||
", ".join(filtered_words) if filtered_words else ""
|
||||
)
|
||||
else:
|
||||
# Original behavior for individual words mode
|
||||
original_words = [word.strip() for word in trigger_words.split(',')]
|
||||
# Filter out empty strings
|
||||
original_words = [word for word in original_words if word]
|
||||
filtered_words = [word for word in original_words if word not in active_state or active_state[word]]
|
||||
|
||||
if filtered_words:
|
||||
filtered_triggers = ', '.join(filtered_words)
|
||||
# Fallback to original message parsing if data is not in the expected list format
|
||||
if group_mode:
|
||||
groups = re.split(r",{2,}", trigger_words)
|
||||
groups = [group.strip() for group in groups if group.strip()]
|
||||
filtered_triggers = ", ".join(groups)
|
||||
else:
|
||||
filtered_triggers = ""
|
||||
|
||||
words = [
|
||||
word.strip()
|
||||
for word in trigger_words.split(",")
|
||||
if word.strip()
|
||||
]
|
||||
filtered_triggers = ", ".join(words)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing trigger words: {e}")
|
||||
|
||||
return (filtered_triggers,)
|
||||
|
||||
return (filtered_triggers,)
|
||||
|
||||
def _parse_trigger_item(self, item, allow_strength_adjustment):
|
||||
text = (item.get("text") or "").strip()
|
||||
active = bool(item.get("active", False))
|
||||
strength = item.get("strength")
|
||||
|
||||
strength_match = re.match(r"^\((.+):([\d.]+)\)$", text)
|
||||
if strength_match:
|
||||
text = strength_match.group(1).strip()
|
||||
if strength is None:
|
||||
try:
|
||||
strength = float(strength_match.group(2))
|
||||
except ValueError:
|
||||
strength = None
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"active": active,
|
||||
"strength": strength if allow_strength_adjustment else None,
|
||||
}
|
||||
|
||||
def _format_word_output(self, base_word, strength, allow_strength_adjustment):
|
||||
if allow_strength_adjustment and strength is not None:
|
||||
return f"({base_word}:{strength:.2f})"
|
||||
return base_word
|
||||
|
||||
@@ -36,6 +36,7 @@ any_type = AnyType("*")
|
||||
import os
|
||||
import logging
|
||||
import copy
|
||||
import sys
|
||||
import folder_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -98,22 +99,38 @@ def to_diffusers(input_lora):
|
||||
|
||||
def nunchaku_load_lora(model, lora_name, lora_strength):
|
||||
"""Load a Flux LoRA for Nunchaku model"""
|
||||
# Get full path to the LoRA file. Allow both direct paths and registered LoRA names.
|
||||
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
|
||||
if not lora_path or not os.path.isfile(lora_path):
|
||||
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
|
||||
return model
|
||||
|
||||
model_wrapper = model.model.diffusion_model
|
||||
transformer = model_wrapper.model
|
||||
|
||||
# Save the transformer temporarily
|
||||
model_wrapper.model = None
|
||||
ret_model = copy.deepcopy(model) # copy everything except the model
|
||||
ret_model_wrapper = ret_model.model.diffusion_model
|
||||
|
||||
# Restore the model and set it for the copy
|
||||
model_wrapper.model = transformer
|
||||
ret_model_wrapper.model = transformer
|
||||
|
||||
# Get full path to the LoRA file
|
||||
lora_path = folder_paths.get_full_path("loras", lora_name)
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
|
||||
# Try to find copy_with_ctx in the same module as ComfyFluxWrapper
|
||||
module_name = model_wrapper.__class__.__module__
|
||||
module = sys.modules.get(module_name)
|
||||
copy_with_ctx = getattr(module, "copy_with_ctx", None)
|
||||
|
||||
if copy_with_ctx is not None:
|
||||
# New logic using copy_with_ctx from ComfyUI-nunchaku 1.1.0+
|
||||
ret_model_wrapper, ret_model = copy_with_ctx(model_wrapper)
|
||||
ret_model_wrapper.loras = [*model_wrapper.loras, (lora_path, lora_strength)]
|
||||
else:
|
||||
# Fallback to legacy logic
|
||||
logger.warning("Please upgrade ComfyUI-nunchaku to 1.1.0 or above for better LoRA support. Falling back to legacy loading logic.")
|
||||
transformer = model_wrapper.model
|
||||
|
||||
# Save the transformer temporarily
|
||||
model_wrapper.model = None
|
||||
ret_model = copy.deepcopy(model) # copy everything except the model
|
||||
ret_model_wrapper = ret_model.model.diffusion_model
|
||||
|
||||
# Restore the model and set it for the copy
|
||||
model_wrapper.model = transformer
|
||||
ret_model_wrapper.model = transformer
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
|
||||
# Convert the LoRA to diffusers format
|
||||
sd = to_diffusers(lora_path)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
import folder_paths # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
||||
@@ -6,7 +5,7 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WanVideoLoraSelect:
|
||||
class WanVideoLoraSelectLM:
|
||||
NAME = "WanVideo Lora Select (LoraManager)"
|
||||
CATEGORY = "Lora Manager/stackers"
|
||||
|
||||
@@ -16,18 +15,15 @@ class WanVideoLoraSelect:
|
||||
"required": {
|
||||
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
|
||||
"merge_loras": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
||||
"text": (IO.STRING, {
|
||||
"multiline": True,
|
||||
"pysssss.autocomplete": False,
|
||||
"dynamicPrompts": True,
|
||||
"text": ("AUTOCOMPLETE_TEXT_LORAS", {
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
||||
}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
|
||||
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||
FUNCTION = "process_loras"
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from comfy.comfy_types import IO
|
||||
import folder_paths
|
||||
import folder_paths # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import any_type
|
||||
import logging
|
||||
@@ -8,7 +7,7 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 定义新节点的类
|
||||
class WanVideoLoraSelectFromText:
|
||||
class WanVideoLoraTextSelectLM:
|
||||
# 节点在UI中显示的名称
|
||||
NAME = "WanVideo Lora Select From Text (LoraManager)"
|
||||
# 节点所属的分类
|
||||
@@ -20,9 +19,8 @@ class WanVideoLoraSelectFromText:
|
||||
"required": {
|
||||
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
|
||||
"merge_lora": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
||||
"lora_syntax": (IO.STRING, {
|
||||
"lora_syntax": ("STRING", {
|
||||
"multiline": True,
|
||||
"defaultInput": True,
|
||||
"forceInput": True,
|
||||
"tooltip": "Connect a TEXT output for LoRA syntax: <lora:name:strength>"
|
||||
}),
|
||||
@@ -34,7 +32,7 @@ class WanVideoLoraSelectFromText:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
|
||||
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||
|
||||
FUNCTION = "process_loras_from_syntax"
|
||||
@@ -117,11 +115,3 @@ class WanVideoLoraSelectFromText:
|
||||
active_loras_text = " ".join(formatted_loras)
|
||||
|
||||
return (loras_list, trigger_words_text, active_loras_text)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"WanVideoLoraSelectFromText": WanVideoLoraSelectFromText
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"WanVideoLoraSelectFromText": "WanVideo Lora Select From Text (LoraManager)"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict, List, Any, Optional, Tuple
|
||||
from abc import ABC, abstractmethod
|
||||
from ..config import config
|
||||
from ..utils.constants import VALID_LORA_TYPES
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,7 +37,8 @@ class RecipeMetadataParser(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
async def populate_lora_from_civitai(self, lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]],
|
||||
@staticmethod
|
||||
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]],
|
||||
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Populate a lora entry with information from Civitai API response
|
||||
@@ -78,7 +80,7 @@ class RecipeMetadataParser(ABC):
|
||||
# Update model name if available
|
||||
if 'model' in civitai_info and 'name' in civitai_info['model']:
|
||||
lora_entry['name'] = civitai_info['model']['name']
|
||||
|
||||
|
||||
lora_entry['id'] = civitai_info.get('id')
|
||||
lora_entry['modelId'] = civitai_info.get('modelId')
|
||||
|
||||
@@ -88,7 +90,10 @@ class RecipeMetadataParser(ABC):
|
||||
|
||||
# Get thumbnail URL from first image
|
||||
if 'images' in civitai_info and civitai_info['images']:
|
||||
lora_entry['thumbnailUrl'] = civitai_info['images'][0].get('url', '')
|
||||
image_url = civitai_info['images'][0].get('url')
|
||||
if image_url:
|
||||
rewritten_image_url, _ = rewrite_preview_url(image_url, media_type='image')
|
||||
lora_entry['thumbnailUrl'] = rewritten_image_url or image_url
|
||||
|
||||
# Get base model
|
||||
current_base_model = civitai_info.get('baseModel', '')
|
||||
@@ -144,40 +149,68 @@ class RecipeMetadataParser(ABC):
|
||||
logger.error(f"Error populating lora from Civitai info: {e}")
|
||||
|
||||
return lora_entry
|
||||
|
||||
async def populate_checkpoint_from_civitai(self, checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
@staticmethod
|
||||
async def populate_checkpoint_from_civitai(checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Populate checkpoint information from Civitai API response
|
||||
|
||||
Args:
|
||||
checkpoint: The checkpoint entry to populate
|
||||
civitai_info: The response from Civitai API
|
||||
civitai_info: The response from Civitai API or a (data, error_msg) tuple
|
||||
|
||||
Returns:
|
||||
The populated checkpoint dict
|
||||
"""
|
||||
try:
|
||||
if civitai_info and civitai_info.get("error") != "Model not found":
|
||||
# Update model name if available
|
||||
if 'model' in civitai_info and 'name' in civitai_info['model']:
|
||||
checkpoint['name'] = civitai_info['model']['name']
|
||||
|
||||
# Update version if available
|
||||
if 'name' in civitai_info:
|
||||
checkpoint['version'] = civitai_info.get('name', '')
|
||||
|
||||
# Get thumbnail URL from first image
|
||||
if 'images' in civitai_info and civitai_info['images']:
|
||||
checkpoint['thumbnailUrl'] = civitai_info['images'][0].get('url', '')
|
||||
|
||||
# Get base model
|
||||
checkpoint['baseModel'] = civitai_info.get('baseModel', '')
|
||||
|
||||
# Get download URL
|
||||
checkpoint['downloadUrl'] = civitai_info.get('downloadUrl', '')
|
||||
else:
|
||||
# Model not found or deleted
|
||||
civitai_data, error_msg = (
|
||||
(civitai_info, None)
|
||||
if not isinstance(civitai_info, tuple)
|
||||
else civitai_info
|
||||
)
|
||||
|
||||
if not civitai_data or error_msg == "Model not found":
|
||||
checkpoint['isDeleted'] = True
|
||||
return checkpoint
|
||||
|
||||
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||
checkpoint['name'] = civitai_data['model']['name']
|
||||
|
||||
if 'name' in civitai_data:
|
||||
checkpoint['version'] = civitai_data.get('name', '')
|
||||
|
||||
if 'images' in civitai_data and civitai_data['images']:
|
||||
image_url = civitai_data['images'][0].get('url')
|
||||
if image_url:
|
||||
rewritten_image_url, _ = rewrite_preview_url(image_url, media_type='image')
|
||||
checkpoint['thumbnailUrl'] = rewritten_image_url or image_url
|
||||
|
||||
checkpoint['baseModel'] = civitai_data.get('baseModel', '')
|
||||
checkpoint['downloadUrl'] = civitai_data.get('downloadUrl', '')
|
||||
|
||||
checkpoint['modelId'] = civitai_data.get('modelId', checkpoint.get('modelId', 0))
|
||||
checkpoint['id'] = civitai_data.get('id', 0)
|
||||
|
||||
if 'files' in civitai_data:
|
||||
model_file = next(
|
||||
(
|
||||
file
|
||||
for file in civitai_data.get('files', [])
|
||||
if file.get('type') == 'Model'
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if model_file:
|
||||
checkpoint['size'] = model_file.get('sizeKB', 0) * 1024
|
||||
|
||||
sha256 = model_file.get('hashes', {}).get('SHA256')
|
||||
if sha256:
|
||||
checkpoint['hash'] = sha256.lower()
|
||||
|
||||
file_name = model_file.get('name', '')
|
||||
if file_name:
|
||||
checkpoint['file_name'] = os.path.splitext(file_name)[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Error populating checkpoint from Civitai info: {e}")
|
||||
|
||||
|
||||
216
py/recipes/enrichment.py
Normal file
216
py/recipes/enrichment.py
Normal file
@@ -0,0 +1,216 @@
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
from .merger import GenParamsMerger
|
||||
from .base import RecipeMetadataParser
|
||||
from ..services.metadata_service import get_default_metadata_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RecipeEnricher:
|
||||
"""Service to enrich recipe metadata from multiple sources (Civitai, Embedded, User)."""
|
||||
|
||||
@staticmethod
|
||||
async def enrich_recipe(
|
||||
recipe: Dict[str, Any],
|
||||
civitai_client: Any,
|
||||
request_params: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
||||
|
||||
Args:
|
||||
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
||||
civitai_client: Authenticated Civitai client instance.
|
||||
request_params: (Optional) Parameters from a user request (e.g. import).
|
||||
|
||||
Returns:
|
||||
bool: True if the recipe was modified, False otherwise.
|
||||
"""
|
||||
updated = False
|
||||
gen_params = recipe.get("gen_params", {})
|
||||
|
||||
# 1. Fetch Civitai Info if available
|
||||
civitai_meta = None
|
||||
model_version_id = None
|
||||
|
||||
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
||||
|
||||
# Check if it's a Civitai image URL
|
||||
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
|
||||
if image_id_match:
|
||||
image_id = image_id_match.group(1)
|
||||
try:
|
||||
image_info = await civitai_client.get_image_info(image_id)
|
||||
if image_info:
|
||||
# Handle nested meta often found in Civitai API responses
|
||||
raw_meta = image_info.get("meta")
|
||||
if isinstance(raw_meta, dict):
|
||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||
civitai_meta = raw_meta["meta"]
|
||||
else:
|
||||
civitai_meta = raw_meta
|
||||
|
||||
model_version_id = image_info.get("modelVersionId")
|
||||
|
||||
# If not at top level, check resources in meta
|
||||
if not model_version_id and civitai_meta:
|
||||
resources = civitai_meta.get("civitaiResources", [])
|
||||
for res in resources:
|
||||
if res.get("type") == "checkpoint":
|
||||
model_version_id = res.get("modelVersionId")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||
|
||||
# 2. Merge Parameters
|
||||
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
||||
new_gen_params = GenParamsMerger.merge(
|
||||
request_params=request_params,
|
||||
civitai_meta=civitai_meta,
|
||||
embedded_metadata=gen_params
|
||||
)
|
||||
|
||||
if new_gen_params != gen_params:
|
||||
recipe["gen_params"] = new_gen_params
|
||||
updated = True
|
||||
|
||||
# 3. Checkpoint Enrichment
|
||||
# If we have a checkpoint entry, or we can find one
|
||||
# Use 'id' (from Civitai version) as a marker that it's been enriched
|
||||
checkpoint_entry = recipe.get("checkpoint")
|
||||
has_full_checkpoint = checkpoint_entry and checkpoint_entry.get("name") and checkpoint_entry.get("id")
|
||||
|
||||
if not has_full_checkpoint:
|
||||
# Helper to look up values in priority order
|
||||
def start_lookup(keys):
|
||||
for source in [request_params, civitai_meta, gen_params]:
|
||||
if source:
|
||||
if isinstance(keys, list):
|
||||
for k in keys:
|
||||
if k in source: return source[k]
|
||||
else:
|
||||
if keys in source: return source[keys]
|
||||
return None
|
||||
|
||||
target_version_id = model_version_id or start_lookup("modelVersionId")
|
||||
|
||||
# Also check existing checkpoint entry
|
||||
if not target_version_id and checkpoint_entry:
|
||||
target_version_id = checkpoint_entry.get("modelVersionId") or checkpoint_entry.get("id")
|
||||
|
||||
# Check for version ID in resources (which might be a string in gen_params)
|
||||
if not target_version_id:
|
||||
# Look in all sources for "Civitai resources"
|
||||
resources_val = start_lookup(["Civitai resources", "civitai_resources", "resources"])
|
||||
if resources_val:
|
||||
target_version_id = RecipeEnricher._extract_version_id_from_resources({"Civitai resources": resources_val})
|
||||
|
||||
target_hash = start_lookup(["Model hash", "checkpoint_hash", "hashes"])
|
||||
if not target_hash and checkpoint_entry:
|
||||
target_hash = checkpoint_entry.get("hash") or checkpoint_entry.get("model_hash")
|
||||
|
||||
# Look for 'Model' which sometimes is the hash or name
|
||||
model_val = start_lookup("Model")
|
||||
|
||||
# Look for Checkpoint name fallback
|
||||
checkpoint_val = checkpoint_entry.get("name") if checkpoint_entry else None
|
||||
if not checkpoint_val:
|
||||
checkpoint_val = start_lookup(["Checkpoint", "checkpoint"])
|
||||
|
||||
checkpoint_updated = await RecipeEnricher._resolve_and_populate_checkpoint(
|
||||
recipe, target_version_id, target_hash, model_val, checkpoint_val
|
||||
)
|
||||
if checkpoint_updated:
|
||||
updated = True
|
||||
else:
|
||||
# Checkpoint exists, no need to sync to gen_params anymore.
|
||||
pass
|
||||
# base_model resolution moved to _resolve_and_populate_checkpoint to support strict formatting
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def _extract_version_id_from_resources(gen_params: Dict[str, Any]) -> Optional[Any]:
|
||||
"""Try to find modelVersionId in Civitai resources parameter."""
|
||||
civitai_resources_raw = gen_params.get("Civitai resources")
|
||||
if not civitai_resources_raw:
|
||||
return None
|
||||
|
||||
resources_list = None
|
||||
if isinstance(civitai_resources_raw, str):
|
||||
try:
|
||||
resources_list = json.loads(civitai_resources_raw)
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(civitai_resources_raw, list):
|
||||
resources_list = civitai_resources_raw
|
||||
|
||||
if isinstance(resources_list, list):
|
||||
for res in resources_list:
|
||||
if res.get("type") == "checkpoint":
|
||||
return res.get("modelVersionId")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_and_populate_checkpoint(
|
||||
recipe: Dict[str, Any],
|
||||
target_version_id: Optional[Any],
|
||||
target_hash: Optional[str],
|
||||
model_val: Optional[str],
|
||||
checkpoint_val: Optional[str]
|
||||
) -> bool:
|
||||
"""Find checkpoint metadata and populate it in the recipe."""
|
||||
metadata_provider = await get_default_metadata_provider()
|
||||
civitai_info = None
|
||||
|
||||
if target_version_id:
|
||||
civitai_info = await metadata_provider.get_model_version_info(str(target_version_id))
|
||||
elif target_hash:
|
||||
civitai_info = await metadata_provider.get_model_by_hash(target_hash)
|
||||
else:
|
||||
# Look for 'Model' which sometimes is the hash or name
|
||||
if model_val and len(model_val) == 10: # Likely a short hash
|
||||
civitai_info = await metadata_provider.get_model_by_hash(model_val)
|
||||
|
||||
if civitai_info and not (isinstance(civitai_info, tuple) and civitai_info[1] == "Model not found"):
|
||||
# If we already have a partial checkpoint, use it as base
|
||||
existing_cp = recipe.get("checkpoint")
|
||||
if existing_cp is None:
|
||||
existing_cp = {}
|
||||
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
|
||||
# 1. First, resolve base_model using full data before we format it away
|
||||
current_base_model = recipe.get("base_model")
|
||||
resolved_base_model = checkpoint_data.get("baseModel")
|
||||
if resolved_base_model:
|
||||
# Update if empty OR if it matches our generic prefix but is less specific
|
||||
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
|
||||
if is_generic and resolved_base_model != current_base_model:
|
||||
recipe["base_model"] = resolved_base_model
|
||||
|
||||
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName
|
||||
formatted_checkpoint = {
|
||||
"type": "checkpoint",
|
||||
"modelId": checkpoint_data.get("modelId"),
|
||||
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name']
|
||||
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name']
|
||||
}
|
||||
# Remove None values
|
||||
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
||||
|
||||
return True
|
||||
else:
|
||||
# Fallback to name extraction if we don't already have one
|
||||
existing_cp = recipe.get("checkpoint")
|
||||
if not existing_cp or not existing_cp.get("modelName"):
|
||||
cp_name = checkpoint_val
|
||||
if cp_name:
|
||||
recipe["checkpoint"] = {
|
||||
"type": "checkpoint",
|
||||
"modelName": cp_name
|
||||
}
|
||||
return True
|
||||
|
||||
return False
|
||||
98
py/recipes/merger.py
Normal file
98
py/recipes/merger.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GenParamsMerger:
|
||||
"""Utility to merge generation parameters from multiple sources with priority."""
|
||||
|
||||
BLACKLISTED_KEYS = {
|
||||
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
|
||||
"draft", "extra", "width", "height", "process", "quantity", "workflow",
|
||||
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
|
||||
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
|
||||
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
|
||||
"checkpoint", "checksum", "model_checksum"
|
||||
}
|
||||
|
||||
NORMALIZATION_MAPPING = {
|
||||
# Civitai specific
|
||||
"cfgScale": "cfg_scale",
|
||||
"clipSkip": "clip_skip",
|
||||
"negativePrompt": "negative_prompt",
|
||||
# Case variations
|
||||
"Sampler": "sampler",
|
||||
"Steps": "steps",
|
||||
"Seed": "seed",
|
||||
"Size": "size",
|
||||
"Prompt": "prompt",
|
||||
"Negative prompt": "negative_prompt",
|
||||
"Cfg scale": "cfg_scale",
|
||||
"Clip skip": "clip_skip",
|
||||
"Denoising strength": "denoising_strength",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def merge(
|
||||
request_params: Optional[Dict[str, Any]] = None,
|
||||
civitai_meta: Optional[Dict[str, Any]] = None,
|
||||
embedded_metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge generation parameters from three sources.
|
||||
|
||||
Priority: request_params > civitai_meta > embedded_metadata
|
||||
|
||||
Args:
|
||||
request_params: Params provided directly in the import request
|
||||
civitai_meta: Params from Civitai Image API 'meta' field
|
||||
embedded_metadata: Params extracted from image EXIF/embedded metadata
|
||||
|
||||
Returns:
|
||||
Merged parameters dictionary
|
||||
"""
|
||||
result = {}
|
||||
|
||||
# 1. Start with embedded metadata (lowest priority)
|
||||
if embedded_metadata:
|
||||
# If it's a full recipe metadata, we use its gen_params
|
||||
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
|
||||
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
|
||||
else:
|
||||
# Otherwise assume the dict itself contains gen_params
|
||||
GenParamsMerger._update_normalized(result, embedded_metadata)
|
||||
|
||||
# 2. Layer Civitai meta (medium priority)
|
||||
if civitai_meta:
|
||||
GenParamsMerger._update_normalized(result, civitai_meta)
|
||||
|
||||
# 3. Layer request params (highest priority)
|
||||
if request_params:
|
||||
GenParamsMerger._update_normalized(result, request_params)
|
||||
|
||||
# Filter out blacklisted keys and also the original camelCase keys if they were normalized
|
||||
final_result = {}
|
||||
for k, v in result.items():
|
||||
if k in GenParamsMerger.BLACKLISTED_KEYS:
|
||||
continue
|
||||
if k in GenParamsMerger.NORMALIZATION_MAPPING:
|
||||
continue
|
||||
final_result[k] = v
|
||||
|
||||
return final_result
|
||||
|
||||
@staticmethod
|
||||
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||
"""Update target dict with normalized keys from source."""
|
||||
for k, v in source.items():
|
||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k)
|
||||
target[normalized_key] = v
|
||||
# Also keep the original key for now if it's not the same,
|
||||
# so we can filter at the end or avoid losing it if it wasn't supposed to be renamed?
|
||||
# Actually, if we rename it, we should probably NOT keep both in 'target'
|
||||
# because we want to filter them out at the end anyway.
|
||||
if normalized_key != k:
|
||||
# If we are overwriting an existing snake_case key with a camelCase one's value,
|
||||
# that's fine because of the priority order of calls to _update_normalized.
|
||||
pass
|
||||
target[k] = v
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Parser for Automatic1111 metadata format."""
|
||||
|
||||
import re
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
@@ -22,6 +23,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
CIVITAI_METADATA_REGEX = r', Civitai metadata:\s*(\{.*?\})'
|
||||
EXTRANETS_REGEX = r'<(lora|hypernet):([^:]+):(-?[0-9.]+)>'
|
||||
MODEL_HASH_PATTERN = r'Model hash: ([a-zA-Z0-9]+)'
|
||||
MODEL_NAME_PATTERN = r'Model: ([^,]+)'
|
||||
VAE_HASH_PATTERN = r'VAE hash: ([a-zA-Z0-9]+)'
|
||||
|
||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||
@@ -115,6 +117,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Error parsing hashes JSON")
|
||||
|
||||
# Pick up model hash from parsed hashes if available
|
||||
if "hashes" in metadata and not metadata.get("model_hash"):
|
||||
model_hash_from_hashes = metadata["hashes"].get("model")
|
||||
if model_hash_from_hashes:
|
||||
metadata["model_hash"] = model_hash_from_hashes
|
||||
|
||||
# Extract Lora hashes in alternative format
|
||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||
if not hashes_match and lora_hashes_match:
|
||||
@@ -137,6 +145,17 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing Lora hashes: {e}")
|
||||
|
||||
# Extract checkpoint model hash/name when provided outside Civitai resources
|
||||
model_hash_match = re.search(self.MODEL_HASH_PATTERN, params_section)
|
||||
if model_hash_match:
|
||||
metadata["model_hash"] = model_hash_match.group(1).strip()
|
||||
params_section = params_section.replace(model_hash_match.group(0), '')
|
||||
|
||||
model_name_match = re.search(self.MODEL_NAME_PATTERN, params_section)
|
||||
if model_name_match:
|
||||
metadata["model_name"] = model_name_match.group(1).strip()
|
||||
params_section = params_section.replace(model_name_match.group(0), '')
|
||||
|
||||
# Extract basic parameters
|
||||
param_pattern = r'([A-Za-z\s]+): ([^,]+)'
|
||||
@@ -178,9 +197,10 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
|
||||
metadata["gen_params"] = gen_params
|
||||
|
||||
# Extract LoRA information
|
||||
# Extract LoRA and checkpoint information
|
||||
loras = []
|
||||
base_model_counts = {}
|
||||
checkpoint = None
|
||||
|
||||
# First use Civitai resources if available (more reliable source)
|
||||
if metadata.get("civitai_resources"):
|
||||
@@ -202,6 +222,50 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
resource["modelVersionId"] = air_modelVersionId
|
||||
# --- End added ---
|
||||
|
||||
if resource.get("type") == "checkpoint" and resource.get("modelVersionId"):
|
||||
version_id = resource.get("modelVersionId")
|
||||
version_id_str = str(version_id)
|
||||
checkpoint_entry = {
|
||||
'id': version_id,
|
||||
'modelId': resource.get("modelId", 0),
|
||||
'name': resource.get("modelName", "Unknown Checkpoint"),
|
||||
'version': resource.get("modelVersionName", resource.get("versionName", "")),
|
||||
'type': resource.get("type", "checkpoint"),
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': resource.get("modelName", ""),
|
||||
'hash': resource.get("hash", "") or "",
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_version_info(version_id_str)
|
||||
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||
checkpoint_entry,
|
||||
civitai_info
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching Civitai info for checkpoint version %s: %s",
|
||||
version_id,
|
||||
e,
|
||||
)
|
||||
|
||||
# Prefer the first checkpoint found
|
||||
if checkpoint_entry.get("baseModel"):
|
||||
base_model_value = checkpoint_entry["baseModel"]
|
||||
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
|
||||
|
||||
if checkpoint is None:
|
||||
checkpoint = checkpoint_entry
|
||||
|
||||
continue
|
||||
|
||||
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
|
||||
# Initialize lora entry
|
||||
lora_entry = {
|
||||
@@ -237,6 +301,52 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Fallback checkpoint parsing from generic "Model" and "Model hash" fields
|
||||
if checkpoint is None:
|
||||
model_hash = metadata.get("model_hash")
|
||||
if not model_hash and metadata.get("hashes"):
|
||||
model_hash = metadata["hashes"].get("model")
|
||||
|
||||
model_name = metadata.get("model_name")
|
||||
file_name = ""
|
||||
if model_name:
|
||||
cleaned_name = re.split(r"[\\\\/]", model_name)[-1]
|
||||
file_name = os.path.splitext(cleaned_name)[0]
|
||||
|
||||
if model_hash or model_name:
|
||||
checkpoint_entry = {
|
||||
'id': 0,
|
||||
'modelId': 0,
|
||||
'name': model_name or "Unknown Checkpoint",
|
||||
'version': '',
|
||||
'type': 'checkpoint',
|
||||
'hash': model_hash or "",
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': file_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
if metadata_provider and model_hash:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_by_hash(model_hash)
|
||||
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||
checkpoint_entry,
|
||||
civitai_info
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for checkpoint hash {model_hash}: {e}")
|
||||
|
||||
if checkpoint_entry.get("baseModel"):
|
||||
base_model_value = checkpoint_entry["baseModel"]
|
||||
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
|
||||
|
||||
checkpoint = checkpoint_entry
|
||||
|
||||
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
|
||||
if not loras or len(loras) == 0:
|
||||
# Extract lora weights from extranet tags in prompt (for later use)
|
||||
@@ -300,7 +410,9 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
|
||||
# Try to get base model from resources or make educated guess
|
||||
base_model = None
|
||||
if base_model_counts:
|
||||
if checkpoint and checkpoint.get("baseModel"):
|
||||
base_model = checkpoint.get("baseModel")
|
||||
elif base_model_counts:
|
||||
# Use the most common base model from the loras
|
||||
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||
|
||||
@@ -317,6 +429,10 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
'gen_params': filtered_gen_params,
|
||||
'from_automatic_metadata': True
|
||||
}
|
||||
|
||||
if checkpoint:
|
||||
result['checkpoint'] = checkpoint
|
||||
result['model'] = checkpoint
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -23,13 +23,48 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
"""
|
||||
if not metadata or not isinstance(metadata, dict):
|
||||
return False
|
||||
|
||||
# Check for key markers specific to Civitai image metadata
|
||||
return any([
|
||||
"resources" in metadata,
|
||||
"civitaiResources" in metadata,
|
||||
"additionalResources" in metadata
|
||||
])
|
||||
|
||||
def has_markers(payload: Dict[str, Any]) -> bool:
|
||||
# Check for common CivitAI image metadata fields
|
||||
civitai_image_fields = (
|
||||
"resources",
|
||||
"civitaiResources",
|
||||
"additionalResources",
|
||||
"hashes",
|
||||
"prompt",
|
||||
"negativePrompt",
|
||||
"steps",
|
||||
"sampler",
|
||||
"cfgScale",
|
||||
"seed",
|
||||
"width",
|
||||
"height",
|
||||
"Model",
|
||||
"Model hash"
|
||||
)
|
||||
return any(key in payload for key in civitai_image_fields)
|
||||
|
||||
# Check the main metadata object
|
||||
if has_markers(metadata):
|
||||
return True
|
||||
|
||||
# Check for LoRA hash patterns
|
||||
hashes = metadata.get("hashes")
|
||||
if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes):
|
||||
return True
|
||||
|
||||
# Check nested meta object (common in CivitAI image responses)
|
||||
nested_meta = metadata.get("meta")
|
||||
if isinstance(nested_meta, dict):
|
||||
if has_markers(nested_meta):
|
||||
return True
|
||||
|
||||
# Also check for LoRA hash patterns in nested meta
|
||||
hashes = nested_meta.get("hashes")
|
||||
if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||
"""Parse metadata from Civitai image format
|
||||
@@ -45,11 +80,32 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
try:
|
||||
# Get metadata provider instead of using civitai_client directly
|
||||
metadata_provider = await get_default_metadata_provider()
|
||||
|
||||
# Civitai image responses may wrap the actual metadata inside a "meta" key
|
||||
if (
|
||||
isinstance(metadata, dict)
|
||||
and "meta" in metadata
|
||||
and isinstance(metadata["meta"], dict)
|
||||
):
|
||||
inner_meta = metadata["meta"]
|
||||
if any(
|
||||
key in inner_meta
|
||||
for key in (
|
||||
"resources",
|
||||
"civitaiResources",
|
||||
"additionalResources",
|
||||
"hashes",
|
||||
"prompt",
|
||||
"negativePrompt",
|
||||
)
|
||||
):
|
||||
metadata = inner_meta
|
||||
|
||||
# Initialize result structure
|
||||
result = {
|
||||
'base_model': None,
|
||||
'loras': [],
|
||||
'model': None,
|
||||
'gen_params': {},
|
||||
'from_civitai_image': True
|
||||
}
|
||||
@@ -61,8 +117,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
lora_hashes = {}
|
||||
if "hashes" in metadata and isinstance(metadata["hashes"], dict):
|
||||
for key, hash_value in metadata["hashes"].items():
|
||||
if key.startswith("LORA:"):
|
||||
lora_name = key.replace("LORA:", "")
|
||||
key_str = str(key)
|
||||
if key_str.lower().startswith("lora:"):
|
||||
lora_name = key_str.split(":", 1)[1]
|
||||
lora_hashes[lora_name] = hash_value
|
||||
|
||||
# Extract prompt and negative prompt
|
||||
@@ -174,13 +231,48 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
# Process civitaiResources array
|
||||
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
|
||||
for resource in metadata["civitaiResources"]:
|
||||
# Get unique identifier for deduplication
|
||||
# Get resource type and identifier
|
||||
resource_type = str(resource.get("type") or "").lower()
|
||||
version_id = str(resource.get("modelVersionId", ""))
|
||||
|
||||
|
||||
if resource_type == "checkpoint":
|
||||
checkpoint_entry = {
|
||||
'id': resource.get("modelVersionId", 0),
|
||||
'modelId': resource.get("modelId", 0),
|
||||
'name': resource.get("modelName", "Unknown Checkpoint"),
|
||||
'version': resource.get("modelVersionName", ""),
|
||||
'type': resource.get("type", "checkpoint"),
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': resource.get("modelName", ""),
|
||||
'hash': resource.get("hash", "") or "",
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
if version_id and metadata_provider:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_version_info(version_id)
|
||||
|
||||
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||
checkpoint_entry,
|
||||
civitai_info
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for checkpoint version {version_id}: {e}")
|
||||
|
||||
if result["model"] is None:
|
||||
result["model"] = checkpoint_entry
|
||||
|
||||
continue
|
||||
|
||||
# Skip if we've already added this LoRA
|
||||
if version_id and version_id in added_loras:
|
||||
continue
|
||||
|
||||
|
||||
# Initialize lora entry
|
||||
lora_entry = {
|
||||
'id': resource.get("modelVersionId", 0),
|
||||
@@ -196,31 +288,31 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
|
||||
# Try to get info from Civitai if modelVersionId is available
|
||||
if version_id and metadata_provider:
|
||||
try:
|
||||
# Use get_model_version_info instead of get_model_version
|
||||
civitai_info = await metadata_provider.get_model_version_info(version_id)
|
||||
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts
|
||||
)
|
||||
|
||||
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
|
||||
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for model version {version_id}: {e}")
|
||||
|
||||
|
||||
# Track this LoRA in our deduplication dict
|
||||
if version_id:
|
||||
added_loras[version_id] = len(result["loras"])
|
||||
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Process additionalResources array
|
||||
|
||||
@@ -36,9 +36,6 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
||||
# Find all LoraLoader nodes
|
||||
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
||||
|
||||
if not lora_nodes:
|
||||
return {"error": "No LoRA information found in this ComfyUI workflow", "loras": []}
|
||||
|
||||
# Process each LoraLoader node
|
||||
for node_id, node in lora_nodes.items():
|
||||
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Parser for meta format (Lora_N Model hash) metadata."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
@@ -145,14 +146,53 @@ class MetaFormatParser(RecipeMetadataParser):
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Extract model information
|
||||
model = None
|
||||
if 'model' in metadata:
|
||||
model = metadata['model']
|
||||
# Extract checkpoint information from generic Model/Model hash fields
|
||||
checkpoint = None
|
||||
model_hash = metadata.get("model_hash")
|
||||
model_name = metadata.get("model")
|
||||
|
||||
if model_hash or model_name:
|
||||
cleaned_name = None
|
||||
if model_name:
|
||||
cleaned_name = re.split(r"[\\\\/]", model_name)[-1]
|
||||
cleaned_name = os.path.splitext(cleaned_name)[0]
|
||||
|
||||
checkpoint_entry = {
|
||||
'id': 0,
|
||||
'modelId': 0,
|
||||
'name': model_name or "Unknown Checkpoint",
|
||||
'version': '',
|
||||
'type': 'checkpoint',
|
||||
'hash': model_hash or "",
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': cleaned_name or (model_name or ""),
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
if metadata_provider and model_hash:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_by_hash(model_hash)
|
||||
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||
checkpoint_entry,
|
||||
civitai_info
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for checkpoint hash {model_hash}: {e}")
|
||||
|
||||
if checkpoint_entry.get("baseModel"):
|
||||
base_model_value = checkpoint_entry["baseModel"]
|
||||
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
|
||||
|
||||
checkpoint = checkpoint_entry
|
||||
|
||||
# Set base_model to the most common one from civitai_info
|
||||
base_model = None
|
||||
if base_model_counts:
|
||||
# Set base_model to the most common one from civitai_info or checkpoint
|
||||
base_model = checkpoint["baseModel"] if checkpoint and checkpoint.get("baseModel") else None
|
||||
if not base_model and base_model_counts:
|
||||
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||
|
||||
# Extract generation parameters for recipe metadata
|
||||
@@ -170,7 +210,8 @@ class MetaFormatParser(RecipeMetadataParser):
|
||||
'loras': loras,
|
||||
'gen_params': gen_params,
|
||||
'raw_metadata': metadata,
|
||||
'from_meta_format': True
|
||||
'from_meta_format': True,
|
||||
**({'checkpoint': checkpoint, 'model': checkpoint} if checkpoint else {})
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
from ...config import config
|
||||
from ..base import RecipeMetadataParser
|
||||
from ..constants import GEN_PARAM_KEYS
|
||||
@@ -16,6 +16,28 @@ class RecipeFormatParser(RecipeMetadataParser):
|
||||
|
||||
# Regular expression pattern for extracting recipe metadata
|
||||
METADATA_MARKER = r'Recipe metadata: (\{.*\})'
|
||||
|
||||
async def _get_lora_from_version_index(self, recipe_scanner, model_version_id: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Return a cached LoRA entry by modelVersionId if available."""
|
||||
|
||||
if not recipe_scanner or not getattr(recipe_scanner, "_lora_scanner", None):
|
||||
return None
|
||||
|
||||
try:
|
||||
normalized_id = int(model_version_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
try:
|
||||
cache = await recipe_scanner._lora_scanner.get_cached_data()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug("Unable to load lora cache for version lookup: %s", exc)
|
||||
return None
|
||||
|
||||
if not cache or not getattr(cache, "version_index", None):
|
||||
return None
|
||||
|
||||
return cache.version_index.get(normalized_id)
|
||||
|
||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||
"""Check if the user comment matches the metadata format"""
|
||||
@@ -53,49 +75,110 @@ class RecipeFormatParser(RecipeMetadataParser):
|
||||
'type': 'lora',
|
||||
'weight': lora.get('strength', 1.0),
|
||||
'file_name': lora.get('file_name', ''),
|
||||
'hash': lora.get('hash', '')
|
||||
'hash': lora.get('hash', ''),
|
||||
'existsLocally': False,
|
||||
'inLibrary': False,
|
||||
'localPath': None,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'size': 0
|
||||
}
|
||||
|
||||
# Check if this LoRA exists locally by SHA256 hash
|
||||
if lora.get('hash') and recipe_scanner:
|
||||
if recipe_scanner:
|
||||
lora_scanner = recipe_scanner._lora_scanner
|
||||
exists_locally = lora_scanner.has_hash(lora['hash'])
|
||||
if exists_locally:
|
||||
lora_cache = await lora_scanner.get_cached_data()
|
||||
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
||||
if lora_item:
|
||||
|
||||
if lora.get('hash'):
|
||||
exists_locally = lora_scanner.has_hash(lora['hash'])
|
||||
if exists_locally:
|
||||
lora_cache = await lora_scanner.get_cached_data()
|
||||
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
||||
if lora_item:
|
||||
lora_entry['existsLocally'] = True
|
||||
lora_entry['inLibrary'] = True
|
||||
lora_entry['localPath'] = lora_item['file_path']
|
||||
lora_entry['file_name'] = lora_item['file_name']
|
||||
lora_entry['size'] = lora_item['size']
|
||||
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
||||
|
||||
else:
|
||||
lora_entry['existsLocally'] = False
|
||||
lora_entry['inLibrary'] = False
|
||||
lora_entry['localPath'] = None
|
||||
|
||||
# If we still don't have a local match, try matching by modelVersionId
|
||||
if not lora_entry['existsLocally'] and lora.get('modelVersionId') is not None:
|
||||
cached_lora = await self._get_lora_from_version_index(recipe_scanner, lora.get('modelVersionId'))
|
||||
if cached_lora:
|
||||
lora_entry['existsLocally'] = True
|
||||
lora_entry['localPath'] = lora_item['file_path']
|
||||
lora_entry['file_name'] = lora_item['file_name']
|
||||
lora_entry['size'] = lora_item['size']
|
||||
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
||||
|
||||
else:
|
||||
lora_entry['existsLocally'] = False
|
||||
lora_entry['localPath'] = None
|
||||
|
||||
# Try to get additional info from Civitai if we have a model version ID
|
||||
if lora.get('modelVersionId') and metadata_provider:
|
||||
try:
|
||||
civitai_info_tuple = await metadata_provider.get_model_version_info(lora['modelVersionId'])
|
||||
# Populate lora entry with Civitai info
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info_tuple,
|
||||
recipe_scanner,
|
||||
None, # No need to track base model counts
|
||||
lora['hash']
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||
lora_entry['inLibrary'] = True
|
||||
lora_entry['localPath'] = cached_lora.get('file_path')
|
||||
lora_entry['file_name'] = cached_lora.get('file_name') or lora_entry['file_name']
|
||||
lora_entry['size'] = cached_lora.get('size', lora_entry['size'])
|
||||
if cached_lora.get('sha256'):
|
||||
lora_entry['hash'] = cached_lora['sha256']
|
||||
preview_url = cached_lora.get('preview_url')
|
||||
if preview_url:
|
||||
lora_entry['thumbnailUrl'] = config.get_preview_static_url(preview_url)
|
||||
|
||||
# Try to get additional info from Civitai if we have a model version ID and still missing locally
|
||||
if not lora_entry['existsLocally'] and lora.get('modelVersionId') and metadata_provider:
|
||||
try:
|
||||
civitai_info_tuple = await metadata_provider.get_model_version_info(lora['modelVersionId'])
|
||||
# Populate lora entry with Civitai info
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info_tuple,
|
||||
recipe_scanner,
|
||||
None, # No need to track base model counts
|
||||
lora_entry.get('hash', '')
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
|
||||
logger.info(f"Found {len(loras)} loras in recipe metadata")
|
||||
|
||||
# Process checkpoint information if present
|
||||
checkpoint = None
|
||||
checkpoint_data = recipe_metadata.get('checkpoint') or {}
|
||||
if isinstance(checkpoint_data, dict) and checkpoint_data:
|
||||
version_id = checkpoint_data.get('modelVersionId') or checkpoint_data.get('id')
|
||||
checkpoint_entry = {
|
||||
'id': version_id or 0,
|
||||
'modelId': checkpoint_data.get('modelId', 0),
|
||||
'name': checkpoint_data.get('name', 'Unknown Checkpoint'),
|
||||
'version': checkpoint_data.get('version', ''),
|
||||
'type': checkpoint_data.get('type', 'checkpoint'),
|
||||
'hash': checkpoint_data.get('hash', ''),
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': checkpoint_data.get('file_name', ''),
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info = None
|
||||
if version_id:
|
||||
civitai_info = await metadata_provider.get_model_version_info(str(version_id))
|
||||
elif checkpoint_entry.get('hash'):
|
||||
civitai_info = await metadata_provider.get_model_by_hash(checkpoint_entry['hash'])
|
||||
|
||||
if civitai_info:
|
||||
checkpoint_entry = await self.populate_checkpoint_from_civitai(checkpoint_entry, civitai_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for checkpoint in recipe metadata: {e}")
|
||||
|
||||
checkpoint = checkpoint_entry
|
||||
|
||||
# Filter gen_params to only include recognized keys
|
||||
filtered_gen_params = {}
|
||||
@@ -105,12 +188,13 @@ class RecipeFormatParser(RecipeMetadataParser):
|
||||
filtered_gen_params[key] = value
|
||||
|
||||
return {
|
||||
'base_model': recipe_metadata.get('base_model', ''),
|
||||
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else recipe_metadata.get('base_model', ''),
|
||||
'loras': loras,
|
||||
'gen_params': filtered_gen_params,
|
||||
'tags': recipe_metadata.get('tags', []),
|
||||
'title': recipe_metadata.get('title', ''),
|
||||
'from_recipe_metadata': True
|
||||
'from_recipe_metadata': True,
|
||||
**({'checkpoint': checkpoint, 'model': checkpoint} if checkpoint else {})
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Dict, Mapping
|
||||
from typing import TYPE_CHECKING, Callable, Dict, Mapping
|
||||
|
||||
import jinja2
|
||||
from aiohttp import web
|
||||
@@ -17,7 +17,7 @@ from ..services.model_lifecycle_service import ModelLifecycleService
|
||||
from ..services.preview_asset_service import PreviewAssetService
|
||||
from ..services.server_i18n import server_i18n as default_server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.settings_manager import settings as default_settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.tag_update_service import TagUpdateService
|
||||
from ..services.websocket_manager import ws_manager as default_ws_manager
|
||||
from ..services.use_cases import (
|
||||
@@ -42,8 +42,12 @@ from .handlers.model_handlers import (
|
||||
ModelMoveHandler,
|
||||
ModelPageView,
|
||||
ModelQueryHandler,
|
||||
ModelUpdateHandler,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..services.model_update_service import ModelUpdateService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -56,14 +60,14 @@ class BaseModelRoutes(ABC):
|
||||
self,
|
||||
service=None,
|
||||
*,
|
||||
settings_service=default_settings,
|
||||
settings_service=None,
|
||||
ws_manager=default_ws_manager,
|
||||
server_i18n=default_server_i18n,
|
||||
metadata_provider_factory=get_default_metadata_provider,
|
||||
) -> None:
|
||||
self.service = None
|
||||
self.model_type = ""
|
||||
self._settings = settings_service
|
||||
self._settings = settings_service or get_settings_manager()
|
||||
self._ws_manager = ws_manager
|
||||
self._server_i18n = server_i18n
|
||||
self._metadata_provider_factory = metadata_provider_factory
|
||||
@@ -90,7 +94,7 @@ class BaseModelRoutes(ABC):
|
||||
self._metadata_sync_service = MetadataSyncService(
|
||||
metadata_manager=MetadataManager,
|
||||
preview_service=self._preview_service,
|
||||
settings=settings_service,
|
||||
settings=self._settings,
|
||||
default_metadata_provider_factory=metadata_provider_factory,
|
||||
metadata_provider_selector=get_metadata_provider,
|
||||
)
|
||||
@@ -99,21 +103,30 @@ class BaseModelRoutes(ABC):
|
||||
ws_manager=self._ws_manager,
|
||||
download_manager_factory=ServiceRegistry.get_download_manager,
|
||||
)
|
||||
self._model_update_service: ModelUpdateService | None = None
|
||||
|
||||
if service is not None:
|
||||
self.attach_service(service)
|
||||
|
||||
def set_model_update_service(self, service: "ModelUpdateService") -> None:
|
||||
"""Attach the model update tracking service."""
|
||||
|
||||
self._model_update_service = service
|
||||
self._handler_set = None
|
||||
self._handler_mapping = None
|
||||
|
||||
def attach_service(self, service) -> None:
|
||||
"""Attach a model service and rebuild handler dependencies."""
|
||||
self.service = service
|
||||
self.model_type = service.model_type
|
||||
self.model_file_service = ModelFileService(service.scanner, service.model_type)
|
||||
self.model_move_service = ModelMoveService(service.scanner)
|
||||
self.model_move_service = ModelMoveService(service.scanner, service.model_type)
|
||||
self.model_lifecycle_service = ModelLifecycleService(
|
||||
scanner=service.scanner,
|
||||
metadata_manager=MetadataManager,
|
||||
metadata_loader=self._metadata_sync_service.load_local_metadata,
|
||||
recipe_scanner_factory=ServiceRegistry.get_recipe_scanner,
|
||||
update_service=self._model_update_service,
|
||||
)
|
||||
self._handler_set = None
|
||||
self._handler_mapping = None
|
||||
@@ -127,6 +140,7 @@ class BaseModelRoutes(ABC):
|
||||
|
||||
def _create_handler_set(self) -> ModelHandlerSet:
|
||||
service = self._ensure_service()
|
||||
update_service = self._ensure_model_update_service()
|
||||
page_view = ModelPageView(
|
||||
template_env=self.template_env,
|
||||
template_name=self.template_name or "",
|
||||
@@ -186,6 +200,12 @@ class BaseModelRoutes(ABC):
|
||||
ws_manager=self._ws_manager,
|
||||
logger=logger,
|
||||
)
|
||||
updates = ModelUpdateHandler(
|
||||
service=service,
|
||||
update_service=update_service,
|
||||
metadata_provider_selector=get_metadata_provider,
|
||||
logger=logger,
|
||||
)
|
||||
return ModelHandlerSet(
|
||||
page_view=page_view,
|
||||
listing=listing,
|
||||
@@ -195,6 +215,7 @@ class BaseModelRoutes(ABC):
|
||||
civitai=civitai,
|
||||
move=move,
|
||||
auto_organize=auto_organize,
|
||||
updates=updates,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -249,7 +270,7 @@ class BaseModelRoutes(ABC):
|
||||
def _ensure_move_service(self) -> ModelMoveService:
|
||||
if self.model_move_service is None:
|
||||
service = self._ensure_service()
|
||||
self.model_move_service = ModelMoveService(service.scanner)
|
||||
self.model_move_service = ModelMoveService(service.scanner, service.model_type)
|
||||
return self.model_move_service
|
||||
|
||||
def _ensure_lifecycle_service(self) -> ModelLifecycleService:
|
||||
@@ -273,3 +294,7 @@ class BaseModelRoutes(ABC):
|
||||
|
||||
return proxy
|
||||
|
||||
def _ensure_model_update_service(self) -> "ModelUpdateService":
|
||||
if self._model_update_service is None:
|
||||
raise RuntimeError("Model update service has not been attached")
|
||||
return self._model_update_service
|
||||
|
||||
@@ -18,7 +18,7 @@ from ..services.recipes import (
|
||||
)
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from .handlers.recipe_handlers import (
|
||||
@@ -48,7 +48,7 @@ class BaseRecipeRoutes:
|
||||
self.recipe_scanner = None
|
||||
self.lora_scanner = None
|
||||
self.civitai_client = None
|
||||
self.settings = settings
|
||||
self.settings = get_settings_manager()
|
||||
self.server_i18n = server_i18n
|
||||
self.template_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||
@@ -79,26 +79,8 @@ class BaseRecipeRoutes:
|
||||
return
|
||||
|
||||
app.on_startup.append(self.attach_dependencies)
|
||||
app.on_startup.append(self.prewarm_cache)
|
||||
self._startup_hooks_registered = True
|
||||
|
||||
async def prewarm_cache(self, app: web.Application | None = None) -> None:
|
||||
"""Pre-load recipe and LoRA caches on startup."""
|
||||
|
||||
try:
|
||||
await self.attach_dependencies(app)
|
||||
|
||||
if self.lora_scanner is not None:
|
||||
await self.lora_scanner.get_cached_data()
|
||||
hash_index = getattr(self.lora_scanner, "_hash_index", None)
|
||||
if hash_index is not None and hasattr(hash_index, "_hash_to_path"):
|
||||
_ = len(hash_index._hash_to_path)
|
||||
|
||||
if self.recipe_scanner is not None:
|
||||
await self.recipe_scanner.get_cached_data(force_refresh=True)
|
||||
except Exception as exc:
|
||||
logger.error("Error pre-warming recipe cache: %s", exc, exc_info=True)
|
||||
|
||||
def to_route_mapping(self) -> Mapping[str, Callable]:
|
||||
"""Return a mapping of handler name to coroutine for registrar binding."""
|
||||
|
||||
@@ -191,6 +173,8 @@ class BaseRecipeRoutes:
|
||||
logger=logger,
|
||||
persistence_service=persistence_service,
|
||||
analysis_service=analysis_service,
|
||||
downloader_factory=get_downloader,
|
||||
civitai_client_getter=civitai_client_getter,
|
||||
)
|
||||
analysis = RecipeAnalysisHandler(
|
||||
ensure_dependencies_ready=self.ensure_dependencies_ready,
|
||||
@@ -214,4 +198,3 @@ class BaseRecipeRoutes:
|
||||
analysis=analysis,
|
||||
sharing=sharing,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
from aiohttp import web
|
||||
|
||||
from .base_model_routes import BaseModelRoutes
|
||||
@@ -20,8 +21,10 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
async def initialize_services(self):
|
||||
"""Initialize services from ServiceRegistry"""
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
self.service = CheckpointService(checkpoint_scanner)
|
||||
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
self.service = CheckpointService(checkpoint_scanner, update_service=update_service)
|
||||
self.set_model_update_service(update_service)
|
||||
|
||||
# Attach service dependencies
|
||||
self.attach_service(self.service)
|
||||
|
||||
@@ -49,6 +52,19 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
def _get_expected_model_types(self) -> str:
|
||||
"""Get expected model types string for error messages"""
|
||||
return "Checkpoint"
|
||||
|
||||
def _parse_specific_params(self, request: web.Request) -> Dict:
|
||||
"""Parse Checkpoint-specific parameters"""
|
||||
params: Dict = {}
|
||||
|
||||
if 'checkpoint_hash' in request.query:
|
||||
params['hash_filters'] = {'single_hash': request.query['checkpoint_hash'].lower()}
|
||||
elif 'checkpoint_hashes' in request.query:
|
||||
params['hash_filters'] = {
|
||||
'multiple_hashes': [h.lower() for h in request.query['checkpoint_hashes'].split(',')]
|
||||
}
|
||||
|
||||
return params
|
||||
|
||||
async def get_checkpoint_info(self, request: web.Request) -> web.Response:
|
||||
"""Get detailed information for a specific checkpoint by name"""
|
||||
@@ -93,4 +109,4 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
}, status=500)
|
||||
|
||||
@@ -19,8 +19,10 @@ class EmbeddingRoutes(BaseModelRoutes):
|
||||
async def initialize_services(self):
|
||||
"""Initialize services from ServiceRegistry"""
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
self.service = EmbeddingService(embedding_scanner)
|
||||
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
self.service = EmbeddingService(embedding_scanner, update_service=update_service)
|
||||
self.set_model_update_service(update_service)
|
||||
|
||||
# Attach service dependencies
|
||||
self.attach_service(self.service)
|
||||
|
||||
|
||||
@@ -22,12 +22,14 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/example-images-status", "get_example_images_status"),
|
||||
RouteDefinition("POST", "/api/lm/pause-example-images", "pause_example_images"),
|
||||
RouteDefinition("POST", "/api/lm/resume-example-images", "resume_example_images"),
|
||||
RouteDefinition("POST", "/api/lm/stop-example-images", "stop_example_images"),
|
||||
RouteDefinition("POST", "/api/lm/open-example-images-folder", "open_example_images_folder"),
|
||||
RouteDefinition("GET", "/api/lm/example-image-files", "get_example_image_files"),
|
||||
RouteDefinition("GET", "/api/lm/has-example-images", "has_example_images"),
|
||||
RouteDefinition("POST", "/api/lm/delete-example-image", "delete_example_image"),
|
||||
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
|
||||
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
|
||||
RouteDefinition("POST", "/api/lm/example-images/set-nsfw-level", "set_example_image_nsfw_level"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +68,13 @@ class ExampleImagesDownloadHandler:
|
||||
except DownloadNotRunningError as exc:
|
||||
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||
|
||||
async def stop_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||
try:
|
||||
result = await self._download_manager.stop_download(request)
|
||||
return web.json_response(result)
|
||||
except DownloadNotRunningError as exc:
|
||||
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||
|
||||
async def force_download_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||
try:
|
||||
payload = await request.json()
|
||||
@@ -106,6 +113,9 @@ class ExampleImagesManagementHandler:
|
||||
async def delete_example_image(self, request: web.Request) -> web.StreamResponse:
|
||||
return await self._processor.delete_custom_image(request)
|
||||
|
||||
async def set_example_image_nsfw_level(self, request: web.Request) -> web.StreamResponse:
|
||||
return await self._processor.set_example_image_nsfw_level(request)
|
||||
|
||||
async def cleanup_example_image_folders(self, request: web.Request) -> web.StreamResponse:
|
||||
result = await self._cleanup_service.cleanup_example_image_folders()
|
||||
|
||||
@@ -149,9 +159,11 @@ class ExampleImagesHandlerSet:
|
||||
"get_example_images_status": self.download.get_example_images_status,
|
||||
"pause_example_images": self.download.pause_example_images,
|
||||
"resume_example_images": self.download.resume_example_images,
|
||||
"stop_example_images": self.download.stop_example_images,
|
||||
"force_download_example_images": self.download.force_download_example_images,
|
||||
"import_example_images": self.management.import_example_images,
|
||||
"delete_example_image": self.management.delete_example_image,
|
||||
"set_example_image_nsfw_level": self.management.set_example_image_nsfw_level,
|
||||
"cleanup_example_image_folders": self.management.cleanup_example_image_folders,
|
||||
"open_example_images_folder": self.files.open_example_images_folder,
|
||||
"get_example_image_files": self.files.get_example_image_files,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,6 @@ class PreviewHandler:
|
||||
|
||||
resolved_str = str(resolved)
|
||||
if not self._config.is_preview_path_allowed(resolved_str):
|
||||
logger.debug("Rejected preview outside allowed roots: %s", resolved_str)
|
||||
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
|
||||
|
||||
if not resolved.is_file():
|
||||
|
||||
@@ -4,8 +4,11 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -20,6 +23,12 @@ from ...services.recipes import (
|
||||
RecipeSharingService,
|
||||
RecipeValidationError,
|
||||
)
|
||||
from ...services.metadata_service import get_default_metadata_provider
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.exif_utils import ExifUtils
|
||||
from ...recipes.merger import GenParamsMerger
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
from ...services.websocket_manager import ws_manager as default_ws_manager
|
||||
|
||||
Logger = logging.Logger
|
||||
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||
@@ -45,22 +54,33 @@ class RecipeHandlerSet:
|
||||
"render_page": self.page_view.render_page,
|
||||
"list_recipes": self.listing.list_recipes,
|
||||
"get_recipe": self.listing.get_recipe,
|
||||
"import_remote_recipe": self.management.import_remote_recipe,
|
||||
"analyze_uploaded_image": self.analysis.analyze_uploaded_image,
|
||||
"analyze_local_image": self.analysis.analyze_local_image,
|
||||
"save_recipe": self.management.save_recipe,
|
||||
"delete_recipe": self.management.delete_recipe,
|
||||
"get_top_tags": self.query.get_top_tags,
|
||||
"get_base_models": self.query.get_base_models,
|
||||
"get_roots": self.query.get_roots,
|
||||
"get_folders": self.query.get_folders,
|
||||
"get_folder_tree": self.query.get_folder_tree,
|
||||
"get_unified_folder_tree": self.query.get_unified_folder_tree,
|
||||
"share_recipe": self.sharing.share_recipe,
|
||||
"download_shared_recipe": self.sharing.download_shared_recipe,
|
||||
"get_recipe_syntax": self.query.get_recipe_syntax,
|
||||
"update_recipe": self.management.update_recipe,
|
||||
"reconnect_lora": self.management.reconnect_lora,
|
||||
"find_duplicates": self.query.find_duplicates,
|
||||
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||
"bulk_delete": self.management.bulk_delete,
|
||||
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||
"scan_recipes": self.query.scan_recipes,
|
||||
"move_recipe": self.management.move_recipe,
|
||||
"repair_recipes": self.management.repair_recipes,
|
||||
"cancel_repair": self.management.cancel_repair,
|
||||
"repair_recipe": self.management.repair_recipe,
|
||||
"get_repair_progress": self.management.get_repair_progress,
|
||||
}
|
||||
|
||||
|
||||
@@ -144,22 +164,45 @@ class RecipeListingHandler:
|
||||
page_size = int(request.query.get("page_size", "20"))
|
||||
sort_by = request.query.get("sort_by", "date")
|
||||
search = request.query.get("search")
|
||||
folder = request.query.get("folder")
|
||||
recursive = request.query.get("recursive", "true").lower() == "true"
|
||||
|
||||
search_options = {
|
||||
"title": request.query.get("search_title", "true").lower() == "true",
|
||||
"tags": request.query.get("search_tags", "true").lower() == "true",
|
||||
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
|
||||
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
||||
"prompt": request.query.get("search_prompt", "true").lower() == "true",
|
||||
}
|
||||
|
||||
filters: Dict[str, list[str]] = {}
|
||||
filters: Dict[str, Any] = {}
|
||||
base_models = request.query.get("base_models")
|
||||
if base_models:
|
||||
filters["base_model"] = base_models.split(",")
|
||||
|
||||
tags = request.query.get("tags")
|
||||
if tags:
|
||||
filters["tags"] = tags.split(",")
|
||||
if request.query.get("favorite", "false").lower() == "true":
|
||||
filters["favorite"] = True
|
||||
|
||||
tag_filters: Dict[str, str] = {}
|
||||
legacy_tags = request.query.get("tags")
|
||||
if legacy_tags:
|
||||
for tag in legacy_tags.split(","):
|
||||
tag = tag.strip()
|
||||
if tag:
|
||||
tag_filters[tag] = "include"
|
||||
|
||||
include_tags = request.query.getall("tag_include", [])
|
||||
for tag in include_tags:
|
||||
if tag:
|
||||
tag_filters[tag] = "include"
|
||||
|
||||
exclude_tags = request.query.getall("tag_exclude", [])
|
||||
for tag in exclude_tags:
|
||||
if tag:
|
||||
tag_filters[tag] = "exclude"
|
||||
|
||||
if tag_filters:
|
||||
filters["tags"] = tag_filters
|
||||
|
||||
lora_hash = request.query.get("lora_hash")
|
||||
|
||||
@@ -171,6 +214,8 @@ class RecipeListingHandler:
|
||||
filters=filters,
|
||||
search_options=search_options,
|
||||
lora_hash=lora_hash,
|
||||
folder=folder,
|
||||
recursive=recursive,
|
||||
)
|
||||
|
||||
for item in result.get("items", []):
|
||||
@@ -277,6 +322,58 @@ class RecipeQueryHandler:
|
||||
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_roots(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
roots = [recipe_scanner.recipes_dir] if recipe_scanner.recipes_dir else []
|
||||
return web.json_response({"success": True, "roots": roots})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving recipe roots: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_folders(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
folders = await recipe_scanner.get_folders()
|
||||
return web.json_response({"success": True, "folders": folders})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_folder_tree(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
folder_tree = await recipe_scanner.get_folder_tree()
|
||||
return web.json_response({"success": True, "tree": folder_tree})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
folder_tree = await recipe_scanner.get_folder_tree()
|
||||
return web.json_response({"success": True, "tree": folder_tree})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -387,12 +484,18 @@ class RecipeManagementHandler:
|
||||
logger: Logger,
|
||||
persistence_service: RecipePersistenceService,
|
||||
analysis_service: RecipeAnalysisService,
|
||||
downloader_factory,
|
||||
civitai_client_getter: CivitaiClientGetter,
|
||||
ws_manager=default_ws_manager,
|
||||
) -> None:
|
||||
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||
self._recipe_scanner_getter = recipe_scanner_getter
|
||||
self._logger = logger
|
||||
self._persistence_service = persistence_service
|
||||
self._analysis_service = analysis_service
|
||||
self._downloader_factory = downloader_factory
|
||||
self._civitai_client_getter = civitai_client_getter
|
||||
self._ws_manager = ws_manager
|
||||
|
||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
@@ -411,6 +514,7 @@ class RecipeManagementHandler:
|
||||
name=payload["name"],
|
||||
tags=payload["tags"],
|
||||
metadata=payload["metadata"],
|
||||
extension=payload.get("extension"),
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
@@ -419,6 +523,215 @@ class RecipeManagementHandler:
|
||||
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def repair_recipes(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||
|
||||
# Check if already running
|
||||
if self._ws_manager.is_recipe_repair_running():
|
||||
return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409)
|
||||
|
||||
recipe_scanner.reset_cancellation()
|
||||
|
||||
async def progress_callback(data):
|
||||
await self._ws_manager.broadcast_recipe_repair_progress(data)
|
||||
|
||||
# Run in background to avoid timeout
|
||||
async def run_repair():
|
||||
try:
|
||||
await recipe_scanner.repair_all_recipes(
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error in recipe repair task: {e}", exc_info=True)
|
||||
await self._ws_manager.broadcast_recipe_repair_progress({
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
})
|
||||
finally:
|
||||
# Keep the final status for a while so the UI can see it
|
||||
await asyncio.sleep(5)
|
||||
# Don't cleanup if it was cancelled, let the UI see the cancelled state for a bit?
|
||||
# Actually cleanup_recipe_repair_progress is fine as long as we waited enough.
|
||||
self._ws_manager.cleanup_recipe_repair_progress()
|
||||
|
||||
asyncio.create_task(run_repair())
|
||||
|
||||
return web.json_response({"success": True, "message": "Recipe repair started"})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def cancel_repair(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||
|
||||
recipe_scanner.cancel_task()
|
||||
return web.json_response({"success": True, "message": "Cancellation requested"})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def repair_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||
|
||||
recipe_id = request.match_info["recipe_id"]
|
||||
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||
return web.json_response(result)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_repair_progress(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
progress = self._ws_manager.get_recipe_repair_progress()
|
||||
if progress:
|
||||
return web.json_response({"success": True, "progress": progress})
|
||||
return web.json_response({"success": False, "message": "No repair in progress"}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
# 1. Parse Parameters
|
||||
params = request.rel_url.query
|
||||
image_url = params.get("image_url")
|
||||
name = params.get("name")
|
||||
resources_raw = params.get("resources")
|
||||
|
||||
if not image_url:
|
||||
raise RecipeValidationError("Missing required field: image_url")
|
||||
if not name:
|
||||
raise RecipeValidationError("Missing required field: name")
|
||||
if not resources_raw:
|
||||
raise RecipeValidationError("Missing required field: resources")
|
||||
|
||||
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
|
||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||
|
||||
# 2. Initial Metadata Construction
|
||||
metadata: Dict[str, Any] = {
|
||||
"base_model": params.get("base_model", "") or "",
|
||||
"loras": lora_entries,
|
||||
"gen_params": gen_params_request or {},
|
||||
"source_url": image_url
|
||||
}
|
||||
|
||||
source_path = params.get("source_path")
|
||||
if source_path:
|
||||
metadata["source_path"] = source_path
|
||||
|
||||
# Checkpoint handling
|
||||
if checkpoint_entry:
|
||||
metadata["checkpoint"] = checkpoint_entry
|
||||
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
|
||||
# Actually enricher looks at metadata['checkpoint'], so this is fine.
|
||||
|
||||
# Try to resolve base model from checkpoint if not explicitly provided
|
||||
if not metadata["base_model"]:
|
||||
base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||
if base_model_from_metadata:
|
||||
metadata["base_model"] = base_model_from_metadata
|
||||
|
||||
tags = self._parse_tags(params.get("tags"))
|
||||
|
||||
# 3. Download Image
|
||||
image_bytes, extension, civitai_meta_from_download = await self._download_remote_media(image_url)
|
||||
|
||||
# 4. Extract Embedded Metadata
|
||||
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
||||
# with embedded data if we want it to merge it.
|
||||
# However, logic in Enricher merges: request > civitai > embedded.
|
||||
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
||||
# OR pass them to enricher to handle?
|
||||
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
|
||||
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
|
||||
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
|
||||
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
|
||||
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
||||
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
|
||||
|
||||
# Let's extract embedded metadata first
|
||||
embedded_gen_params = {}
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img:
|
||||
temp_img.write(image_bytes)
|
||||
temp_img_path = temp_img.name
|
||||
|
||||
try:
|
||||
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
||||
if raw_embedded:
|
||||
parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded)
|
||||
if parser:
|
||||
parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner)
|
||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||
embedded_gen_params = parsed_embedded["gen_params"]
|
||||
else:
|
||||
embedded_gen_params = {"raw_metadata": raw_embedded}
|
||||
finally:
|
||||
if os.path.exists(temp_img_path):
|
||||
os.unlink(temp_img_path)
|
||||
except Exception as exc:
|
||||
self._logger.warning("Failed to extract embedded metadata during import: %s", exc)
|
||||
|
||||
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
|
||||
if embedded_gen_params:
|
||||
# Merge embedded into existing gen_params (which currently only has request params if any)
|
||||
# But wait, we want request params to override everything.
|
||||
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
|
||||
metadata["gen_params"] = embedded_gen_params
|
||||
|
||||
# 5. Enrich with unified logic
|
||||
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
|
||||
civitai_client = self._civitai_client_getter()
|
||||
await RecipeEnricher.enrich_recipe(
|
||||
recipe=metadata,
|
||||
civitai_client=civitai_client,
|
||||
request_params=gen_params_request # Pass explicit request params here to override
|
||||
)
|
||||
|
||||
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
|
||||
# we might want to manually merge it?
|
||||
# But usually `import_remote_recipe` is used with Civitai URLs.
|
||||
# For now, relying on Enricher's internal fetch is consistent with repair.
|
||||
|
||||
result = await self._persistence_service.save_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_bytes=image_bytes,
|
||||
image_base64=None,
|
||||
name=name,
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
extension=extension,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeDownloadError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error importing recipe from remote source: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -458,6 +771,64 @@ class RecipeManagementHandler:
|
||||
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def move_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
recipe_id = data.get("recipe_id")
|
||||
target_path = data.get("target_path")
|
||||
if not recipe_id or not target_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "recipe_id and target_path are required"}, status=400
|
||||
)
|
||||
|
||||
result = await self._persistence_service.move_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=str(recipe_id),
|
||||
target_path=str(target_path),
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error moving recipe: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def move_recipes_bulk(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
recipe_ids = data.get("recipe_ids") or []
|
||||
target_path = data.get("target_path")
|
||||
if not recipe_ids or not target_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "recipe_ids and target_path are required"}, status=400
|
||||
)
|
||||
|
||||
result = await self._persistence_service.move_recipes_bulk(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_ids=recipe_ids,
|
||||
target_path=str(target_path),
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error moving recipes in bulk: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -539,6 +910,7 @@ class RecipeManagementHandler:
|
||||
name: Optional[str] = None
|
||||
tags: list[str] = []
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
extension: Optional[str] = None
|
||||
|
||||
while True:
|
||||
field = await reader.next()
|
||||
@@ -569,6 +941,8 @@ class RecipeManagementHandler:
|
||||
metadata = json.loads(metadata_text)
|
||||
except Exception:
|
||||
metadata = {}
|
||||
elif field.name == "extension":
|
||||
extension = await field.text()
|
||||
|
||||
return {
|
||||
"image_bytes": image_bytes,
|
||||
@@ -576,8 +950,160 @@ class RecipeManagementHandler:
|
||||
"name": name,
|
||||
"tags": tags,
|
||||
"metadata": metadata,
|
||||
"extension": extension,
|
||||
}
|
||||
|
||||
def _parse_tags(self, tag_text: Optional[str]) -> list[str]:
|
||||
if not tag_text:
|
||||
return []
|
||||
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
|
||||
|
||||
def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if payload is None:
|
||||
return None
|
||||
if payload == "":
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(payload)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RecipeValidationError(f"Invalid gen_params payload: {exc}") from exc
|
||||
if parsed is None:
|
||||
return {}
|
||||
if not isinstance(parsed, dict):
|
||||
raise RecipeValidationError("gen_params payload must be an object")
|
||||
return parsed
|
||||
|
||||
def _parse_resources_payload(self, payload_raw: str) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
try:
|
||||
payload = json.loads(payload_raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RecipeValidationError(f"Invalid resources payload: {exc}") from exc
|
||||
|
||||
if not isinstance(payload, list):
|
||||
raise RecipeValidationError("Resources payload must be a list")
|
||||
|
||||
checkpoint_entry: Optional[Dict[str, Any]] = None
|
||||
lora_entries: List[Dict[str, Any]] = []
|
||||
|
||||
for resource in payload:
|
||||
if not isinstance(resource, dict):
|
||||
continue
|
||||
resource_type = str(resource.get("type") or "").lower()
|
||||
if resource_type == "checkpoint":
|
||||
checkpoint_entry = self._build_checkpoint_entry(resource)
|
||||
elif resource_type in {"lora", "lycoris"}:
|
||||
lora_entries.append(self._build_lora_entry(resource))
|
||||
|
||||
return checkpoint_entry, lora_entries
|
||||
|
||||
def _build_checkpoint_entry(self, resource: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": resource.get("type", "checkpoint"),
|
||||
"modelId": self._safe_int(resource.get("modelId")),
|
||||
"modelVersionId": self._safe_int(resource.get("modelVersionId")),
|
||||
"modelName": resource.get("modelName", ""),
|
||||
"modelVersionName": resource.get("modelVersionName", ""),
|
||||
}
|
||||
|
||||
def _build_lora_entry(self, resource: Dict[str, Any]) -> Dict[str, Any]:
|
||||
weight_raw = resource.get("weight", 1.0)
|
||||
try:
|
||||
weight = float(weight_raw)
|
||||
except (TypeError, ValueError):
|
||||
weight = 1.0
|
||||
return {
|
||||
"file_name": resource.get("modelName", ""),
|
||||
"weight": weight,
|
||||
"id": self._safe_int(resource.get("modelVersionId")),
|
||||
"name": resource.get("modelName", ""),
|
||||
"version": resource.get("modelVersionName", ""),
|
||||
"isDeleted": False,
|
||||
"exclude": False,
|
||||
}
|
||||
|
||||
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str]:
|
||||
civitai_client = self._civitai_client_getter()
|
||||
downloader = await self._downloader_factory()
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
download_url = image_url
|
||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
|
||||
if civitai_match:
|
||||
if civitai_client is None:
|
||||
raise RecipeDownloadError("Civitai client unavailable for image download")
|
||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||
if not image_info:
|
||||
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
||||
|
||||
media_url = image_info.get("url")
|
||||
if not media_url:
|
||||
raise RecipeDownloadError("No image URL found in Civitai response")
|
||||
|
||||
# Use optimized preview URLs if possible
|
||||
media_type = image_info.get("type")
|
||||
rewritten_url, _ = rewrite_preview_url(media_url, media_type=media_type)
|
||||
if rewritten_url:
|
||||
download_url = rewritten_url
|
||||
else:
|
||||
download_url = media_url
|
||||
|
||||
success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
|
||||
if not success:
|
||||
raise RecipeDownloadError(f"Failed to download image: {result}")
|
||||
|
||||
# Extract extension from URL
|
||||
url_path = download_url.split('?')[0].split('#')[0]
|
||||
extension = os.path.splitext(url_path)[1].lower()
|
||||
if not extension:
|
||||
extension = ".webp" # Default to webp if unknown
|
||||
|
||||
with open(temp_path, "rb") as file_obj:
|
||||
return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
|
||||
except RecipeDownloadError:
|
||||
raise
|
||||
except RecipeValidationError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
raise RecipeValidationError(f"Unable to download image: {exc}") from exc
|
||||
finally:
|
||||
if temp_path:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _safe_int(self, value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
async def _resolve_base_model_from_checkpoint(self, checkpoint_entry: Dict[str, Any]) -> str:
|
||||
version_id = self._safe_int(checkpoint_entry.get("modelVersionId"))
|
||||
|
||||
if not version_id:
|
||||
return ""
|
||||
|
||||
try:
|
||||
provider = await get_default_metadata_provider()
|
||||
if not provider:
|
||||
return ""
|
||||
|
||||
version_info = await provider.get_model_version_info(version_id)
|
||||
if isinstance(version_info, tuple):
|
||||
version_info = version_info[0]
|
||||
|
||||
if isinstance(version_info, dict):
|
||||
base_model = version_info.get("baseModel") or ""
|
||||
return str(base_model) if base_model is not None else ""
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
self._logger.warning("Failed to resolve base model from checkpoint metadata: %s", exc)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
class RecipeAnalysisHandler:
|
||||
"""Analyze images to extract recipe metadata."""
|
||||
|
||||
@@ -12,234 +12,344 @@ from ..utils.utils import get_lora_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoraRoutes(BaseModelRoutes):
|
||||
"""LoRA-specific route controller"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize LoRA routes with LoRA service"""
|
||||
super().__init__()
|
||||
self.template_name = "loras.html"
|
||||
|
||||
|
||||
async def initialize_services(self):
|
||||
"""Initialize services from ServiceRegistry"""
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
self.service = LoraService(lora_scanner)
|
||||
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
self.service = LoraService(lora_scanner, update_service=update_service)
|
||||
self.set_model_update_service(update_service)
|
||||
|
||||
# Attach service dependencies
|
||||
self.attach_service(self.service)
|
||||
|
||||
|
||||
def setup_routes(self, app: web.Application):
|
||||
"""Setup LoRA routes"""
|
||||
# Schedule service initialization on app startup
|
||||
app.on_startup.append(lambda _: self.initialize_services())
|
||||
|
||||
# Setup common routes with 'loras' prefix (includes page route)
|
||||
super().setup_routes(app, 'loras')
|
||||
super().setup_routes(app, "loras")
|
||||
|
||||
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||
"""Setup LoRA-specific routes"""
|
||||
# LoRA-specific query routes
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/letter-counts', prefix, self.get_letter_counts)
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/get-trigger-words', prefix, self.get_lora_trigger_words)
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/usage-tips-by-path', prefix, self.get_lora_usage_tips_by_path)
|
||||
registrar.add_prefixed_route(
|
||||
"GET", "/api/lm/{prefix}/letter-counts", prefix, self.get_letter_counts
|
||||
)
|
||||
registrar.add_prefixed_route(
|
||||
"GET",
|
||||
"/api/lm/{prefix}/get-trigger-words",
|
||||
prefix,
|
||||
self.get_lora_trigger_words,
|
||||
)
|
||||
registrar.add_prefixed_route(
|
||||
"GET",
|
||||
"/api/lm/{prefix}/usage-tips-by-path",
|
||||
prefix,
|
||||
self.get_lora_usage_tips_by_path,
|
||||
)
|
||||
|
||||
# Randomizer routes
|
||||
registrar.add_prefixed_route(
|
||||
"POST", "/api/lm/{prefix}/random-sample", prefix, self.get_random_loras
|
||||
)
|
||||
|
||||
# Cycler routes
|
||||
registrar.add_prefixed_route(
|
||||
"POST", "/api/lm/{prefix}/cycler-list", prefix, self.get_cycler_list
|
||||
)
|
||||
|
||||
# ComfyUI integration
|
||||
registrar.add_prefixed_route('POST', '/api/lm/{prefix}/get_trigger_words', prefix, self.get_trigger_words)
|
||||
|
||||
registrar.add_prefixed_route(
|
||||
"POST", "/api/lm/{prefix}/get_trigger_words", prefix, self.get_trigger_words
|
||||
)
|
||||
|
||||
def _parse_specific_params(self, request: web.Request) -> Dict:
|
||||
"""Parse LoRA-specific parameters"""
|
||||
params = {}
|
||||
|
||||
|
||||
# LoRA-specific parameters
|
||||
if 'first_letter' in request.query:
|
||||
params['first_letter'] = request.query.get('first_letter')
|
||||
|
||||
if "first_letter" in request.query:
|
||||
params["first_letter"] = request.query.get("first_letter")
|
||||
|
||||
# Handle fuzzy search parameter name variation
|
||||
if request.query.get('fuzzy') == 'true':
|
||||
params['fuzzy_search'] = True
|
||||
|
||||
if request.query.get("fuzzy") == "true":
|
||||
params["fuzzy_search"] = True
|
||||
|
||||
# Handle additional filter parameters for LoRAs
|
||||
if 'lora_hash' in request.query:
|
||||
if not params.get('hash_filters'):
|
||||
params['hash_filters'] = {}
|
||||
params['hash_filters']['single_hash'] = request.query['lora_hash'].lower()
|
||||
elif 'lora_hashes' in request.query:
|
||||
if not params.get('hash_filters'):
|
||||
params['hash_filters'] = {}
|
||||
params['hash_filters']['multiple_hashes'] = [h.lower() for h in request.query['lora_hashes'].split(',')]
|
||||
|
||||
if "lora_hash" in request.query:
|
||||
if not params.get("hash_filters"):
|
||||
params["hash_filters"] = {}
|
||||
params["hash_filters"]["single_hash"] = request.query["lora_hash"].lower()
|
||||
elif "lora_hashes" in request.query:
|
||||
if not params.get("hash_filters"):
|
||||
params["hash_filters"] = {}
|
||||
params["hash_filters"]["multiple_hashes"] = [
|
||||
h.lower() for h in request.query["lora_hashes"].split(",")
|
||||
]
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||
"""Validate CivitAI model type for LoRA"""
|
||||
from ..utils.constants import VALID_LORA_TYPES
|
||||
|
||||
return model_type.lower() in VALID_LORA_TYPES
|
||||
|
||||
|
||||
def _get_expected_model_types(self) -> str:
|
||||
"""Get expected model types string for error messages"""
|
||||
return "LORA, LoCon, or DORA"
|
||||
|
||||
|
||||
# LoRA-specific route handlers
|
||||
async def get_letter_counts(self, request: web.Request) -> web.Response:
|
||||
"""Get count of LoRAs for each letter of the alphabet"""
|
||||
try:
|
||||
letter_counts = await self.service.get_letter_counts()
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'letter_counts': letter_counts
|
||||
})
|
||||
return web.json_response({"success": True, "letter_counts": letter_counts})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting letter counts: {e}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def get_lora_notes(self, request: web.Request) -> web.Response:
|
||||
"""Get notes for a specific LoRA file"""
|
||||
try:
|
||||
lora_name = request.query.get('name')
|
||||
lora_name = request.query.get("name")
|
||||
if not lora_name:
|
||||
return web.Response(text='Lora file name is required', status=400)
|
||||
|
||||
return web.Response(text="Lora file name is required", status=400)
|
||||
|
||||
notes = await self.service.get_lora_notes(lora_name)
|
||||
if notes is not None:
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'notes': notes
|
||||
})
|
||||
return web.json_response({"success": True, "notes": notes})
|
||||
else:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'LoRA not found in cache'
|
||||
}, status=404)
|
||||
|
||||
return web.json_response(
|
||||
{"success": False, "error": "LoRA not found in cache"}, status=404
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lora notes: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def get_lora_trigger_words(self, request: web.Request) -> web.Response:
|
||||
"""Get trigger words for a specific LoRA file"""
|
||||
try:
|
||||
lora_name = request.query.get('name')
|
||||
lora_name = request.query.get("name")
|
||||
if not lora_name:
|
||||
return web.Response(text='Lora file name is required', status=400)
|
||||
|
||||
return web.Response(text="Lora file name is required", status=400)
|
||||
|
||||
trigger_words = await self.service.get_lora_trigger_words(lora_name)
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'trigger_words': trigger_words
|
||||
})
|
||||
|
||||
return web.json_response({"success": True, "trigger_words": trigger_words})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lora trigger words: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def get_lora_usage_tips_by_path(self, request: web.Request) -> web.Response:
|
||||
"""Get usage tips for a LoRA by its relative path"""
|
||||
try:
|
||||
relative_path = request.query.get('relative_path')
|
||||
relative_path = request.query.get("relative_path")
|
||||
if not relative_path:
|
||||
return web.Response(text='Relative path is required', status=400)
|
||||
|
||||
usage_tips = await self.service.get_lora_usage_tips_by_relative_path(relative_path)
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'usage_tips': usage_tips or ''
|
||||
})
|
||||
|
||||
return web.Response(text="Relative path is required", status=400)
|
||||
|
||||
usage_tips = await self.service.get_lora_usage_tips_by_relative_path(
|
||||
relative_path
|
||||
)
|
||||
return web.json_response({"success": True, "usage_tips": usage_tips or ""})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def get_lora_preview_url(self, request: web.Request) -> web.Response:
|
||||
"""Get the static preview URL for a LoRA file"""
|
||||
try:
|
||||
lora_name = request.query.get('name')
|
||||
lora_name = request.query.get("name")
|
||||
if not lora_name:
|
||||
return web.Response(text='Lora file name is required', status=400)
|
||||
|
||||
return web.Response(text="Lora file name is required", status=400)
|
||||
|
||||
preview_url = await self.service.get_lora_preview_url(lora_name)
|
||||
if preview_url:
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'preview_url': preview_url
|
||||
})
|
||||
return web.json_response({"success": True, "preview_url": preview_url})
|
||||
else:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'No preview URL found for the specified lora'
|
||||
}, status=404)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "No preview URL found for the specified lora",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def get_lora_civitai_url(self, request: web.Request) -> web.Response:
|
||||
"""Get the Civitai URL for a LoRA file"""
|
||||
try:
|
||||
lora_name = request.query.get('name')
|
||||
lora_name = request.query.get("name")
|
||||
if not lora_name:
|
||||
return web.Response(text='Lora file name is required', status=400)
|
||||
|
||||
return web.Response(text="Lora file name is required", status=400)
|
||||
|
||||
result = await self.service.get_lora_civitai_url(lora_name)
|
||||
if result['civitai_url']:
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
**result
|
||||
})
|
||||
if result["civitai_url"]:
|
||||
return web.json_response({"success": True, **result})
|
||||
else:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'No Civitai data found for the specified lora'
|
||||
}, status=404)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "No Civitai data found for the specified lora",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def get_random_loras(self, request: web.Request) -> web.Response:
|
||||
"""Get random LoRAs based on filters and strength ranges"""
|
||||
try:
|
||||
json_data = await request.json()
|
||||
|
||||
# Parse parameters
|
||||
count = json_data.get("count", 5)
|
||||
count_min = json_data.get("count_min")
|
||||
count_max = json_data.get("count_max")
|
||||
model_strength_min = float(json_data.get("model_strength_min", 0.0))
|
||||
model_strength_max = float(json_data.get("model_strength_max", 1.0))
|
||||
use_same_clip_strength = json_data.get("use_same_clip_strength", True)
|
||||
clip_strength_min = float(json_data.get("clip_strength_min", 0.0))
|
||||
clip_strength_max = float(json_data.get("clip_strength_max", 1.0))
|
||||
locked_loras = json_data.get("locked_loras", [])
|
||||
pool_config = json_data.get("pool_config")
|
||||
use_recommended_strength = json_data.get("use_recommended_strength", False)
|
||||
recommended_strength_scale_min = float(
|
||||
json_data.get("recommended_strength_scale_min", 0.5)
|
||||
)
|
||||
recommended_strength_scale_max = float(
|
||||
json_data.get("recommended_strength_scale_max", 1.0)
|
||||
)
|
||||
|
||||
# Determine target count
|
||||
if count_min is not None and count_max is not None:
|
||||
import random
|
||||
|
||||
target_count = random.randint(count_min, count_max)
|
||||
else:
|
||||
target_count = count
|
||||
|
||||
# Validate parameters
|
||||
if target_count < 1 or target_count > 100:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Count must be between 1 and 100"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if model_strength_min < -10 or model_strength_max > 10:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Model strength must be between -10 and 10",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Get random LoRAs from service
|
||||
result_loras = await self.service.get_random_loras(
|
||||
count=target_count,
|
||||
model_strength_min=model_strength_min,
|
||||
model_strength_max=model_strength_max,
|
||||
use_same_clip_strength=use_same_clip_strength,
|
||||
clip_strength_min=clip_strength_min,
|
||||
clip_strength_max=clip_strength_max,
|
||||
locked_loras=locked_loras,
|
||||
pool_config=pool_config,
|
||||
use_recommended_strength=use_recommended_strength,
|
||||
recommended_strength_scale_min=recommended_strength_scale_min,
|
||||
recommended_strength_scale_max=recommended_strength_scale_max,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "loras": result_loras, "count": len(result_loras)}
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid parameter for random LoRAs: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting random LoRAs: {e}", exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def get_cycler_list(self, request: web.Request) -> web.Response:
|
||||
"""Get filtered and sorted LoRA list for cycler widget"""
|
||||
try:
|
||||
json_data = await request.json()
|
||||
|
||||
# Parse parameters
|
||||
pool_config = json_data.get("pool_config")
|
||||
sort_by = json_data.get("sort_by", "filename")
|
||||
|
||||
# Get cycler list from service
|
||||
lora_list = await self.service.get_cycler_list(
|
||||
pool_config=pool_config,
|
||||
sort_by=sort_by
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "loras": lora_list, "count": len(lora_list)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cycler list: {e}", exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def get_trigger_words(self, request: web.Request) -> web.Response:
|
||||
"""Get trigger words for specified LoRA models"""
|
||||
try:
|
||||
json_data = await request.json()
|
||||
lora_names = json_data.get("lora_names", [])
|
||||
node_ids = json_data.get("node_ids", [])
|
||||
|
||||
|
||||
all_trigger_words = []
|
||||
for lora_name in lora_names:
|
||||
_, trigger_words = get_lora_info(lora_name)
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
|
||||
# Format the trigger words
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
|
||||
trigger_words_text = (
|
||||
",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
)
|
||||
|
||||
# Send update to all connected trigger word toggle nodes
|
||||
for node_id in node_ids:
|
||||
PromptServer.instance.send_sync("trigger_word_update", {
|
||||
"id": node_id,
|
||||
"message": trigger_words_text
|
||||
})
|
||||
|
||||
for entry in node_ids:
|
||||
node_identifier = entry
|
||||
graph_identifier = None
|
||||
if isinstance(entry, dict):
|
||||
node_identifier = entry.get("node_id")
|
||||
graph_identifier = entry.get("graph_id")
|
||||
|
||||
try:
|
||||
parsed_node_id = int(node_identifier)
|
||||
except (TypeError, ValueError):
|
||||
parsed_node_id = node_identifier
|
||||
|
||||
payload = {"id": parsed_node_id, "message": trigger_words_text}
|
||||
|
||||
if graph_identifier is not None:
|
||||
payload["graph_id"] = str(graph_identifier)
|
||||
|
||||
PromptServer.instance.send_sync("trigger_word_update", payload)
|
||||
|
||||
return web.json_response({"success": True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting trigger words: {e}")
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
112
py/routes/misc_model_routes.py
Normal file
112
py/routes/misc_model_routes.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
from aiohttp import web
|
||||
|
||||
from .base_model_routes import BaseModelRoutes
|
||||
from .model_route_registrar import ModelRouteRegistrar
|
||||
from ..services.misc_service import MiscService
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MiscModelRoutes(BaseModelRoutes):
|
||||
"""Misc-specific route controller (VAE, Upscaler)"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Misc routes with Misc service"""
|
||||
super().__init__()
|
||||
self.template_name = "misc.html"
|
||||
|
||||
async def initialize_services(self):
|
||||
"""Initialize services from ServiceRegistry"""
|
||||
misc_scanner = await ServiceRegistry.get_misc_scanner()
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
self.service = MiscService(misc_scanner, update_service=update_service)
|
||||
self.set_model_update_service(update_service)
|
||||
|
||||
# Attach service dependencies
|
||||
self.attach_service(self.service)
|
||||
|
||||
def setup_routes(self, app: web.Application):
|
||||
"""Setup Misc routes"""
|
||||
# Schedule service initialization on app startup
|
||||
app.on_startup.append(lambda _: self.initialize_services())
|
||||
|
||||
# Setup common routes with 'misc' prefix (includes page route)
|
||||
super().setup_routes(app, 'misc')
|
||||
|
||||
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||
"""Setup Misc-specific routes"""
|
||||
# Misc info by name
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_misc_info)
|
||||
|
||||
# VAE roots and Upscaler roots
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/vae_roots', prefix, self.get_vae_roots)
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/upscaler_roots', prefix, self.get_upscaler_roots)
|
||||
|
||||
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||
"""Validate CivitAI model type for Misc (VAE or Upscaler)"""
|
||||
return model_type.lower() in ['vae', 'upscaler']
|
||||
|
||||
def _get_expected_model_types(self) -> str:
|
||||
"""Get expected model types string for error messages"""
|
||||
return "VAE or Upscaler"
|
||||
|
||||
def _parse_specific_params(self, request: web.Request) -> Dict:
|
||||
"""Parse Misc-specific parameters"""
|
||||
params: Dict = {}
|
||||
|
||||
if 'misc_hash' in request.query:
|
||||
params['hash_filters'] = {'single_hash': request.query['misc_hash'].lower()}
|
||||
elif 'misc_hashes' in request.query:
|
||||
params['hash_filters'] = {
|
||||
'multiple_hashes': [h.lower() for h in request.query['misc_hashes'].split(',')]
|
||||
}
|
||||
|
||||
return params
|
||||
|
||||
async def get_misc_info(self, request: web.Request) -> web.Response:
|
||||
"""Get detailed information for a specific misc model by name"""
|
||||
try:
|
||||
name = request.match_info.get('name', '')
|
||||
misc_info = await self.service.get_model_info_by_name(name)
|
||||
|
||||
if misc_info:
|
||||
return web.json_response(misc_info)
|
||||
else:
|
||||
return web.json_response({"error": "Misc model not found"}, status=404)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_misc_info: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def get_vae_roots(self, request: web.Request) -> web.Response:
|
||||
"""Return the list of VAE roots from config"""
|
||||
try:
|
||||
roots = config.vae_roots
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"roots": roots
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting VAE roots: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_upscaler_roots(self, request: web.Request) -> web.Response:
|
||||
"""Return the list of upscaler roots from config"""
|
||||
try:
|
||||
roots = config.upscaler_roots
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"roots": roots
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting upscaler roots: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
@@ -22,6 +22,7 @@ class RouteDefinition:
|
||||
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
||||
@@ -32,12 +33,16 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
|
||||
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
|
||||
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
|
||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
|
||||
RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"),
|
||||
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
|
||||
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),
|
||||
RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"),
|
||||
RouteDefinition("POST", "/api/lm/settings/open-location", "open_settings_location"),
|
||||
RouteDefinition("GET", "/api/lm/custom-words/search", "search_custom_words"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@ from ..services.metadata_service import (
|
||||
get_metadata_provider,
|
||||
update_metadata_providers,
|
||||
)
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.downloader import get_downloader
|
||||
from ..utils.usage_stats import UsageStats
|
||||
from .handlers.misc_handlers import (
|
||||
CustomWordsHandler,
|
||||
FileSystemHandler,
|
||||
HealthCheckHandler,
|
||||
LoraCodeHandler,
|
||||
@@ -47,7 +48,7 @@ class MiscRoutes:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings_service=settings,
|
||||
settings_service=None,
|
||||
usage_stats_factory: Callable[[], UsageStats] = UsageStats,
|
||||
prompt_server: type[PromptServer] = PromptServer,
|
||||
service_registry_adapter=build_service_registry_adapter(),
|
||||
@@ -60,7 +61,7 @@ class MiscRoutes:
|
||||
node_registry: NodeRegistry | None = None,
|
||||
standalone_mode_flag: bool = standalone_mode,
|
||||
) -> None:
|
||||
self._settings = settings_service
|
||||
self._settings = settings_service or get_settings_manager()
|
||||
self._usage_stats_factory = usage_stats_factory
|
||||
self._prompt_server = prompt_server
|
||||
self._service_registry_adapter = service_registry_adapter
|
||||
@@ -107,7 +108,7 @@ class MiscRoutes:
|
||||
settings_service=self._settings,
|
||||
metadata_provider_updater=self._metadata_provider_updater,
|
||||
)
|
||||
filesystem = FileSystemHandler()
|
||||
filesystem = FileSystemHandler(settings_service=self._settings)
|
||||
node_registry_handler = NodeRegistryHandler(
|
||||
node_registry=self._node_registry,
|
||||
prompt_server=self._prompt_server,
|
||||
@@ -117,6 +118,7 @@ class MiscRoutes:
|
||||
service_registry=self._service_registry_adapter,
|
||||
metadata_provider_factory=self._metadata_provider_factory,
|
||||
)
|
||||
custom_words = CustomWordsHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -129,6 +131,7 @@ class MiscRoutes:
|
||||
model_library=model_library,
|
||||
metadata_archive=metadata_archive,
|
||||
filesystem=filesystem,
|
||||
custom_words=custom_words,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"),
|
||||
@@ -55,10 +56,19 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/version/{modelVersionId}", "get_civitai_model_by_version"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/hash/{hash}", "get_civitai_model_by_hash"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/updates/refresh", "refresh_model_updates"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/updates/fetch-missing-license", "fetch_missing_civitai_license_data"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore", "set_model_update_ignore"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore-version", "set_version_update_ignore"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/updates/status/{model_id}", "get_model_update_status"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions"),
|
||||
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
||||
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
||||
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
||||
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
||||
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
||||
RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
|
||||
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
||||
)
|
||||
|
||||
@@ -96,4 +106,3 @@ class ModelRouteRegistrar:
|
||||
add_method_name = self._METHOD_MAP[method.upper()]
|
||||
add_method = getattr(self._app.router, add_method_name)
|
||||
add_method(path, handler)
|
||||
|
||||
|
||||
@@ -20,22 +20,33 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/loras/recipes", "render_page"),
|
||||
RouteDefinition("GET", "/api/lm/recipes", "list_recipes"),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/import-remote", "import_remote_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
|
||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||
)
|
||||
|
||||
|
||||
@@ -61,4 +72,3 @@ class RecipeRouteRegistrar:
|
||||
add_method_name = self._METHOD_MAP[method.upper()]
|
||||
add_method = getattr(self._app.router, add_method_name)
|
||||
add_method(path, handler)
|
||||
|
||||
|
||||
@@ -8,13 +8,32 @@ from collections import defaultdict, Counter
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from ..config import config
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.usage_stats import UsageStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _SettingsProxy:
|
||||
def __init__(self):
|
||||
self._manager = None
|
||||
|
||||
def _resolve(self):
|
||||
if self._manager is None:
|
||||
self._manager = get_settings_manager()
|
||||
return self._manager
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return self._resolve().get(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._resolve(), item)
|
||||
|
||||
|
||||
settings = _SettingsProxy()
|
||||
|
||||
class StatsRoutes:
|
||||
"""Route handlers for Statistics page and API endpoints"""
|
||||
|
||||
@@ -66,7 +85,9 @@ class StatsRoutes:
|
||||
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
|
||||
|
||||
# 获取用户语言设置
|
||||
user_language = settings.get('language', 'en')
|
||||
settings_object = settings
|
||||
user_language = settings_object.get('language', 'en')
|
||||
settings_manager = settings_object if not isinstance(settings_object, _SettingsProxy) else settings_object._resolve()
|
||||
|
||||
# 设置服务端i18n语言
|
||||
server_i18n.set_locale(user_language)
|
||||
@@ -79,7 +100,7 @@ class StatsRoutes:
|
||||
template = self.template_env.get_template('statistics.html')
|
||||
rendered = template.render(
|
||||
is_initializing=is_initializing,
|
||||
settings=settings,
|
||||
settings=settings_manager,
|
||||
request=request,
|
||||
t=server_i18n.get_translation,
|
||||
)
|
||||
|
||||
@@ -45,8 +45,9 @@ class UpdateRoutes:
|
||||
# Fetch remote version from GitHub
|
||||
if nightly:
|
||||
remote_version, changelog = await UpdateRoutes._get_nightly_version()
|
||||
releases = None
|
||||
else:
|
||||
remote_version, changelog = await UpdateRoutes._get_remote_version()
|
||||
remote_version, changelog, releases = await UpdateRoutes._get_remote_version()
|
||||
|
||||
# Compare versions
|
||||
if nightly:
|
||||
@@ -59,7 +60,7 @@ class UpdateRoutes:
|
||||
remote_version.replace('v', '')
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
response_data = {
|
||||
'success': True,
|
||||
'current_version': local_version,
|
||||
'latest_version': remote_version,
|
||||
@@ -67,7 +68,13 @@ class UpdateRoutes:
|
||||
'changelog': changelog,
|
||||
'git_info': git_info,
|
||||
'nightly': nightly
|
||||
})
|
||||
}
|
||||
|
||||
# Include releases list for stable mode
|
||||
if releases is not None:
|
||||
response_data['releases'] = releases
|
||||
|
||||
return web.json_response(response_data)
|
||||
|
||||
except NETWORK_EXCEPTIONS as e:
|
||||
logger.warning("Network unavailable during update check: %s", e)
|
||||
@@ -205,8 +212,8 @@ class UpdateRoutes:
|
||||
|
||||
zip_path = tmp_zip_path
|
||||
|
||||
# Skip both settings.json and civitai folder
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai'])
|
||||
# Skip both settings.json, civitai and model cache folder
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache'])
|
||||
|
||||
# Extract ZIP to temp dir
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
@@ -344,6 +351,11 @@ class UpdateRoutes:
|
||||
origin.fetch()
|
||||
|
||||
if nightly:
|
||||
# Reset to discard any local changes
|
||||
repo.git.reset('--hard')
|
||||
# Clean untracked files
|
||||
repo.git.clean('-fd')
|
||||
|
||||
# Switch to main branch and pull latest
|
||||
main_branch = 'main'
|
||||
if main_branch not in [branch.name for branch in repo.branches]:
|
||||
@@ -357,6 +369,11 @@ class UpdateRoutes:
|
||||
new_version = f"main-{repo.head.commit.hexsha[:7]}"
|
||||
|
||||
else:
|
||||
# Reset to discard any local changes
|
||||
repo.git.reset('--hard')
|
||||
# Clean untracked files
|
||||
repo.git.clean('-fd')
|
||||
|
||||
# Get latest release tag
|
||||
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
||||
if not tags:
|
||||
@@ -433,42 +450,58 @@ class UpdateRoutes:
|
||||
return git_info
|
||||
|
||||
@staticmethod
|
||||
async def _get_remote_version() -> tuple[str, List[str]]:
|
||||
async def _get_remote_version() -> tuple[str, List[str], List[Dict]]:
|
||||
"""
|
||||
Fetch remote version from GitHub
|
||||
Returns:
|
||||
tuple: (version string, changelog list)
|
||||
tuple: (version string, changelog list, releases list)
|
||||
"""
|
||||
repo_owner = "willmiao"
|
||||
repo_name = "ComfyUI-Lora-Manager"
|
||||
|
||||
# Use GitHub API to fetch the latest release
|
||||
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
||||
# Use GitHub API to fetch the last 5 releases
|
||||
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases?per_page=5"
|
||||
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'})
|
||||
|
||||
if not success:
|
||||
logger.warning(f"Failed to fetch GitHub release: {data}")
|
||||
return "v0.0.0", []
|
||||
logger.warning(f"Failed to fetch GitHub releases: {data}")
|
||||
return "v0.0.0", [], []
|
||||
|
||||
version = data.get('tag_name', '')
|
||||
if not version.startswith('v'):
|
||||
version = f"v{version}"
|
||||
# Parse releases
|
||||
releases = []
|
||||
for i, release in enumerate(data):
|
||||
version = release.get('tag_name', '')
|
||||
if not version.startswith('v'):
|
||||
version = f"v{version}"
|
||||
|
||||
# Extract changelog from release notes
|
||||
body = release.get('body', '')
|
||||
changelog = UpdateRoutes._parse_changelog(body)
|
||||
|
||||
releases.append({
|
||||
'version': version,
|
||||
'changelog': changelog,
|
||||
'published_at': release.get('published_at', ''),
|
||||
'is_latest': i == 0
|
||||
})
|
||||
|
||||
# Extract changelog from release notes
|
||||
body = data.get('body', '')
|
||||
changelog = UpdateRoutes._parse_changelog(body)
|
||||
# Get latest version and its changelog
|
||||
if releases:
|
||||
latest_version = releases[0]['version']
|
||||
latest_changelog = releases[0]['changelog']
|
||||
return latest_version, latest_changelog, releases
|
||||
|
||||
return version, changelog
|
||||
return "v0.0.0", [], []
|
||||
|
||||
except NETWORK_EXCEPTIONS as e:
|
||||
logger.warning("Unable to reach GitHub for release info: %s", e)
|
||||
return "v0.0.0", []
|
||||
return "v0.0.0", [], []
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching remote version: {e}", exc_info=True)
|
||||
return "v0.0.0", []
|
||||
return "v0.0.0", [], []
|
||||
|
||||
@staticmethod
|
||||
def _parse_changelog(release_notes: str) -> List[str]:
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Optional, Type
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .model_query import FilterCriteria, ModelCacheRepository, ModelFilterSet, SearchStrategy, SettingsProvider
|
||||
from .settings_manager import settings as default_settings
|
||||
from ..utils.usage_stats import UsageStats
|
||||
from .model_query import (
|
||||
FilterCriteria,
|
||||
ModelCacheRepository,
|
||||
ModelFilterSet,
|
||||
SearchStrategy,
|
||||
SettingsProvider,
|
||||
normalize_sub_type,
|
||||
resolve_sub_type,
|
||||
)
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .model_update_service import ModelUpdateService
|
||||
|
||||
|
||||
class BaseModelService(ABC):
|
||||
"""Base service class for all model types"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_type: str,
|
||||
@@ -23,6 +39,7 @@ class BaseModelService(ABC):
|
||||
filter_set: Optional[ModelFilterSet] = None,
|
||||
search_strategy: Optional[SearchStrategy] = None,
|
||||
settings_provider: Optional[SettingsProvider] = None,
|
||||
update_service: Optional["ModelUpdateService"] = None,
|
||||
):
|
||||
"""Initialize the service.
|
||||
|
||||
@@ -34,88 +51,194 @@ class BaseModelService(ABC):
|
||||
filter_set: Filter component controlling folder/tag/favorites logic.
|
||||
search_strategy: Search component for fuzzy/text matching.
|
||||
settings_provider: Settings object; defaults to the global settings manager.
|
||||
update_service: Service used to determine whether models have remote updates available.
|
||||
"""
|
||||
self.model_type = model_type
|
||||
self.scanner = scanner
|
||||
self.metadata_class = metadata_class
|
||||
self.settings = settings_provider or default_settings
|
||||
self.settings = settings_provider or get_settings_manager()
|
||||
self.cache_repository = cache_repository or ModelCacheRepository(scanner)
|
||||
self.filter_set = filter_set or ModelFilterSet(self.settings)
|
||||
self.search_strategy = search_strategy or SearchStrategy()
|
||||
|
||||
self.update_service = update_service
|
||||
|
||||
async def get_paginated_data(
|
||||
self,
|
||||
page: int,
|
||||
page_size: int,
|
||||
sort_by: str = 'name',
|
||||
sort_by: str = "name",
|
||||
folder: str = None,
|
||||
folder_include: list = None,
|
||||
folder_exclude: list = None,
|
||||
search: str = None,
|
||||
fuzzy_search: bool = False,
|
||||
base_models: list = None,
|
||||
tags: list = None,
|
||||
model_types: list = None,
|
||||
tags: Optional[Dict[str, str]] = None,
|
||||
search_options: dict = None,
|
||||
hash_filters: dict = None,
|
||||
favorites_only: bool = False,
|
||||
update_available_only: bool = False,
|
||||
credit_required: Optional[bool] = None,
|
||||
allow_selling_generated_content: Optional[bool] = None,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
"""Get paginated and filtered model data"""
|
||||
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||
overall_start = time.perf_counter()
|
||||
|
||||
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||
t0 = time.perf_counter()
|
||||
if sort_params.key == "usage":
|
||||
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
||||
else:
|
||||
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||
fetch_duration = time.perf_counter() - t0
|
||||
initial_count = len(sorted_data)
|
||||
|
||||
t1 = time.perf_counter()
|
||||
if hash_filters:
|
||||
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||
return self._paginate(filtered_data, page, page_size)
|
||||
|
||||
filtered_data = await self._apply_common_filters(
|
||||
sorted_data,
|
||||
folder=folder,
|
||||
base_models=base_models,
|
||||
tags=tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=search_options,
|
||||
)
|
||||
|
||||
if search:
|
||||
filtered_data = await self._apply_search_filters(
|
||||
filtered_data,
|
||||
search,
|
||||
fuzzy_search,
|
||||
search_options,
|
||||
else:
|
||||
filtered_data = await self._apply_common_filters(
|
||||
sorted_data,
|
||||
folder=folder,
|
||||
folder_include=folder_include,
|
||||
folder_exclude=folder_exclude,
|
||||
base_models=base_models,
|
||||
model_types=model_types,
|
||||
tags=tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=search_options,
|
||||
)
|
||||
|
||||
filtered_data = await self._apply_specific_filters(filtered_data, **kwargs)
|
||||
if search:
|
||||
filtered_data = await self._apply_search_filters(
|
||||
filtered_data,
|
||||
search,
|
||||
fuzzy_search,
|
||||
search_options,
|
||||
)
|
||||
|
||||
return self._paginate(filtered_data, page, page_size)
|
||||
filtered_data = await self._apply_specific_filters(filtered_data, **kwargs)
|
||||
|
||||
|
||||
async def _apply_hash_filters(self, data: List[Dict], hash_filters: Dict) -> List[Dict]:
|
||||
# Apply license-based filters
|
||||
if credit_required is not None:
|
||||
filtered_data = await self._apply_credit_required_filter(
|
||||
filtered_data, credit_required
|
||||
)
|
||||
|
||||
if allow_selling_generated_content is not None:
|
||||
filtered_data = await self._apply_allow_selling_filter(
|
||||
filtered_data, allow_selling_generated_content
|
||||
)
|
||||
filter_duration = time.perf_counter() - t1
|
||||
post_filter_count = len(filtered_data)
|
||||
|
||||
annotated_for_filter: Optional[List[Dict]] = None
|
||||
t2 = time.perf_counter()
|
||||
if update_available_only:
|
||||
annotated_for_filter = await self._annotate_update_flags(filtered_data)
|
||||
filtered_data = [
|
||||
item for item in annotated_for_filter if item.get("update_available")
|
||||
]
|
||||
update_filter_duration = time.perf_counter() - t2
|
||||
final_count = len(filtered_data)
|
||||
|
||||
t3 = time.perf_counter()
|
||||
paginated = self._paginate(filtered_data, page, page_size)
|
||||
pagination_duration = time.perf_counter() - t3
|
||||
|
||||
t4 = time.perf_counter()
|
||||
if update_available_only:
|
||||
# Items already include update flags thanks to the pre-filter annotation.
|
||||
paginated["items"] = list(paginated["items"])
|
||||
else:
|
||||
paginated["items"] = await self._annotate_update_flags(
|
||||
paginated["items"],
|
||||
)
|
||||
annotate_duration = time.perf_counter() - t4
|
||||
|
||||
overall_duration = time.perf_counter() - overall_start
|
||||
logger.debug(
|
||||
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
|
||||
"Counts: initial=%d, post_filter=%d, final=%d",
|
||||
self.__class__.__name__,
|
||||
overall_duration,
|
||||
fetch_duration,
|
||||
filter_duration,
|
||||
update_filter_duration,
|
||||
pagination_duration,
|
||||
annotate_duration,
|
||||
initial_count,
|
||||
post_filter_count,
|
||||
final_count,
|
||||
)
|
||||
return paginated
|
||||
|
||||
async def _fetch_with_usage_sort(self, sort_params):
|
||||
"""Fetch data sorted by usage count (desc/asc)."""
|
||||
cache = await self.cache_repository.get_cache()
|
||||
raw_items = cache.raw_data or []
|
||||
|
||||
# Map model type to usage stats bucket
|
||||
bucket_map = {
|
||||
"lora": "loras",
|
||||
"checkpoint": "checkpoints",
|
||||
# 'embedding': 'embeddings', # TODO: Enable when embedding usage tracking is implemented
|
||||
}
|
||||
bucket_key = bucket_map.get(self.model_type, "")
|
||||
|
||||
usage_stats = UsageStats()
|
||||
stats = await usage_stats.get_stats()
|
||||
usage_bucket = stats.get(bucket_key, {}) if bucket_key else {}
|
||||
|
||||
annotated = []
|
||||
for item in raw_items:
|
||||
sha = (item.get("sha256") or "").lower()
|
||||
usage_info = (
|
||||
usage_bucket.get(sha, {}) if isinstance(usage_bucket, dict) else {}
|
||||
)
|
||||
usage_count = (
|
||||
usage_info.get("total", 0) if isinstance(usage_info, dict) else 0
|
||||
)
|
||||
annotated.append({**item, "usage_count": usage_count})
|
||||
|
||||
reverse = sort_params.order == "desc"
|
||||
annotated.sort(
|
||||
key=lambda x: (x.get("usage_count", 0), x.get("model_name", "").lower()),
|
||||
reverse=reverse,
|
||||
)
|
||||
return annotated
|
||||
|
||||
async def _apply_hash_filters(
|
||||
self, data: List[Dict], hash_filters: Dict
|
||||
) -> List[Dict]:
|
||||
"""Apply hash-based filtering"""
|
||||
single_hash = hash_filters.get('single_hash')
|
||||
multiple_hashes = hash_filters.get('multiple_hashes')
|
||||
|
||||
single_hash = hash_filters.get("single_hash")
|
||||
multiple_hashes = hash_filters.get("multiple_hashes")
|
||||
|
||||
if single_hash:
|
||||
# Filter by single hash
|
||||
single_hash = single_hash.lower()
|
||||
return [
|
||||
item for item in data
|
||||
if item.get('sha256', '').lower() == single_hash
|
||||
item for item in data if item.get("sha256", "").lower() == single_hash
|
||||
]
|
||||
elif multiple_hashes:
|
||||
# Filter by multiple hashes
|
||||
hash_set = set(hash.lower() for hash in multiple_hashes)
|
||||
return [
|
||||
item for item in data
|
||||
if item.get('sha256', '').lower() in hash_set
|
||||
]
|
||||
|
||||
return [item for item in data if item.get("sha256", "").lower() in hash_set]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def _apply_common_filters(
|
||||
self,
|
||||
data: List[Dict],
|
||||
folder: str = None,
|
||||
folder_include: list = None,
|
||||
folder_exclude: list = None,
|
||||
base_models: list = None,
|
||||
tags: list = None,
|
||||
model_types: list = None,
|
||||
tags: Optional[Dict[str, str]] = None,
|
||||
favorites_only: bool = False,
|
||||
search_options: dict = None,
|
||||
) -> List[Dict]:
|
||||
@@ -123,13 +246,16 @@ class BaseModelService(ABC):
|
||||
normalized_options = self.search_strategy.normalize_options(search_options)
|
||||
criteria = FilterCriteria(
|
||||
folder=folder,
|
||||
folder_include=folder_include,
|
||||
folder_exclude=folder_exclude,
|
||||
base_models=base_models,
|
||||
model_types=model_types,
|
||||
tags=tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=normalized_options,
|
||||
)
|
||||
return self.filter_set.apply(data, criteria)
|
||||
|
||||
|
||||
async def _apply_search_filters(
|
||||
self,
|
||||
data: List[Dict],
|
||||
@@ -139,83 +265,398 @@ class BaseModelService(ABC):
|
||||
) -> List[Dict]:
|
||||
"""Apply search filtering"""
|
||||
normalized_options = self.search_strategy.normalize_options(search_options)
|
||||
return self.search_strategy.apply(data, search, normalized_options, fuzzy_search)
|
||||
|
||||
return self.search_strategy.apply(
|
||||
data, search, normalized_options, fuzzy_search
|
||||
)
|
||||
|
||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||
"""Apply model-specific filters - to be overridden by subclasses if needed"""
|
||||
return data
|
||||
|
||||
|
||||
async def _apply_credit_required_filter(
|
||||
self, data: List[Dict], credit_required: bool
|
||||
) -> List[Dict]:
|
||||
"""Apply credit required filtering based on license_flags.
|
||||
|
||||
Args:
|
||||
data: List of model data items
|
||||
credit_required:
|
||||
- True: Return items where credit is required (allowNoCredit=False)
|
||||
- False: Return items where credit is not required (allowNoCredit=True)
|
||||
"""
|
||||
filtered_data = []
|
||||
for item in data:
|
||||
license_flags = item.get(
|
||||
"license_flags", 127
|
||||
) # Default to all permissions enabled
|
||||
|
||||
# Bit 0 represents allowNoCredit (1 = no credit required, 0 = credit required)
|
||||
allow_no_credit = bool(license_flags & (1 << 0))
|
||||
|
||||
# If credit_required is True, we want items where allowNoCredit is False (credit required)
|
||||
# If credit_required is False, we want items where allowNoCredit is True (no credit required)
|
||||
if credit_required:
|
||||
if not allow_no_credit: # Credit is required
|
||||
filtered_data.append(item)
|
||||
else:
|
||||
if allow_no_credit: # Credit is not required
|
||||
filtered_data.append(item)
|
||||
|
||||
return filtered_data
|
||||
|
||||
async def _apply_allow_selling_filter(
|
||||
self, data: List[Dict], allow_selling: bool
|
||||
) -> List[Dict]:
|
||||
"""Apply allow selling generated content filtering based on license_flags.
|
||||
|
||||
Args:
|
||||
data: List of model data items
|
||||
allow_selling:
|
||||
- True: Return items where selling generated content is allowed (allowCommercialUse contains Image)
|
||||
- False: Return items where selling generated content is not allowed (allowCommercialUse does not contain Image)
|
||||
"""
|
||||
filtered_data = []
|
||||
for item in data:
|
||||
license_flags = item.get(
|
||||
"license_flags", 127
|
||||
) # Default to all permissions enabled
|
||||
|
||||
# Bits 1-4 represent commercial use permissions
|
||||
# Bit 1 specifically represents Image permission (allowCommercialUse contains Image)
|
||||
has_image_permission = bool(license_flags & (1 << 1))
|
||||
|
||||
# If allow_selling is True, we want items where Image permission is granted
|
||||
# If allow_selling is False, we want items where Image permission is not granted
|
||||
if allow_selling:
|
||||
if has_image_permission: # Selling generated content is allowed
|
||||
filtered_data.append(item)
|
||||
else:
|
||||
if not has_image_permission: # Selling generated content is not allowed
|
||||
filtered_data.append(item)
|
||||
|
||||
return filtered_data
|
||||
|
||||
async def _annotate_update_flags(
|
||||
self,
|
||||
items: List[Dict],
|
||||
) -> List[Dict]:
|
||||
"""Attach an update_available flag to each response item.
|
||||
|
||||
Items without a civitai model id default to False.
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
|
||||
annotated = [dict(item) for item in items]
|
||||
|
||||
if self.update_service is None:
|
||||
for item in annotated:
|
||||
item["update_available"] = False
|
||||
return annotated
|
||||
|
||||
id_to_items: Dict[int, List[Dict]] = {}
|
||||
ordered_ids: List[int] = []
|
||||
for item in annotated:
|
||||
model_id = self._extract_model_id(item)
|
||||
if model_id is None:
|
||||
item["update_available"] = False
|
||||
continue
|
||||
if model_id not in id_to_items:
|
||||
id_to_items[model_id] = []
|
||||
ordered_ids.append(model_id)
|
||||
id_to_items[model_id].append(item)
|
||||
|
||||
if not ordered_ids:
|
||||
return annotated
|
||||
|
||||
strategy_value = self.settings.get("update_flag_strategy")
|
||||
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||
strategy = strategy_value.strip().lower()
|
||||
else:
|
||||
strategy = "same_base"
|
||||
same_base_mode = strategy == "same_base"
|
||||
|
||||
records = None
|
||||
resolved: Optional[Dict[int, bool]] = None
|
||||
if same_base_mode:
|
||||
record_method = getattr(self.update_service, "get_records_bulk", None)
|
||||
if callable(record_method):
|
||||
try:
|
||||
records = await record_method(self.model_type, ordered_ids)
|
||||
resolved = {
|
||||
model_id: record.has_update()
|
||||
for model_id, record in records.items()
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to resolve update records in bulk for %s models (%s): %s",
|
||||
self.model_type,
|
||||
ordered_ids,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
records = None
|
||||
resolved = None
|
||||
|
||||
if resolved is None:
|
||||
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||
if callable(bulk_method):
|
||||
try:
|
||||
resolved = await bulk_method(self.model_type, ordered_ids)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||
self.model_type,
|
||||
ordered_ids,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
resolved = None
|
||||
|
||||
if resolved is None:
|
||||
tasks = [
|
||||
self.update_service.has_update(self.model_type, model_id)
|
||||
for model_id in ordered_ids
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
resolved = {}
|
||||
for model_id, result in zip(ordered_ids, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
"Failed to resolve update status for model %s (%s): %s",
|
||||
model_id,
|
||||
self.model_type,
|
||||
result,
|
||||
)
|
||||
continue
|
||||
resolved[model_id] = bool(result)
|
||||
|
||||
for model_id, items_for_id in id_to_items.items():
|
||||
default_flag = bool(resolved.get(model_id, False)) if resolved else False
|
||||
record = records.get(model_id) if records else None
|
||||
base_highest_versions = (
|
||||
self._build_highest_local_versions_by_base(record)
|
||||
if same_base_mode and record
|
||||
else {}
|
||||
)
|
||||
for item in items_for_id:
|
||||
if same_base_mode and record is not None:
|
||||
base_model = self._extract_base_model(item)
|
||||
normalized_base = self._normalize_base_model_name(base_model)
|
||||
threshold_version = (
|
||||
base_highest_versions.get(normalized_base)
|
||||
if normalized_base
|
||||
else None
|
||||
)
|
||||
if threshold_version is None:
|
||||
threshold_version = self._extract_version_id(item)
|
||||
flag = record.has_update_for_base(
|
||||
threshold_version,
|
||||
base_model,
|
||||
)
|
||||
else:
|
||||
flag = default_flag
|
||||
item["update_available"] = flag
|
||||
|
||||
return annotated
|
||||
|
||||
@staticmethod
|
||||
def _extract_model_id(item: Dict) -> Optional[int]:
|
||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||
if not isinstance(civitai, dict):
|
||||
return None
|
||||
try:
|
||||
value = civitai.get("modelId")
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_version_id(item: Dict) -> Optional[int]:
|
||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||
if not isinstance(civitai, dict):
|
||||
return None
|
||||
value = civitai.get("id")
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_base_model(item: Dict) -> Optional[str]:
|
||||
value = item.get("base_model")
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
candidate = value.strip()
|
||||
else:
|
||||
try:
|
||||
candidate = str(value).strip()
|
||||
except Exception:
|
||||
return None
|
||||
return candidate if candidate else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_base_model_name(value: Optional[str]) -> Optional[str]:
|
||||
"""Return a lowercased, trimmed base model name for comparison."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
candidate = value.strip()
|
||||
else:
|
||||
try:
|
||||
candidate = str(value).strip()
|
||||
except Exception:
|
||||
return None
|
||||
return candidate.lower() if candidate else None
|
||||
|
||||
def _build_highest_local_versions_by_base(self, record) -> Dict[str, int]:
|
||||
"""Return the highest local version id known for each normalized base model."""
|
||||
|
||||
if record is None:
|
||||
return {}
|
||||
|
||||
highest_by_base: Dict[str, int] = {}
|
||||
for version in getattr(record, "versions", []):
|
||||
if not getattr(version, "is_in_library", False):
|
||||
continue
|
||||
normalized_base = self._normalize_base_model_name(
|
||||
getattr(version, "base_model", None)
|
||||
)
|
||||
if normalized_base is None:
|
||||
continue
|
||||
version_id = getattr(version, "version_id", None)
|
||||
if version_id is None:
|
||||
continue
|
||||
current_max = highest_by_base.get(normalized_base)
|
||||
if current_max is None or version_id > current_max:
|
||||
highest_by_base[normalized_base] = version_id
|
||||
|
||||
return highest_by_base
|
||||
|
||||
def _paginate(self, data: List[Dict], page: int, page_size: int) -> Dict:
|
||||
"""Apply pagination to filtered data"""
|
||||
total_items = len(data)
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = min(start_idx + page_size, total_items)
|
||||
|
||||
|
||||
return {
|
||||
'items': data[start_idx:end_idx],
|
||||
'total': total_items,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total_items + page_size - 1) // page_size
|
||||
"items": data[start_idx:end_idx],
|
||||
"total": total_items,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total_items + page_size - 1) // page_size,
|
||||
}
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def format_response(self, model_data: Dict) -> Dict:
|
||||
"""Format model data for API response - must be implemented by subclasses"""
|
||||
pass
|
||||
|
||||
|
||||
# Common service methods that delegate to scanner
|
||||
async def get_top_tags(self, limit: int = 20) -> List[Dict]:
|
||||
"""Get top tags sorted by frequency"""
|
||||
return await self.scanner.get_top_tags(limit)
|
||||
|
||||
|
||||
async def get_base_models(self, limit: int = 20) -> List[Dict]:
|
||||
"""Get base models sorted by frequency"""
|
||||
return await self.scanner.get_base_models(limit)
|
||||
|
||||
|
||||
async def get_model_types(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Get counts of sub-types present in the cache."""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
type_counts: Dict[str, int] = {}
|
||||
for entry in cache.raw_data:
|
||||
normalized_type = normalize_sub_type(resolve_sub_type(entry))
|
||||
if not normalized_type:
|
||||
continue
|
||||
|
||||
# Filter by valid sub-types based on scanner type
|
||||
if self.model_type == "lora" and normalized_type not in VALID_LORA_SUB_TYPES:
|
||||
continue
|
||||
if self.model_type == "checkpoint" and normalized_type not in VALID_CHECKPOINT_SUB_TYPES:
|
||||
continue
|
||||
|
||||
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||
|
||||
sorted_types = sorted(
|
||||
[
|
||||
{"type": model_type, "count": count}
|
||||
for model_type, count in type_counts.items()
|
||||
],
|
||||
key=lambda value: value["count"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return sorted_types[:limit]
|
||||
|
||||
def has_hash(self, sha256: str) -> bool:
|
||||
"""Check if a model with given hash exists"""
|
||||
return self.scanner.has_hash(sha256)
|
||||
|
||||
|
||||
def get_path_by_hash(self, sha256: str) -> Optional[str]:
|
||||
"""Get file path for a model by its hash"""
|
||||
return self.scanner.get_path_by_hash(sha256)
|
||||
|
||||
|
||||
def get_hash_by_path(self, file_path: str) -> Optional[str]:
|
||||
"""Get hash for a model by its file path"""
|
||||
return self.scanner.get_hash_by_path(file_path)
|
||||
|
||||
async def scan_models(self, force_refresh: bool = False, rebuild_cache: bool = False):
|
||||
|
||||
async def scan_models(
|
||||
self, force_refresh: bool = False, rebuild_cache: bool = False
|
||||
):
|
||||
"""Trigger model scanning"""
|
||||
return await self.scanner.get_cached_data(force_refresh=force_refresh, rebuild_cache=rebuild_cache)
|
||||
|
||||
return await self.scanner.get_cached_data(
|
||||
force_refresh=force_refresh, rebuild_cache=rebuild_cache
|
||||
)
|
||||
|
||||
async def get_model_info_by_name(self, name: str):
|
||||
"""Get model information by name"""
|
||||
return await self.scanner.get_model_info_by_name(name)
|
||||
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get model root directories"""
|
||||
return self.scanner.get_model_roots()
|
||||
|
||||
|
||||
def filter_civitai_data(self, data: Dict, minimal: bool = False) -> Dict:
|
||||
"""Filter relevant fields from CivitAI data"""
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
fields = ["id", "modelId", "name", "trainedWords"] if minimal else [
|
||||
"id", "modelId", "name", "createdAt", "updatedAt",
|
||||
"publishedAt", "trainedWords", "baseModel", "description",
|
||||
"model", "images", "customImages", "creator"
|
||||
]
|
||||
fields = (
|
||||
["id", "modelId", "name", "trainedWords"]
|
||||
if minimal
|
||||
else [
|
||||
"id",
|
||||
"modelId",
|
||||
"name",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"publishedAt",
|
||||
"trainedWords",
|
||||
"baseModel",
|
||||
"description",
|
||||
"model",
|
||||
"images",
|
||||
"customImages",
|
||||
"creator",
|
||||
]
|
||||
)
|
||||
return {k: data[k] for k in fields if k in data}
|
||||
|
||||
|
||||
async def get_folder_tree(self, model_root: str) -> Dict:
|
||||
"""Get hierarchical folder tree for a specific model root"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
|
||||
# Build tree structure from folders
|
||||
tree = {}
|
||||
|
||||
|
||||
for folder in cache.folders:
|
||||
# Check if this folder belongs to the specified model root
|
||||
folder_belongs_to_root = False
|
||||
@@ -223,95 +664,96 @@ class BaseModelService(ABC):
|
||||
if root == model_root:
|
||||
folder_belongs_to_root = True
|
||||
break
|
||||
|
||||
|
||||
if not folder_belongs_to_root:
|
||||
continue
|
||||
|
||||
|
||||
# Split folder path into components
|
||||
parts = folder.split('/') if folder else []
|
||||
parts = folder.split("/") if folder else []
|
||||
current_level = tree
|
||||
|
||||
|
||||
for part in parts:
|
||||
if part not in current_level:
|
||||
current_level[part] = {}
|
||||
current_level = current_level[part]
|
||||
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
async def get_unified_folder_tree(self) -> Dict:
|
||||
"""Get unified folder tree across all model roots"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
|
||||
# Build unified tree structure by analyzing all relative paths
|
||||
unified_tree = {}
|
||||
|
||||
|
||||
# Get all model roots for path normalization
|
||||
model_roots = self.scanner.get_model_roots()
|
||||
|
||||
|
||||
for folder in cache.folders:
|
||||
if not folder: # Skip empty folders
|
||||
continue
|
||||
|
||||
|
||||
# Find which root this folder belongs to by checking the actual file paths
|
||||
# This is a simplified approach - we'll use the folder as-is since it should already be relative
|
||||
relative_path = folder
|
||||
|
||||
|
||||
# Split folder path into components
|
||||
parts = relative_path.split('/')
|
||||
parts = relative_path.split("/")
|
||||
current_level = unified_tree
|
||||
|
||||
|
||||
for part in parts:
|
||||
if part not in current_level:
|
||||
current_level[part] = {}
|
||||
current_level = current_level[part]
|
||||
|
||||
|
||||
return unified_tree
|
||||
|
||||
async def get_model_notes(self, model_name: str) -> Optional[str]:
|
||||
"""Get notes for a specific model file"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model['file_name'] == model_name:
|
||||
return model.get('notes', '')
|
||||
|
||||
if model["file_name"] == model_name:
|
||||
return model.get("notes", "")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_model_preview_url(self, model_name: str) -> Optional[str]:
|
||||
"""Get the static preview URL for a model file"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model['file_name'] == model_name:
|
||||
preview_url = model.get('preview_url')
|
||||
if model["file_name"] == model_name:
|
||||
preview_url = model.get("preview_url")
|
||||
if preview_url:
|
||||
from ..config import config
|
||||
|
||||
return config.get_preview_static_url(preview_url)
|
||||
|
||||
return '/loras_static/images/no-preview.png'
|
||||
|
||||
|
||||
return "/loras_static/images/no-preview.png"
|
||||
|
||||
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
||||
"""Get the Civitai URL for a model file"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model['file_name'] == model_name:
|
||||
civitai_data = model.get('civitai', {})
|
||||
model_id = civitai_data.get('modelId')
|
||||
version_id = civitai_data.get('id')
|
||||
|
||||
if model["file_name"] == model_name:
|
||||
civitai_data = model.get("civitai", {})
|
||||
model_id = civitai_data.get("modelId")
|
||||
version_id = civitai_data.get("id")
|
||||
|
||||
if model_id:
|
||||
civitai_url = f"https://civitai.com/models/{model_id}"
|
||||
if version_id:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
|
||||
|
||||
return {
|
||||
'civitai_url': civitai_url,
|
||||
'model_id': str(model_id),
|
||||
'version_id': str(version_id) if version_id else None
|
||||
"civitai_url": civitai_url,
|
||||
"model_id": str(model_id),
|
||||
"version_id": str(version_id) if version_id else None,
|
||||
}
|
||||
|
||||
return {'civitai_url': None, 'model_id': None, 'version_id': None}
|
||||
|
||||
return {"civitai_url": None, "model_id": None, "version_id": None}
|
||||
|
||||
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||
"""Load full metadata for a single model.
|
||||
@@ -319,58 +761,116 @@ class BaseModelService(ABC):
|
||||
Listing/search endpoints return lightweight cache entries; this method performs
|
||||
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
||||
"""
|
||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class)
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path, self.metadata_class
|
||||
)
|
||||
if should_skip or metadata is None:
|
||||
return None
|
||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||
|
||||
|
||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||
"""Return the stored modelDescription field for a model."""
|
||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class)
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path, self.metadata_class
|
||||
)
|
||||
if should_skip or metadata is None:
|
||||
return None
|
||||
return metadata.modelDescription or ''
|
||||
return metadata.modelDescription or ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_search_tokens(search_term: str) -> tuple[List[str], List[str]]:
|
||||
"""Split a search string into include and exclude tokens."""
|
||||
include_terms: List[str] = []
|
||||
exclude_terms: List[str] = []
|
||||
|
||||
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
|
||||
for raw_term in search_term.split():
|
||||
term = raw_term.strip()
|
||||
if not term:
|
||||
continue
|
||||
|
||||
if term.startswith("-") and len(term) > 1:
|
||||
exclude_terms.append(term[1:].lower())
|
||||
else:
|
||||
include_terms.append(term.lower())
|
||||
|
||||
return include_terms, exclude_terms
|
||||
|
||||
@staticmethod
|
||||
def _relative_path_matches_tokens(
|
||||
path_lower: str, include_terms: List[str], exclude_terms: List[str]
|
||||
) -> bool:
|
||||
"""Determine whether a relative path string satisfies include/exclude tokens."""
|
||||
if any(term and term in path_lower for term in exclude_terms):
|
||||
return False
|
||||
|
||||
for term in include_terms:
|
||||
if term and term not in path_lower:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
|
||||
"""Sort paths by how well they satisfy the include tokens."""
|
||||
path_lower = relative_path.lower()
|
||||
prefix_hits = sum(
|
||||
1 for term in include_terms if term and path_lower.startswith(term)
|
||||
)
|
||||
match_positions = [
|
||||
path_lower.find(term)
|
||||
for term in include_terms
|
||||
if term and term in path_lower
|
||||
]
|
||||
first_match_index = min(match_positions) if match_positions else 0
|
||||
|
||||
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
|
||||
|
||||
async def search_relative_paths(
|
||||
self, search_term: str, limit: int = 15
|
||||
) -> List[str]:
|
||||
"""Search model relative file paths for autocomplete functionality"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
include_terms, exclude_terms = self._parse_search_tokens(search_term)
|
||||
|
||||
matching_paths = []
|
||||
search_lower = search_term.lower()
|
||||
|
||||
|
||||
# Get model roots for path calculation
|
||||
model_roots = self.scanner.get_model_roots()
|
||||
|
||||
|
||||
for model in cache.raw_data:
|
||||
file_path = model.get('file_path', '')
|
||||
file_path = model.get("file_path", "")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
|
||||
# Calculate relative path from model root
|
||||
relative_path = None
|
||||
for root in model_roots:
|
||||
# Normalize paths for comparison
|
||||
normalized_root = os.path.normpath(root)
|
||||
normalized_file = os.path.normpath(file_path)
|
||||
|
||||
|
||||
if normalized_file.startswith(normalized_root):
|
||||
# Remove root and leading separator to get relative path
|
||||
relative_path = normalized_file[len(normalized_root):].lstrip(os.sep)
|
||||
relative_path = normalized_file[len(normalized_root) :].lstrip(
|
||||
os.sep
|
||||
)
|
||||
break
|
||||
|
||||
if relative_path and search_lower in relative_path.lower():
|
||||
|
||||
if not relative_path:
|
||||
continue
|
||||
|
||||
relative_lower = relative_path.lower()
|
||||
if self._relative_path_matches_tokens(
|
||||
relative_lower, include_terms, exclude_terms
|
||||
):
|
||||
matching_paths.append(relative_path)
|
||||
|
||||
|
||||
if len(matching_paths) >= limit * 2: # Get more for better sorting
|
||||
break
|
||||
|
||||
# Sort by relevance (exact matches first, then by length)
|
||||
matching_paths.sort(key=lambda x: (
|
||||
not x.lower().startswith(search_lower), # Exact prefix matches first
|
||||
len(x), # Then by length (shorter first)
|
||||
x.lower() # Then alphabetically
|
||||
))
|
||||
|
||||
return matching_paths[:limit]
|
||||
|
||||
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
|
||||
matching_paths.sort(
|
||||
key=lambda relative: self._relative_path_sort_key(relative, include_terms)
|
||||
)
|
||||
|
||||
return matching_paths[:limit]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.models import CheckpointMetadata
|
||||
from ..config import config
|
||||
@@ -21,14 +21,35 @@ class CheckpointScanner(ModelScanner):
|
||||
hash_index=ModelHashIndex()
|
||||
)
|
||||
|
||||
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve the sub-type based on the root path."""
|
||||
if not root_path:
|
||||
return None
|
||||
|
||||
if config.checkpoints_roots and root_path in config.checkpoints_roots:
|
||||
return "checkpoint"
|
||||
|
||||
if config.unet_roots and root_path in config.unet_roots:
|
||||
return "diffusion_model"
|
||||
|
||||
return None
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
if hasattr(metadata, "model_type"):
|
||||
if root_path in config.checkpoints_roots:
|
||||
metadata.model_type = "checkpoint"
|
||||
elif root_path in config.unet_roots:
|
||||
metadata.model_type = "diffusion_model"
|
||||
"""Adjust metadata during scanning to set sub_type."""
|
||||
sub_type = self._resolve_sub_type(root_path)
|
||||
if sub_type:
|
||||
metadata.sub_type = sub_type
|
||||
return metadata
|
||||
|
||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Adjust entries loaded from the persisted cache to ensure sub_type is set."""
|
||||
sub_type = self._resolve_sub_type(
|
||||
self._find_root_for_file(entry.get("file_path"))
|
||||
)
|
||||
if sub_type:
|
||||
entry["sub_type"] = sub_type
|
||||
return entry
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get checkpoint root directories"""
|
||||
return config.base_models_roots
|
||||
return config.base_models_roots
|
||||
|
||||
@@ -11,16 +11,20 @@ logger = logging.getLogger(__name__)
|
||||
class CheckpointService(BaseModelService):
|
||||
"""Checkpoint-specific service implementation"""
|
||||
|
||||
def __init__(self, scanner):
|
||||
def __init__(self, scanner, update_service=None):
|
||||
"""Initialize Checkpoint service
|
||||
|
||||
Args:
|
||||
scanner: Checkpoint scanner instance
|
||||
update_service: Optional service for remote update tracking.
|
||||
"""
|
||||
super().__init__("checkpoint", scanner, CheckpointMetadata)
|
||||
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, checkpoint_data: Dict) -> Dict:
|
||||
"""Format Checkpoint data for API response"""
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = checkpoint_data.get("sub_type", "checkpoint")
|
||||
|
||||
return {
|
||||
"model_name": checkpoint_data["model_name"],
|
||||
"file_name": checkpoint_data["file_name"],
|
||||
@@ -34,9 +38,11 @@ class CheckpointService(BaseModelService):
|
||||
"modified": checkpoint_data.get("modified", ""),
|
||||
"tags": checkpoint_data.get("tags", []),
|
||||
"from_civitai": checkpoint_data.get("from_civitai", True),
|
||||
"usage_count": checkpoint_data.get("usage_count", 0),
|
||||
"notes": checkpoint_data.get("notes", ""),
|
||||
"model_type": checkpoint_data.get("model_type", "checkpoint"),
|
||||
"sub_type": sub_type,
|
||||
"favorite": checkpoint_data.get("favorite", False),
|
||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
@@ -46,4 +52,4 @@ class CheckpointService(BaseModelService):
|
||||
|
||||
def find_duplicate_filenames(self) -> Dict:
|
||||
"""Find Checkpoints with conflicting filenames"""
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
|
||||
431
py/services/civarchive_client.py
Normal file
431
py/services/civarchive_client.py
Normal file
@@ -0,0 +1,431 @@
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
from .model_metadata_provider import CivArchiveModelMetadataProvider, ModelMetadataProviderManager
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CivArchiveClient:
|
||||
_instance = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls):
|
||||
"""Get singleton instance of CivArchiveClient"""
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
|
||||
# Register this client as a metadata provider
|
||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||
provider_manager.register_provider('civarchive', CivArchiveModelMetadataProvider(cls._instance), False)
|
||||
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
# Check if already initialized for singleton pattern
|
||||
if hasattr(self, '_initialized'):
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
self.base_url = "https://civarchive.com/api"
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
path: str,
|
||||
params: Optional[Dict[str, str]] = None
|
||||
) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Call CivArchive API and return JSON payload"""
|
||||
success, payload = await self._make_request(path, params=params)
|
||||
if not success:
|
||||
error = payload if isinstance(payload, str) else "Request failed"
|
||||
return None, error
|
||||
if not isinstance(payload, dict):
|
||||
return None, "Invalid response structure"
|
||||
return payload, None
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[bool, Dict | str]:
|
||||
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
||||
|
||||
downloader = await get_downloader()
|
||||
kwargs: Dict[str, Dict[str, str]] = {}
|
||||
if params:
|
||||
safe_params = {str(key): str(value) for key, value in params.items() if value is not None}
|
||||
if safe_params:
|
||||
kwargs["params"] = safe_params
|
||||
|
||||
success, payload = await downloader.make_request(
|
||||
"GET",
|
||||
f"{self.base_url}{path}",
|
||||
use_auth=False,
|
||||
**kwargs,
|
||||
)
|
||||
if not success and isinstance(payload, RateLimitError):
|
||||
if payload.provider is None:
|
||||
payload.provider = "civarchive_api"
|
||||
raise payload
|
||||
return success, payload
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payload(payload: Dict) -> Dict:
|
||||
"""Unwrap CivArchive responses that wrap content under a data key"""
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _split_context(payload: Dict) -> Tuple[Dict, Dict, List[Dict]]:
|
||||
"""Separate version payload from surrounding model context"""
|
||||
data = CivArchiveClient._normalize_payload(payload)
|
||||
context: Dict = {}
|
||||
fallback_files: List[Dict] = []
|
||||
version: Dict = {}
|
||||
|
||||
for key, value in data.items():
|
||||
if key in {"version", "model"}:
|
||||
continue
|
||||
context[key] = value
|
||||
|
||||
if isinstance(data.get("version"), dict):
|
||||
version = data["version"]
|
||||
|
||||
model_block = data.get("model")
|
||||
if isinstance(model_block, dict):
|
||||
for key, value in model_block.items():
|
||||
if key == "version":
|
||||
if not version and isinstance(value, dict):
|
||||
version = value
|
||||
continue
|
||||
context.setdefault(key, value)
|
||||
fallback_files = fallback_files or model_block.get("files") or []
|
||||
|
||||
fallback_files = fallback_files or data.get("files") or []
|
||||
return context, version, fallback_files
|
||||
|
||||
@staticmethod
|
||||
def _ensure_list(value) -> List:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if value is None:
|
||||
return []
|
||||
return [value]
|
||||
|
||||
@staticmethod
|
||||
def _build_model_info(context: Dict) -> Dict:
|
||||
tags = context.get("tags")
|
||||
if not isinstance(tags, list):
|
||||
tags = list(tags) if isinstance(tags, (set, tuple)) else ([] if tags is None else [tags])
|
||||
return {
|
||||
"name": context.get("name"),
|
||||
"type": context.get("type"),
|
||||
"nsfw": bool(context.get("is_nsfw", context.get("nsfw", False))),
|
||||
"description": context.get("description"),
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_creator_info(context: Dict) -> Dict:
|
||||
username = context.get("creator_username") or context.get("username") or ""
|
||||
image = context.get("creator_image") or context.get("creator_avatar") or ""
|
||||
creator: Dict[str, Optional[str]] = {
|
||||
"username": username,
|
||||
"image": image,
|
||||
}
|
||||
if context.get("creator_name"):
|
||||
creator["name"] = context["creator_name"]
|
||||
if context.get("creator_url"):
|
||||
creator["url"] = context["creator_url"]
|
||||
return creator
|
||||
|
||||
@staticmethod
|
||||
def _transform_file_entry(file_data: Dict) -> Dict:
|
||||
mirrors = file_data.get("mirrors") or []
|
||||
if not isinstance(mirrors, list):
|
||||
mirrors = [mirrors]
|
||||
available_mirror = next(
|
||||
(mirror for mirror in mirrors if isinstance(mirror, dict) and mirror.get("deletedAt") is None),
|
||||
None
|
||||
)
|
||||
download_url = file_data.get("downloadUrl")
|
||||
if not download_url and available_mirror:
|
||||
download_url = available_mirror.get("url")
|
||||
name = file_data.get("name")
|
||||
if not name and available_mirror:
|
||||
name = available_mirror.get("filename")
|
||||
|
||||
transformed: Dict = {
|
||||
"id": file_data.get("id"),
|
||||
"sizeKB": file_data.get("sizeKB"),
|
||||
"name": name,
|
||||
"type": file_data.get("type"),
|
||||
"downloadUrl": download_url,
|
||||
"primary": True,
|
||||
# TODO: for some reason is_primary is false in CivArchive response, need to figure this out,
|
||||
# "primary": bool(file_data.get("is_primary", file_data.get("primary", False))),
|
||||
"mirrors": mirrors,
|
||||
}
|
||||
|
||||
sha256 = file_data.get("sha256")
|
||||
if sha256:
|
||||
transformed["hashes"] = {"SHA256": str(sha256).upper()}
|
||||
elif isinstance(file_data.get("hashes"), dict):
|
||||
transformed["hashes"] = file_data["hashes"]
|
||||
|
||||
if "metadata" in file_data:
|
||||
transformed["metadata"] = file_data["metadata"]
|
||||
|
||||
if file_data.get("modelVersionId") is not None:
|
||||
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
||||
elif file_data.get("model_version_id") is not None:
|
||||
transformed["modelVersionId"] = file_data.get("model_version_id")
|
||||
|
||||
if file_data.get("modelId") is not None:
|
||||
transformed["modelId"] = file_data.get("modelId")
|
||||
elif file_data.get("model_id") is not None:
|
||||
transformed["modelId"] = file_data.get("model_id")
|
||||
|
||||
return transformed
|
||||
|
||||
def _transform_files(
|
||||
self,
|
||||
files: Optional[List[Dict]],
|
||||
fallback_files: Optional[List[Dict]] = None
|
||||
) -> List[Dict]:
|
||||
candidates: List[Dict] = []
|
||||
if isinstance(files, list) and files:
|
||||
candidates = files
|
||||
elif isinstance(fallback_files, list):
|
||||
candidates = fallback_files
|
||||
|
||||
transformed_files: List[Dict] = []
|
||||
for file_data in candidates:
|
||||
if isinstance(file_data, dict):
|
||||
transformed_files.append(self._transform_file_entry(file_data))
|
||||
return transformed_files
|
||||
|
||||
def _transform_version(
|
||||
self,
|
||||
context: Dict,
|
||||
version: Dict,
|
||||
fallback_files: Optional[List[Dict]] = None
|
||||
) -> Optional[Dict]:
|
||||
if not version:
|
||||
return None
|
||||
|
||||
version_copy = deepcopy(version)
|
||||
version_copy.pop("model", None)
|
||||
version_copy.pop("creator", None)
|
||||
|
||||
if "trigger" in version_copy:
|
||||
triggers = version_copy.pop("trigger")
|
||||
if isinstance(triggers, list):
|
||||
version_copy["trainedWords"] = triggers
|
||||
elif triggers is None:
|
||||
version_copy["trainedWords"] = []
|
||||
else:
|
||||
version_copy["trainedWords"] = [triggers]
|
||||
|
||||
if "trainedWords" in version_copy and isinstance(version_copy["trainedWords"], str):
|
||||
version_copy["trainedWords"] = [version_copy["trainedWords"]]
|
||||
|
||||
if "nsfw_level" in version_copy:
|
||||
version_copy["nsfwLevel"] = version_copy.pop("nsfw_level")
|
||||
elif "nsfwLevel" not in version_copy and context.get("nsfw_level") is not None:
|
||||
version_copy["nsfwLevel"] = context.get("nsfw_level")
|
||||
|
||||
stats_keys = ["downloadCount", "ratingCount", "rating"]
|
||||
stats = {key: version_copy.pop(key) for key in stats_keys if key in version_copy}
|
||||
if stats:
|
||||
version_copy["stats"] = stats
|
||||
|
||||
version_copy["files"] = self._transform_files(version_copy.get("files"), fallback_files)
|
||||
version_copy["images"] = self._ensure_list(version_copy.get("images"))
|
||||
|
||||
version_copy["model"] = self._build_model_info(context)
|
||||
version_copy["creator"] = self._build_creator_info(context)
|
||||
|
||||
version_copy["source"] = "civarchive"
|
||||
version_copy["is_deleted"] = bool(context.get("deletedAt")) or bool(version.get("deletedAt"))
|
||||
|
||||
return version_copy
|
||||
|
||||
async def _resolve_version_from_files(self, payload: Dict) -> Optional[Dict]:
|
||||
"""Fallback to fetch version data when only file metadata is available"""
|
||||
data = self._normalize_payload(payload)
|
||||
files = data.get("files") or payload.get("files") or []
|
||||
if not isinstance(files, list):
|
||||
files = [files]
|
||||
for file_data in files:
|
||||
if not isinstance(file_data, dict):
|
||||
continue
|
||||
model_id = file_data.get("model_id") or file_data.get("modelId")
|
||||
version_id = file_data.get("model_version_id") or file_data.get("modelVersionId")
|
||||
if model_id is None or version_id is None:
|
||||
continue
|
||||
resolved = await self.get_model_version(model_id, version_id)
|
||||
if resolved:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Find model by SHA256 hash value using CivArchive API"""
|
||||
try:
|
||||
payload, error = await self._request_json(f"/sha256/{model_hash.lower()}")
|
||||
if error:
|
||||
if "not found" in error.lower():
|
||||
return None, "Model not found"
|
||||
return None, error
|
||||
|
||||
context, version_data, fallback_files = self._split_context(payload)
|
||||
transformed = self._transform_version(context, version_data, fallback_files)
|
||||
if transformed:
|
||||
return transformed, None
|
||||
|
||||
resolved = await self._resolve_version_from_files(payload)
|
||||
if resolved:
|
||||
return resolved, None
|
||||
|
||||
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||
return None, "No version data found"
|
||||
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
|
||||
return None, str(e)
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Get all versions of a model using CivArchive API"""
|
||||
try:
|
||||
payload, error = await self._request_json(f"/models/{model_id}")
|
||||
if error or payload is None:
|
||||
if error and "not found" in error.lower():
|
||||
return None
|
||||
logger.error(f"Error fetching CivArchive model versions for {model_id}: {error}")
|
||||
return None
|
||||
|
||||
data = self._normalize_payload(payload)
|
||||
context, version_data, fallback_files = self._split_context(payload)
|
||||
|
||||
versions_meta = data.get("versions") or []
|
||||
transformed_versions: List[Dict] = []
|
||||
for meta in versions_meta:
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
version_id = meta.get("id")
|
||||
if version_id is None:
|
||||
continue
|
||||
target_model_id = meta.get("modelId") or model_id
|
||||
version = await self.get_model_version(target_model_id, version_id)
|
||||
if version:
|
||||
transformed_versions.append(version)
|
||||
|
||||
# Ensure the primary version is included even if versions list was empty
|
||||
primary_version = self._transform_version(context, version_data, fallback_files)
|
||||
if primary_version:
|
||||
transformed_versions.insert(0, primary_version)
|
||||
|
||||
ordered_versions: List[Dict] = []
|
||||
seen_ids = set()
|
||||
for version in transformed_versions:
|
||||
version_id = version.get("id")
|
||||
if version_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(version_id)
|
||||
ordered_versions.append(version)
|
||||
|
||||
return {
|
||||
"modelVersions": ordered_versions,
|
||||
"type": context.get("type", ""),
|
||||
"name": context.get("name", ""),
|
||||
}
|
||||
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CivArchive model versions for {model_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
"""Get specific model version using CivArchive API
|
||||
|
||||
Args:
|
||||
model_id: The model ID (required)
|
||||
version_id: Optional specific version ID to filter to
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: The model version data or None if not found
|
||||
"""
|
||||
if model_id is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
params = {"modelVersionId": version_id} if version_id is not None else None
|
||||
payload, error = await self._request_json(f"/models/{model_id}", params=params)
|
||||
if error or payload is None:
|
||||
if error and "not found" in error.lower():
|
||||
return None
|
||||
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {error}")
|
||||
return None
|
||||
|
||||
context, version_data, fallback_files = self._split_context(payload)
|
||||
|
||||
if not version_data:
|
||||
return await self._resolve_version_from_files(payload)
|
||||
|
||||
if version_id is not None:
|
||||
raw_id = version_data.get("id")
|
||||
if raw_id != version_id:
|
||||
logger.warning(
|
||||
"Requested version %s doesn't match default version %s for model %s",
|
||||
version_id,
|
||||
raw_id,
|
||||
model_id,
|
||||
)
|
||||
return None
|
||||
actual_model_id = version_data.get("modelId")
|
||||
context_model_id = context.get("id")
|
||||
# CivArchive can respond with data for a different model id while already
|
||||
# returning the fully resolved model context. Only follow the redirect when
|
||||
# the context itself still points to the original (wrong) model.
|
||||
if (
|
||||
actual_model_id is not None
|
||||
and str(actual_model_id) != str(model_id)
|
||||
and (context_model_id is None or str(context_model_id) != str(actual_model_id))
|
||||
):
|
||||
return await self.get_model_version(actual_model_id, version_id)
|
||||
|
||||
return self._transform_version(context, version_data, fallback_files)
|
||||
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
""" Fetch model version metadata using a known bogus model lookup
|
||||
CivArchive lacks a direct version lookup API, this uses a workaround (which we handle in the main model request now)
|
||||
|
||||
Args:
|
||||
version_id: The model version ID
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[Dict], Optional[str]]: (version_data, error_message)
|
||||
"""
|
||||
version = await self.get_model_version(1, version_id)
|
||||
if version is None:
|
||||
return None, "Model not found"
|
||||
return version, None
|
||||
@@ -1,10 +1,12 @@
|
||||
import os
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
import os
|
||||
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
||||
from .model_metadata_provider import CivitaiModelMetadataProvider, ModelMetadataProviderManager
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
from ..utils.civitai_utils import resolve_license_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,6 +35,29 @@ class CivitaiClient:
|
||||
|
||||
self.base_url = "https://civitai.com/api/v1"
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
use_auth: bool = False,
|
||||
**kwargs,
|
||||
) -> Tuple[bool, Dict | str]:
|
||||
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
||||
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.make_request(
|
||||
method,
|
||||
url,
|
||||
use_auth=use_auth,
|
||||
**kwargs,
|
||||
)
|
||||
if not success and isinstance(result, RateLimitError):
|
||||
if result.provider is None:
|
||||
result.provider = "civitai_api"
|
||||
raise result
|
||||
return success, result
|
||||
|
||||
@staticmethod
|
||||
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
|
||||
"""Remove Comfy-specific metadata from model version images."""
|
||||
@@ -79,43 +104,32 @@ class CivitaiClient:
|
||||
|
||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.make_request(
|
||||
success, version = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True
|
||||
)
|
||||
if success:
|
||||
# Get model ID from version data
|
||||
model_id = result.get('modelId')
|
||||
if model_id:
|
||||
# Fetch additional model metadata
|
||||
success_model, data = await downloader.make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if success_model:
|
||||
# Enrich version_info with model data
|
||||
result['model']['description'] = data.get("description")
|
||||
result['model']['tags'] = data.get("tags", [])
|
||||
if not success:
|
||||
message = str(version)
|
||||
if "not found" in message.lower():
|
||||
return None, "Model not found"
|
||||
|
||||
# Add creator from model data
|
||||
result['creator'] = data.get("creator")
|
||||
logger.error("Failed to fetch model info for %s: %s", model_hash[:10], message)
|
||||
return None, message
|
||||
|
||||
self._remove_comfy_metadata(result)
|
||||
return result, None
|
||||
|
||||
# Handle specific error cases
|
||||
if "not found" in str(result):
|
||||
return None, "Model not found"
|
||||
|
||||
# Other error cases
|
||||
logger.error(f"Failed to fetch model info for {model_hash[:10]}: {result}")
|
||||
return None, str(result)
|
||||
except Exception as e:
|
||||
logger.error(f"API Error: {str(e)}")
|
||||
return None, str(e)
|
||||
model_id = version.get('modelId')
|
||||
if model_id:
|
||||
model_data = await self._fetch_model_data(model_id)
|
||||
if model_data:
|
||||
self._enrich_version_with_model_data(version, model_data)
|
||||
|
||||
self._remove_comfy_metadata(version)
|
||||
return version, None
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("API Error: %s", exc)
|
||||
return None, str(exc)
|
||||
|
||||
async def download_preview_image(self, image_url: str, save_path: str):
|
||||
try:
|
||||
@@ -135,11 +149,32 @@ class CivitaiClient:
|
||||
logger.error(f"Download Error: {str(e)}")
|
||||
return False
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> List[Dict]:
|
||||
@staticmethod
|
||||
def _extract_error_message(payload: Any) -> str:
|
||||
"""Return a human-readable error message from an API payload."""
|
||||
|
||||
def _from_value(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
for key in ("message", "error", "detail", "details"):
|
||||
if key in value:
|
||||
candidate = _from_value(value[key])
|
||||
if candidate:
|
||||
return candidate
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
candidate = _from_value(item)
|
||||
if candidate:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
return _from_value(payload)
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.make_request(
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
@@ -151,147 +186,237 @@ class CivitaiClient:
|
||||
'type': result.get('type', ''),
|
||||
'name': result.get('name', '')
|
||||
}
|
||||
message = self._extract_error_message(result)
|
||||
if message and 'not found' in message.lower():
|
||||
raise ResourceNotFoundError(f"Resource not found for model {model_id}")
|
||||
if message:
|
||||
raise RuntimeError(message)
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
except ResourceNotFoundError as exc:
|
||||
logger.info("Model %s is no longer available on Civitai: %s", model_id, exc)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model versions: {e}")
|
||||
logger.error("Error fetching model versions: %s", e, exc_info=True)
|
||||
raise
|
||||
|
||||
async def get_model_versions_bulk(
|
||||
self, model_ids: Sequence[int]
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
"""Fetch model metadata for multiple ids using the batch API."""
|
||||
|
||||
deduped: Dict[int, None] = {}
|
||||
for raw_id in model_ids:
|
||||
try:
|
||||
normalized = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
deduped.setdefault(normalized, None)
|
||||
|
||||
normalized_ids = [str(model_id) for model_id in deduped.keys()]
|
||||
if not normalized_ids:
|
||||
return {}
|
||||
|
||||
try:
|
||||
query = ",".join(normalized_ids)
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params={'ids': query},
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
|
||||
items = result.get('items') if isinstance(result, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return {}
|
||||
|
||||
payload: Dict[int, Dict] = {}
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
model_id = item.get('id')
|
||||
try:
|
||||
normalized_id = int(model_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
payload[normalized_id] = {
|
||||
'modelVersions': item.get('modelVersions', []),
|
||||
'type': item.get('type', ''),
|
||||
'name': item.get('name', ''),
|
||||
'allowNoCredit': item.get('allowNoCredit'),
|
||||
'allowCommercialUse': item.get('allowCommercialUse'),
|
||||
'allowDerivatives': item.get('allowDerivatives'),
|
||||
'allowDifferentLicense': item.get('allowDifferentLicense'),
|
||||
}
|
||||
return payload
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error fetching model versions in bulk: {exc}")
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
"""Get specific model version with additional metadata
|
||||
|
||||
Args:
|
||||
model_id: The Civitai model ID (optional if version_id is provided)
|
||||
version_id: Optional specific version ID to retrieve
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: The model version data with additional fields or None if not found
|
||||
"""
|
||||
"""Get specific model version with additional metadata."""
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
|
||||
# Case 1: Only version_id is provided
|
||||
if model_id is None and version_id is not None:
|
||||
# First get the version info to extract model_id
|
||||
success, version = await downloader.make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/{version_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
|
||||
model_id = version.get('modelId')
|
||||
if not model_id:
|
||||
logger.error(f"No modelId found in version {version_id}")
|
||||
return None
|
||||
|
||||
# Now get the model data for additional metadata
|
||||
success, model_data = await downloader.make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if success:
|
||||
# Enrich version with model data
|
||||
version['model']['description'] = model_data.get("description")
|
||||
version['model']['tags'] = model_data.get("tags", [])
|
||||
version['creator'] = model_data.get("creator")
|
||||
return await self._get_version_by_id_only(version_id)
|
||||
|
||||
self._remove_comfy_metadata(version)
|
||||
return version
|
||||
|
||||
# Case 2: model_id is provided (with or without version_id)
|
||||
elif model_id is not None:
|
||||
# Step 1: Get model data to find version_id if not provided and get additional metadata
|
||||
success, data = await downloader.make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
if model_id is not None:
|
||||
return await self._get_version_with_model_id(model_id, version_id)
|
||||
|
||||
model_versions = data.get('modelVersions', [])
|
||||
if not model_versions:
|
||||
logger.warning(f"No model versions found for model {model_id}")
|
||||
return None
|
||||
logger.error("Either model_id or version_id must be provided")
|
||||
return None
|
||||
|
||||
# Step 2: Determine the target version entry to use
|
||||
target_version = None
|
||||
if version_id is not None:
|
||||
target_version = next(
|
||||
(item for item in model_versions if item.get('id') == version_id),
|
||||
None
|
||||
)
|
||||
if target_version is None:
|
||||
logger.warning(
|
||||
f"Version {version_id} not found for model {model_id}, defaulting to first version"
|
||||
)
|
||||
if target_version is None:
|
||||
target_version = model_versions[0]
|
||||
|
||||
target_version_id = target_version.get('id')
|
||||
|
||||
# Step 3: Get detailed version info using the SHA256 hash
|
||||
model_hash = None
|
||||
for file_info in target_version.get('files', []):
|
||||
if file_info.get('type') == 'Model' and file_info.get('primary'):
|
||||
model_hash = file_info.get('hashes', {}).get('SHA256')
|
||||
if model_hash:
|
||||
break
|
||||
|
||||
version = None
|
||||
if model_hash:
|
||||
success, version = await downloader.make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True
|
||||
)
|
||||
if not success:
|
||||
logger.warning(
|
||||
f"Failed to fetch version by hash for model {model_id} version {target_version_id}: {version}"
|
||||
)
|
||||
version = None
|
||||
else:
|
||||
logger.warning(
|
||||
f"No primary model hash found for model {model_id} version {target_version_id}"
|
||||
)
|
||||
|
||||
if version is None:
|
||||
version = copy.deepcopy(target_version)
|
||||
version.pop('index', None)
|
||||
version['modelId'] = model_id
|
||||
version['model'] = {
|
||||
'name': data.get('name'),
|
||||
'type': data.get('type'),
|
||||
'nsfw': data.get('nsfw'),
|
||||
'poi': data.get('poi')
|
||||
}
|
||||
|
||||
# Step 4: Enrich version_info with model data
|
||||
# Add description and tags from model data
|
||||
model_info = version.get('model')
|
||||
if not isinstance(model_info, dict):
|
||||
model_info = {}
|
||||
version['model'] = model_info
|
||||
model_info['description'] = data.get("description")
|
||||
model_info['tags'] = data.get("tags", [])
|
||||
|
||||
# Add creator from model data
|
||||
version['creator'] = data.get("creator")
|
||||
|
||||
self._remove_comfy_metadata(version)
|
||||
return version
|
||||
|
||||
# Case 3: Neither model_id nor version_id provided
|
||||
else:
|
||||
logger.error("Either model_id or version_id must be provided")
|
||||
return None
|
||||
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model version: {e}")
|
||||
return None
|
||||
|
||||
async def _get_version_by_id_only(self, version_id: int) -> Optional[Dict]:
|
||||
version = await self._fetch_version_by_id(version_id)
|
||||
if version is None:
|
||||
return None
|
||||
|
||||
model_id = version.get('modelId')
|
||||
if not model_id:
|
||||
logger.error(f"No modelId found in version {version_id}")
|
||||
return None
|
||||
|
||||
model_data = await self._fetch_model_data(model_id)
|
||||
if model_data:
|
||||
self._enrich_version_with_model_data(version, model_data)
|
||||
|
||||
self._remove_comfy_metadata(version)
|
||||
return version
|
||||
|
||||
async def _get_version_with_model_id(self, model_id: int, version_id: Optional[int]) -> Optional[Dict]:
|
||||
model_data = await self._fetch_model_data(model_id)
|
||||
if not model_data:
|
||||
return None
|
||||
|
||||
target_version = self._select_target_version(model_data, model_id, version_id)
|
||||
if target_version is None:
|
||||
return None
|
||||
|
||||
target_version_id = target_version.get('id')
|
||||
version = await self._fetch_version_by_id(target_version_id) if target_version_id else None
|
||||
|
||||
if version is None:
|
||||
model_hash = self._extract_primary_model_hash(target_version)
|
||||
if model_hash:
|
||||
version = await self._fetch_version_by_hash(model_hash)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No primary model hash found for model {model_id} version {target_version_id}"
|
||||
)
|
||||
|
||||
if version is None:
|
||||
version = self._build_version_from_model_data(target_version, model_id, model_data)
|
||||
|
||||
self._enrich_version_with_model_data(version, model_data)
|
||||
self._remove_comfy_metadata(version)
|
||||
return version
|
||||
|
||||
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
||||
success, data = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if success:
|
||||
return data
|
||||
logger.warning(f"Failed to fetch model data for model {model_id}")
|
||||
return None
|
||||
|
||||
async def _fetch_version_by_id(self, version_id: Optional[int]) -> Optional[Dict]:
|
||||
if version_id is None:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/{version_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
|
||||
logger.warning(f"Failed to fetch version by id {version_id}")
|
||||
return None
|
||||
|
||||
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict]:
|
||||
if not model_hash:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
|
||||
logger.warning(f"Failed to fetch version by hash {model_hash}")
|
||||
return None
|
||||
|
||||
def _select_target_version(self, model_data: Dict, model_id: int, version_id: Optional[int]) -> Optional[Dict]:
|
||||
model_versions = model_data.get('modelVersions', [])
|
||||
if not model_versions:
|
||||
logger.warning(f"No model versions found for model {model_id}")
|
||||
return None
|
||||
|
||||
if version_id is not None:
|
||||
target_version = next(
|
||||
(item for item in model_versions if item.get('id') == version_id),
|
||||
None
|
||||
)
|
||||
if target_version is None:
|
||||
logger.warning(
|
||||
f"Version {version_id} not found for model {model_id}, defaulting to first version"
|
||||
)
|
||||
return model_versions[0]
|
||||
return target_version
|
||||
|
||||
return model_versions[0]
|
||||
|
||||
def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]:
|
||||
for file_info in version_entry.get('files', []):
|
||||
if file_info.get('type') == 'Model' and file_info.get('primary'):
|
||||
hashes = file_info.get('hashes', {})
|
||||
model_hash = hashes.get('SHA256')
|
||||
if model_hash:
|
||||
return model_hash
|
||||
return None
|
||||
|
||||
def _build_version_from_model_data(self, version_entry: Dict, model_id: int, model_data: Dict) -> Dict:
|
||||
version = copy.deepcopy(version_entry)
|
||||
version.pop('index', None)
|
||||
version['modelId'] = model_id
|
||||
version['model'] = {
|
||||
'name': model_data.get('name'),
|
||||
'type': model_data.get('type'),
|
||||
'nsfw': model_data.get('nsfw'),
|
||||
'poi': model_data.get('poi')
|
||||
}
|
||||
return version
|
||||
|
||||
def _enrich_version_with_model_data(self, version: Dict, model_data: Dict) -> None:
|
||||
model_info = version.get('model')
|
||||
if not isinstance(model_info, dict):
|
||||
model_info = {}
|
||||
version['model'] = model_info
|
||||
|
||||
model_info['description'] = model_data.get("description")
|
||||
model_info['tags'] = model_data.get("tags", [])
|
||||
version['creator'] = model_data.get("creator")
|
||||
|
||||
license_payload = resolve_license_payload(model_data)
|
||||
for field, value in license_payload.items():
|
||||
model_info[field] = value
|
||||
|
||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Fetch model version metadata from Civitai
|
||||
|
||||
@@ -304,11 +429,10 @@ class CivitaiClient:
|
||||
- An error message if there was an error, or None on success
|
||||
"""
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
url = f"{self.base_url}/model-versions/{version_id}"
|
||||
|
||||
logger.debug(f"Resolving DNS for model version info: {url}")
|
||||
success, result = await downloader.make_request(
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
url,
|
||||
use_auth=True
|
||||
@@ -328,6 +452,8 @@ class CivitaiClient:
|
||||
# Other error cases
|
||||
logger.error(f"Failed to fetch model info for {version_id}: {result}")
|
||||
return None, str(result)
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"Error fetching model version info: {e}"
|
||||
logger.error(error_msg)
|
||||
@@ -335,7 +461,7 @@ class CivitaiClient:
|
||||
|
||||
async def get_image_info(self, image_id: str) -> Optional[Dict]:
|
||||
"""Fetch image information from Civitai API
|
||||
|
||||
|
||||
Args:
|
||||
image_id: The Civitai image ID
|
||||
|
||||
@@ -343,11 +469,10 @@ class CivitaiClient:
|
||||
Optional[Dict]: The image data or None if not found
|
||||
"""
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
|
||||
logger.debug(f"Fetching image info for ID: {image_id}")
|
||||
success, result = await downloader.make_request(
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
url,
|
||||
use_auth=True
|
||||
@@ -362,7 +487,44 @@ class CivitaiClient:
|
||||
|
||||
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"Error fetching image info: {e}"
|
||||
logger.error(error_msg)
|
||||
return None
|
||||
|
||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||
"""Fetch all models for a specific Civitai user."""
|
||||
if not username:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/models?username={username}"
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
url,
|
||||
use_auth=True
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error("Failed to fetch models for %s: %s", username, result)
|
||||
return None
|
||||
|
||||
items = result.get("items") if isinstance(result, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
|
||||
for model in items:
|
||||
versions = model.get("modelVersions")
|
||||
if not isinstance(versions, list):
|
||||
continue
|
||||
for version in versions:
|
||||
self._remove_comfy_metadata(version)
|
||||
|
||||
return items
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Error fetching models for %s: %s", username, exc)
|
||||
return None
|
||||
|
||||
91
py/services/custom_words_service.py
Normal file
91
py/services/custom_words_service.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Service for managing autocomplete via TagFTSIndex.
|
||||
|
||||
This service provides full-text search capabilities for Danbooru/e621 tags
|
||||
with category filtering and enriched results including post counts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomWordsService:
|
||||
"""Service for autocomplete via TagFTSIndex.
|
||||
|
||||
This service:
|
||||
- Uses TagFTSIndex for fast full-text search of Danbooru/e621 tags
|
||||
- Supports category-based filtering
|
||||
- Returns enriched results with category and post_count
|
||||
- Provides sub-100ms search times for 221k+ tags
|
||||
"""
|
||||
|
||||
_instance: Optional[CustomWordsService] = None
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls) -> CustomWordsService:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._tag_index: Optional[Any] = None
|
||||
self._initialized = True
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> CustomWordsService:
|
||||
"""Get the singleton instance of CustomWordsService."""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def _get_tag_index(self):
|
||||
"""Get or create the TagFTSIndex instance (lazy initialization)."""
|
||||
if self._tag_index is None:
|
||||
try:
|
||||
from .tag_fts_index import get_tag_fts_index
|
||||
self._tag_index = get_tag_fts_index()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TagFTSIndex: {e}")
|
||||
self._tag_index = None
|
||||
return self._tag_index
|
||||
|
||||
def search_words(
|
||||
self,
|
||||
search_term: str,
|
||||
limit: int = 20,
|
||||
categories: Optional[List[int]] = None,
|
||||
enriched: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search tags using TagFTSIndex with category filtering.
|
||||
|
||||
Args:
|
||||
search_term: The search term to match against.
|
||||
limit: Maximum number of results to return.
|
||||
categories: Optional list of category IDs to filter by.
|
||||
enriched: If True, always return enriched results with category
|
||||
and post_count (default behavior now).
|
||||
|
||||
Returns:
|
||||
List of dicts with tag_name, category, and post_count.
|
||||
"""
|
||||
tag_index = self._get_tag_index()
|
||||
if tag_index is not None:
|
||||
results = tag_index.search(search_term, categories=categories, limit=limit)
|
||||
return results
|
||||
|
||||
logger.debug("TagFTSIndex not available, returning empty results")
|
||||
return []
|
||||
|
||||
|
||||
def get_custom_words_service() -> CustomWordsService:
|
||||
"""Factory function to get the CustomWordsService singleton."""
|
||||
return CustomWordsService.get_instance()
|
||||
|
||||
|
||||
__all__ = ["CustomWordsService", "get_custom_words_service"]
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from .downloader import DownloadProgress
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,14 +31,40 @@ class DownloadCoordinator:
|
||||
download_id = payload.get("download_id") or self._ws_manager.generate_download_id()
|
||||
payload.setdefault("download_id", download_id)
|
||||
|
||||
async def progress_callback(progress: Any) -> None:
|
||||
async def progress_callback(progress: Any, snapshot: Optional[DownloadProgress] = None) -> None:
|
||||
percent = 0.0
|
||||
metrics: Optional[DownloadProgress] = None
|
||||
|
||||
if isinstance(progress, DownloadProgress):
|
||||
metrics = progress
|
||||
percent = progress.percent_complete
|
||||
elif isinstance(snapshot, DownloadProgress):
|
||||
metrics = snapshot
|
||||
percent = snapshot.percent_complete
|
||||
else:
|
||||
try:
|
||||
percent = float(progress)
|
||||
except (TypeError, ValueError):
|
||||
percent = 0.0
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"status": "progress",
|
||||
"progress": round(percent),
|
||||
"download_id": download_id,
|
||||
}
|
||||
|
||||
if metrics is not None:
|
||||
payload.update(
|
||||
{
|
||||
"bytes_downloaded": metrics.bytes_downloaded,
|
||||
"total_bytes": metrics.total_bytes,
|
||||
"bytes_per_second": metrics.bytes_per_second,
|
||||
}
|
||||
)
|
||||
|
||||
await self._ws_manager.broadcast_download_progress(
|
||||
download_id,
|
||||
{
|
||||
"status": "progress",
|
||||
"progress": progress,
|
||||
"download_id": download_id,
|
||||
},
|
||||
payload,
|
||||
)
|
||||
|
||||
model_id = self._parse_optional_int(payload.get("model_id"), "model_id")
|
||||
@@ -81,6 +109,56 @@ class DownloadCoordinator:
|
||||
|
||||
return result
|
||||
|
||||
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||
"""Pause an active download and notify listeners."""
|
||||
|
||||
download_manager = await self._download_manager_factory()
|
||||
result = await download_manager.pause_download(download_id)
|
||||
|
||||
if result.get("success"):
|
||||
cached_progress = self._ws_manager.get_download_progress(download_id) or {}
|
||||
payload: Dict[str, Any] = {
|
||||
"status": "paused",
|
||||
"progress": cached_progress.get("progress", 0),
|
||||
"download_id": download_id,
|
||||
"message": "Download paused by user",
|
||||
}
|
||||
|
||||
for field in ("bytes_downloaded", "total_bytes", "bytes_per_second"):
|
||||
if field in cached_progress:
|
||||
payload[field] = cached_progress[field]
|
||||
|
||||
payload["bytes_per_second"] = 0.0
|
||||
|
||||
await self._ws_manager.broadcast_download_progress(download_id, payload)
|
||||
|
||||
return result
|
||||
|
||||
async def resume_download(self, download_id: str) -> Dict[str, Any]:
|
||||
"""Resume a paused download and notify listeners."""
|
||||
|
||||
download_manager = await self._download_manager_factory()
|
||||
result = await download_manager.resume_download(download_id)
|
||||
|
||||
if result.get("success"):
|
||||
cached_progress = self._ws_manager.get_download_progress(download_id) or {}
|
||||
payload: Dict[str, Any] = {
|
||||
"status": "downloading",
|
||||
"progress": cached_progress.get("progress", 0),
|
||||
"download_id": download_id,
|
||||
"message": "Download resumed by user",
|
||||
}
|
||||
|
||||
for field in ("bytes_downloaded", "total_bytes"):
|
||||
if field in cached_progress:
|
||||
payload[field] = cached_progress[field]
|
||||
|
||||
payload["bytes_per_second"] = cached_progress.get("bytes_per_second", 0.0)
|
||||
|
||||
await self._ws_manager.broadcast_download_progress(download_id, payload)
|
||||
|
||||
return result
|
||||
|
||||
async def list_active_downloads(self) -> Dict[str, Any]:
|
||||
"""Return the active download map from the underlying manager."""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,13 +14,95 @@ import os
|
||||
import logging
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Tuple, Callable, Union
|
||||
from ..services.settings_manager import settings
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from .errors import RateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadProgress:
|
||||
"""Snapshot of a download transfer at a moment in time."""
|
||||
|
||||
percent_complete: float
|
||||
bytes_downloaded: int
|
||||
total_bytes: Optional[int]
|
||||
bytes_per_second: float
|
||||
timestamp: float
|
||||
|
||||
|
||||
class DownloadStreamControl:
|
||||
"""Synchronize pause/resume requests and reconnect hints for a download."""
|
||||
|
||||
def __init__(self, *, stall_timeout: Optional[float] = None) -> None:
|
||||
self._event = asyncio.Event()
|
||||
self._event.set()
|
||||
self._reconnect_requested = False
|
||||
self.last_progress_timestamp: Optional[float] = None
|
||||
self.stall_timeout: float = float(stall_timeout) if stall_timeout is not None else 120.0
|
||||
|
||||
def is_set(self) -> bool:
|
||||
return self._event.is_set()
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
return not self._event.is_set()
|
||||
|
||||
def set(self) -> None:
|
||||
self._event.set()
|
||||
|
||||
def clear(self) -> None:
|
||||
self._event.clear()
|
||||
|
||||
async def wait(self) -> None:
|
||||
await self._event.wait()
|
||||
|
||||
def pause(self) -> None:
|
||||
self.clear()
|
||||
|
||||
def resume(self, *, force_reconnect: bool = False) -> None:
|
||||
if force_reconnect:
|
||||
self._reconnect_requested = True
|
||||
self.set()
|
||||
|
||||
def request_reconnect(self) -> None:
|
||||
self._reconnect_requested = True
|
||||
self.set()
|
||||
|
||||
def has_reconnect_request(self) -> bool:
|
||||
return self._reconnect_requested
|
||||
|
||||
def consume_reconnect_request(self) -> bool:
|
||||
reconnect = self._reconnect_requested
|
||||
self._reconnect_requested = False
|
||||
return reconnect
|
||||
|
||||
def mark_progress(self, timestamp: Optional[float] = None) -> None:
|
||||
self.last_progress_timestamp = timestamp or datetime.now().timestamp()
|
||||
self._reconnect_requested = False
|
||||
|
||||
def time_since_last_progress(self, *, now: Optional[float] = None) -> Optional[float]:
|
||||
if self.last_progress_timestamp is None:
|
||||
return None
|
||||
reference = now if now is not None else datetime.now().timestamp()
|
||||
return max(0.0, reference - self.last_progress_timestamp)
|
||||
|
||||
def update_stall_timeout(self, stall_timeout: float) -> None:
|
||||
self.stall_timeout = float(stall_timeout)
|
||||
|
||||
|
||||
class DownloadRestartRequested(Exception):
|
||||
"""Raised when a caller explicitly requests a fresh HTTP stream."""
|
||||
|
||||
|
||||
class DownloadStalledError(Exception):
|
||||
"""Raised when download progress stalls beyond the configured timeout."""
|
||||
|
||||
|
||||
class Downloader:
|
||||
"""Unified downloader for all HTTP/HTTPS downloads in the application."""
|
||||
|
||||
@@ -46,32 +128,64 @@ class Downloader:
|
||||
self._session = None
|
||||
self._session_created_at = None
|
||||
self._proxy_url = None # Store proxy URL for current session
|
||||
self._session_lock = asyncio.Lock()
|
||||
|
||||
# Configuration
|
||||
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput
|
||||
self.max_retries = 5
|
||||
self.base_delay = 2.0 # Base delay for exponential backoff
|
||||
self.session_timeout = 300 # 5 minutes
|
||||
self.stall_timeout = self._resolve_stall_timeout()
|
||||
|
||||
# Default headers
|
||||
self.default_headers = {
|
||||
'User-Agent': 'ComfyUI-LoRA-Manager/1.0'
|
||||
'User-Agent': 'ComfyUI-LoRA-Manager/1.0',
|
||||
# Explicitly request uncompressed payloads so aiohttp doesn't need optional
|
||||
# decoders (e.g. zstandard) that may be missing in runtime environments.
|
||||
'Accept-Encoding': 'identity',
|
||||
}
|
||||
|
||||
@property
|
||||
async def session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create the global aiohttp session with optimized settings"""
|
||||
if self._session is None or self._should_refresh_session():
|
||||
await self._create_session()
|
||||
async with self._session_lock:
|
||||
# Double check after acquiring lock
|
||||
if self._session is None or self._should_refresh_session():
|
||||
await self._create_session()
|
||||
return self._session
|
||||
|
||||
|
||||
@property
|
||||
def proxy_url(self) -> Optional[str]:
|
||||
"""Get the current proxy URL (initialize if needed)"""
|
||||
if not hasattr(self, '_proxy_url'):
|
||||
self._proxy_url = None
|
||||
return self._proxy_url
|
||||
|
||||
|
||||
def _resolve_stall_timeout(self) -> float:
|
||||
"""Determine the stall timeout from settings or environment."""
|
||||
default_timeout = 120.0
|
||||
settings_timeout = None
|
||||
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
settings_timeout = settings_manager.get('download_stall_timeout_seconds')
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.debug("Failed to read stall timeout from settings: %s", exc)
|
||||
|
||||
raw_value = (
|
||||
settings_timeout
|
||||
if settings_timeout not in (None, "")
|
||||
else os.environ.get('COMFYUI_DOWNLOAD_STALL_TIMEOUT')
|
||||
)
|
||||
|
||||
try:
|
||||
timeout_value = float(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
timeout_value = default_timeout
|
||||
|
||||
return max(30.0, timeout_value)
|
||||
|
||||
def _should_refresh_session(self) -> bool:
|
||||
"""Check if session should be refreshed"""
|
||||
if self._session is None:
|
||||
@@ -87,19 +201,28 @@ class Downloader:
|
||||
return False
|
||||
|
||||
async def _create_session(self):
|
||||
"""Create a new aiohttp session with optimized settings"""
|
||||
"""Create a new aiohttp session with optimized settings.
|
||||
|
||||
Note: This is private and caller MUST hold self._session_lock.
|
||||
"""
|
||||
# Close existing session if any
|
||||
if self._session is not None:
|
||||
await self._session.close()
|
||||
try:
|
||||
await self._session.close()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(f"Error closing previous session: {e}")
|
||||
finally:
|
||||
self._session = None
|
||||
|
||||
# Check for app-level proxy settings
|
||||
proxy_url = None
|
||||
if settings.get('proxy_enabled', False):
|
||||
proxy_host = settings.get('proxy_host', '').strip()
|
||||
proxy_port = settings.get('proxy_port', '').strip()
|
||||
proxy_type = settings.get('proxy_type', 'http').lower()
|
||||
proxy_username = settings.get('proxy_username', '').strip()
|
||||
proxy_password = settings.get('proxy_password', '').strip()
|
||||
settings_manager = get_settings_manager()
|
||||
if settings_manager.get('proxy_enabled', False):
|
||||
proxy_host = settings_manager.get('proxy_host', '').strip()
|
||||
proxy_port = settings_manager.get('proxy_port', '').strip()
|
||||
proxy_type = settings_manager.get('proxy_type', 'http').lower()
|
||||
proxy_username = settings_manager.get('proxy_username', '').strip()
|
||||
proxy_password = settings_manager.get('proxy_password', '').strip()
|
||||
|
||||
if proxy_host and proxy_port:
|
||||
# Build proxy URL
|
||||
@@ -146,7 +269,8 @@ class Downloader:
|
||||
|
||||
if use_auth:
|
||||
# Add CivitAI API key if available
|
||||
api_key = settings.get('civitai_api_key')
|
||||
settings_manager = get_settings_manager()
|
||||
api_key = settings_manager.get('civitai_api_key')
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
headers['Content-Type'] = 'application/json'
|
||||
@@ -157,10 +281,11 @@ class Downloader:
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
progress_callback: Optional[Callable[..., Awaitable[None]]] = None,
|
||||
use_auth: bool = False,
|
||||
custom_headers: Optional[Dict[str, str]] = None,
|
||||
allow_resume: bool = True
|
||||
allow_resume: bool = True,
|
||||
pause_event: Optional[DownloadStreamControl] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Download a file with resumable downloads and retry mechanism
|
||||
@@ -172,6 +297,7 @@ class Downloader:
|
||||
use_auth: Whether to include authentication headers (e.g., CivitAI API key)
|
||||
custom_headers: Additional headers to include in request
|
||||
allow_resume: Whether to support resumable downloads
|
||||
pause_event: Optional stream control used to pause/resume and request reconnects
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (success, save_path or error message)
|
||||
@@ -246,7 +372,16 @@ class Downloader:
|
||||
if allow_resume:
|
||||
os.rename(part_path, save_path)
|
||||
if progress_callback:
|
||||
await progress_callback(100)
|
||||
await self._dispatch_progress_callback(
|
||||
progress_callback,
|
||||
DownloadProgress(
|
||||
percent_complete=100.0,
|
||||
bytes_downloaded=part_size,
|
||||
total_bytes=actual_size,
|
||||
bytes_per_second=0.0,
|
||||
timestamp=datetime.now().timestamp(),
|
||||
),
|
||||
)
|
||||
return True, save_path
|
||||
# Remove corrupted part file and restart
|
||||
os.remove(part_path)
|
||||
@@ -274,36 +409,146 @@ class Downloader:
|
||||
|
||||
current_size = resume_offset
|
||||
last_progress_report_time = datetime.now()
|
||||
|
||||
progress_samples: deque[tuple[datetime, int]] = deque()
|
||||
progress_samples.append((last_progress_report_time, current_size))
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
|
||||
|
||||
# Stream download to file with progress updates
|
||||
loop = asyncio.get_running_loop()
|
||||
mode = 'ab' if (allow_resume and resume_offset > 0) else 'wb'
|
||||
control = pause_event
|
||||
|
||||
if control is not None:
|
||||
control.update_stall_timeout(self.stall_timeout)
|
||||
|
||||
with open(part_path, mode) as f:
|
||||
async for chunk in response.content.iter_chunked(self.chunk_size):
|
||||
if chunk:
|
||||
# Run blocking file write in executor
|
||||
await loop.run_in_executor(None, f.write, chunk)
|
||||
current_size += len(chunk)
|
||||
|
||||
# Limit progress update frequency to reduce overhead
|
||||
now = datetime.now()
|
||||
time_diff = (now - last_progress_report_time).total_seconds()
|
||||
|
||||
if progress_callback and total_size and time_diff >= 1.0:
|
||||
progress = (current_size / total_size) * 100
|
||||
await progress_callback(progress)
|
||||
last_progress_report_time = now
|
||||
while True:
|
||||
active_stall_timeout = control.stall_timeout if control else self.stall_timeout
|
||||
|
||||
if control is not None:
|
||||
if control.is_paused():
|
||||
await control.wait()
|
||||
resume_time = datetime.now()
|
||||
last_progress_report_time = resume_time
|
||||
if control.consume_reconnect_request():
|
||||
raise DownloadRestartRequested(
|
||||
"Reconnect requested after resume"
|
||||
)
|
||||
elif control.consume_reconnect_request():
|
||||
raise DownloadRestartRequested("Reconnect requested")
|
||||
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
response.content.read(self.chunk_size),
|
||||
timeout=active_stall_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
logger.warning(
|
||||
"Download stalled for %.1f seconds without progress from %s",
|
||||
active_stall_timeout,
|
||||
url,
|
||||
)
|
||||
raise DownloadStalledError(
|
||||
f"No data received for {active_stall_timeout:.1f} seconds"
|
||||
) from exc
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
# Run blocking file write in executor
|
||||
await loop.run_in_executor(None, f.write, chunk)
|
||||
current_size += len(chunk)
|
||||
|
||||
now = datetime.now()
|
||||
if control is not None:
|
||||
control.mark_progress(timestamp=now.timestamp())
|
||||
|
||||
# Limit progress update frequency to reduce overhead
|
||||
time_diff = (now - last_progress_report_time).total_seconds()
|
||||
|
||||
if progress_callback and time_diff >= 1.0:
|
||||
progress_samples.append((now, current_size))
|
||||
cutoff = now - timedelta(seconds=5)
|
||||
while progress_samples and progress_samples[0][0] < cutoff:
|
||||
progress_samples.popleft()
|
||||
|
||||
percent = (current_size / total_size) * 100 if total_size else 0.0
|
||||
bytes_per_second = 0.0
|
||||
if len(progress_samples) >= 2:
|
||||
first_time, first_bytes = progress_samples[0]
|
||||
last_time, last_bytes = progress_samples[-1]
|
||||
elapsed = (last_time - first_time).total_seconds()
|
||||
if elapsed > 0:
|
||||
bytes_per_second = (last_bytes - first_bytes) / elapsed
|
||||
|
||||
progress_snapshot = DownloadProgress(
|
||||
percent_complete=percent,
|
||||
bytes_downloaded=current_size,
|
||||
total_bytes=total_size or None,
|
||||
bytes_per_second=bytes_per_second,
|
||||
timestamp=now.timestamp(),
|
||||
)
|
||||
|
||||
await self._dispatch_progress_callback(progress_callback, progress_snapshot)
|
||||
last_progress_report_time = now
|
||||
|
||||
# Download completed successfully
|
||||
# Verify file size if total_size was provided
|
||||
final_size = os.path.getsize(part_path)
|
||||
if total_size > 0 and final_size != total_size:
|
||||
logger.warning(f"File size mismatch. Expected: {total_size}, Got: {final_size}")
|
||||
# Don't treat this as fatal error, continue anyway
|
||||
|
||||
# Verify file size integrity before finalizing
|
||||
final_size = os.path.getsize(part_path) if os.path.exists(part_path) else 0
|
||||
expected_size = total_size if total_size > 0 else None
|
||||
|
||||
integrity_error: Optional[str] = None
|
||||
if final_size <= 0:
|
||||
integrity_error = "Downloaded file is empty"
|
||||
elif expected_size is not None and final_size != expected_size:
|
||||
integrity_error = (
|
||||
f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
|
||||
)
|
||||
|
||||
if integrity_error is not None:
|
||||
logger.error(
|
||||
"Download integrity check failed for %s: %s",
|
||||
save_path,
|
||||
integrity_error,
|
||||
)
|
||||
|
||||
# Remove the corrupted payload so future attempts start fresh
|
||||
if os.path.exists(part_path):
|
||||
try:
|
||||
os.remove(part_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete corrupted download %s: %s",
|
||||
part_path,
|
||||
remove_error,
|
||||
)
|
||||
if part_path != save_path and os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete target file %s after integrity error: %s",
|
||||
save_path,
|
||||
remove_error,
|
||||
)
|
||||
|
||||
retry_count += 1
|
||||
if retry_count <= self.max_retries:
|
||||
delay = self.base_delay * (2 ** (retry_count - 1))
|
||||
logger.info(
|
||||
"Retrying download in %s seconds due to integrity check failure",
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
resume_offset = 0
|
||||
total_size = 0
|
||||
await self._create_session()
|
||||
continue
|
||||
|
||||
return False, integrity_error
|
||||
|
||||
# Atomically rename .part to final file (only if using resume)
|
||||
if allow_resume and part_path != save_path:
|
||||
max_rename_attempts = 5
|
||||
@@ -326,18 +571,34 @@ class Downloader:
|
||||
else:
|
||||
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}")
|
||||
return False, f"Failed to finalize download: {str(e)}"
|
||||
|
||||
|
||||
final_size = os.path.getsize(save_path)
|
||||
|
||||
# Ensure 100% progress is reported
|
||||
if progress_callback:
|
||||
await progress_callback(100)
|
||||
final_snapshot = DownloadProgress(
|
||||
percent_complete=100.0,
|
||||
bytes_downloaded=final_size,
|
||||
total_bytes=total_size or final_size,
|
||||
bytes_per_second=0.0,
|
||||
timestamp=datetime.now().timestamp(),
|
||||
)
|
||||
await self._dispatch_progress_callback(progress_callback, final_snapshot)
|
||||
|
||||
|
||||
return True, save_path
|
||||
|
||||
except (aiohttp.ClientError, aiohttp.ClientPayloadError,
|
||||
aiohttp.ServerDisconnectedError, asyncio.TimeoutError) as e:
|
||||
except (
|
||||
aiohttp.ClientError,
|
||||
aiohttp.ClientPayloadError,
|
||||
aiohttp.ServerDisconnectedError,
|
||||
asyncio.TimeoutError,
|
||||
DownloadStalledError,
|
||||
DownloadRestartRequested,
|
||||
) as e:
|
||||
retry_count += 1
|
||||
logger.warning(f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}")
|
||||
|
||||
|
||||
if retry_count <= self.max_retries:
|
||||
# Calculate delay with exponential backoff
|
||||
delay = self.base_delay * (2 ** (retry_count - 1))
|
||||
@@ -361,7 +622,24 @@ class Downloader:
|
||||
return False, str(e)
|
||||
|
||||
return False, f"Download failed after {self.max_retries + 1} attempts"
|
||||
|
||||
|
||||
async def _dispatch_progress_callback(
|
||||
self,
|
||||
progress_callback: Callable[..., Awaitable[None]],
|
||||
snapshot: DownloadProgress,
|
||||
) -> None:
|
||||
"""Invoke a progress callback while preserving backward compatibility."""
|
||||
|
||||
try:
|
||||
result = progress_callback(snapshot, snapshot)
|
||||
except TypeError:
|
||||
result = progress_callback(snapshot.percent_complete)
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
elif hasattr(result, "__await__"):
|
||||
await result
|
||||
|
||||
async def download_to_memory(
|
||||
self,
|
||||
url: str,
|
||||
@@ -511,6 +789,19 @@ class Downloader:
|
||||
return False, "Access forbidden"
|
||||
elif response.status == 404:
|
||||
return False, "Resource not found"
|
||||
elif response.status == 429:
|
||||
retry_after = self._extract_retry_after(response.headers)
|
||||
error_msg = "Request rate limited"
|
||||
logger.warning(
|
||||
"Rate limit encountered for %s %s; retry_after=%s",
|
||||
method,
|
||||
url,
|
||||
retry_after,
|
||||
)
|
||||
return False, RateLimitError(
|
||||
error_msg,
|
||||
retry_after=retry_after,
|
||||
)
|
||||
else:
|
||||
return False, f"Request failed with status {response.status}"
|
||||
|
||||
@@ -529,9 +820,42 @@ class Downloader:
|
||||
|
||||
async def refresh_session(self):
|
||||
"""Force refresh the HTTP session (useful when proxy settings change)"""
|
||||
await self._create_session()
|
||||
async with self._session_lock:
|
||||
await self._create_session()
|
||||
logger.info("HTTP session refreshed due to settings change")
|
||||
|
||||
@staticmethod
|
||||
def _extract_retry_after(headers) -> Optional[float]:
|
||||
"""Parse the Retry-After header into seconds."""
|
||||
if not headers:
|
||||
return None
|
||||
|
||||
header_value = headers.get("Retry-After")
|
||||
if not header_value:
|
||||
return None
|
||||
|
||||
header_value = header_value.strip()
|
||||
if not header_value:
|
||||
return None
|
||||
|
||||
if header_value.isdigit():
|
||||
try:
|
||||
seconds = float(header_value)
|
||||
except ValueError:
|
||||
return None
|
||||
return max(0.0, seconds)
|
||||
|
||||
try:
|
||||
retry_datetime = parsedate_to_datetime(header_value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if retry_datetime.tzinfo is None:
|
||||
return None
|
||||
|
||||
delta = retry_datetime - datetime.now(tz=retry_datetime.tzinfo)
|
||||
return max(0.0, delta.total_seconds())
|
||||
|
||||
|
||||
# Global instance accessor
|
||||
async def get_downloader() -> Downloader:
|
||||
|
||||
@@ -11,16 +11,20 @@ logger = logging.getLogger(__name__)
|
||||
class EmbeddingService(BaseModelService):
|
||||
"""Embedding-specific service implementation"""
|
||||
|
||||
def __init__(self, scanner):
|
||||
def __init__(self, scanner, update_service=None):
|
||||
"""Initialize Embedding service
|
||||
|
||||
Args:
|
||||
scanner: Embedding scanner instance
|
||||
update_service: Optional service for remote update tracking.
|
||||
"""
|
||||
super().__init__("embedding", scanner, EmbeddingMetadata)
|
||||
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, embedding_data: Dict) -> Dict:
|
||||
"""Format Embedding data for API response"""
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = embedding_data.get("sub_type", "embedding")
|
||||
|
||||
return {
|
||||
"model_name": embedding_data["model_name"],
|
||||
"file_name": embedding_data["file_name"],
|
||||
@@ -34,9 +38,11 @@ class EmbeddingService(BaseModelService):
|
||||
"modified": embedding_data.get("modified", ""),
|
||||
"tags": embedding_data.get("tags", []),
|
||||
"from_civitai": embedding_data.get("from_civitai", True),
|
||||
# "usage_count": embedding_data.get("usage_count", 0), # TODO: Enable when embedding usage tracking is implemented
|
||||
"notes": embedding_data.get("notes", ""),
|
||||
"model_type": embedding_data.get("model_type", "embedding"),
|
||||
"sub_type": sub_type,
|
||||
"favorite": embedding_data.get("favorite", False),
|
||||
"update_available": bool(embedding_data.get("update_available", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
|
||||
27
py/services/errors.py
Normal file
27
py/services/errors.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Common service-level exception types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class RateLimitError(RuntimeError):
|
||||
"""Raised when a remote provider rejects a request due to rate limiting."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
retry_after: Optional[float] = None,
|
||||
provider: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
self.provider = provider
|
||||
|
||||
|
||||
class ResourceNotFoundError(RuntimeError):
|
||||
"""Raised when a remote resource is permanently missing."""
|
||||
|
||||
pass
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from .service_registry import ServiceRegistry
|
||||
from .settings_manager import settings
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.example_images_paths import iter_library_roots
|
||||
|
||||
|
||||
@@ -62,7 +62,8 @@ class ExampleImagesCleanupService:
|
||||
async def cleanup_example_image_folders(self) -> Dict[str, object]:
|
||||
"""Clean empty or orphaned example image folders by moving them under a deleted bucket."""
|
||||
|
||||
example_images_path = settings.get("example_images_path")
|
||||
settings_manager = get_settings_manager()
|
||||
example_images_path = settings_manager.get("example_images_path")
|
||||
if not example_images_path:
|
||||
logger.debug("Cleanup skipped: example images path not configured")
|
||||
return {
|
||||
|
||||
@@ -3,28 +3,37 @@ import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .model_query import resolve_sub_type
|
||||
from ..utils.models import LoraMetadata
|
||||
from ..config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoraService(BaseModelService):
|
||||
"""LoRA-specific service implementation"""
|
||||
|
||||
def __init__(self, scanner):
|
||||
|
||||
def __init__(self, scanner, update_service=None):
|
||||
"""Initialize LoRA service
|
||||
|
||||
|
||||
Args:
|
||||
scanner: LoRA scanner instance
|
||||
update_service: Optional service for remote update tracking.
|
||||
"""
|
||||
super().__init__("lora", scanner, LoraMetadata)
|
||||
|
||||
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, lora_data: Dict) -> Dict:
|
||||
"""Format LoRA data for API response"""
|
||||
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||
# Normalize to lowercase for consistent API responses
|
||||
sub_type = resolve_sub_type(lora_data).lower()
|
||||
|
||||
return {
|
||||
"model_name": lora_data["model_name"],
|
||||
"file_name": lora_data["file_name"],
|
||||
"preview_url": config.get_preview_static_url(lora_data.get("preview_url", "")),
|
||||
"preview_url": config.get_preview_static_url(
|
||||
lora_data.get("preview_url", "")
|
||||
),
|
||||
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
||||
"base_model": lora_data.get("base_model", ""),
|
||||
"folder": lora_data["folder"],
|
||||
@@ -34,148 +43,491 @@ class LoraService(BaseModelService):
|
||||
"modified": lora_data.get("modified", ""),
|
||||
"tags": lora_data.get("tags", []),
|
||||
"from_civitai": lora_data.get("from_civitai", True),
|
||||
"usage_count": lora_data.get("usage_count", 0),
|
||||
"usage_tips": lora_data.get("usage_tips", ""),
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"civitai": self.filter_civitai_data(lora_data.get("civitai", {}), minimal=True)
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"sub_type": sub_type,
|
||||
"civitai": self.filter_civitai_data(
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||
"""Apply LoRA-specific filters"""
|
||||
# Handle first_letter filter for LoRAs
|
||||
first_letter = kwargs.get('first_letter')
|
||||
first_letter = kwargs.get("first_letter")
|
||||
if first_letter:
|
||||
data = self._filter_by_first_letter(data, first_letter)
|
||||
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
|
||||
"""Filter data by first letter of model name
|
||||
|
||||
|
||||
Special handling:
|
||||
- '#': Numbers (0-9)
|
||||
- '@': Special characters (not alphanumeric)
|
||||
- '漢': CJK characters
|
||||
"""
|
||||
filtered_data = []
|
||||
|
||||
|
||||
for lora in data:
|
||||
model_name = lora.get('model_name', '')
|
||||
model_name = lora.get("model_name", "")
|
||||
if not model_name:
|
||||
continue
|
||||
|
||||
|
||||
first_char = model_name[0].upper()
|
||||
|
||||
if letter == '#' and first_char.isdigit():
|
||||
|
||||
if letter == "#" and first_char.isdigit():
|
||||
filtered_data.append(lora)
|
||||
elif letter == '@' and not first_char.isalnum():
|
||||
elif letter == "@" and not first_char.isalnum():
|
||||
# Special characters (not alphanumeric)
|
||||
filtered_data.append(lora)
|
||||
elif letter == '漢' and self._is_cjk_character(first_char):
|
||||
elif letter == "漢" and self._is_cjk_character(first_char):
|
||||
# CJK characters
|
||||
filtered_data.append(lora)
|
||||
elif letter.upper() == first_char:
|
||||
# Regular alphabet matching
|
||||
filtered_data.append(lora)
|
||||
|
||||
|
||||
return filtered_data
|
||||
|
||||
|
||||
def _is_cjk_character(self, char: str) -> bool:
|
||||
"""Check if character is a CJK character"""
|
||||
# Define Unicode ranges for CJK characters
|
||||
cjk_ranges = [
|
||||
(0x4E00, 0x9FFF), # CJK Unified Ideographs
|
||||
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
|
||||
(0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B
|
||||
(0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C
|
||||
(0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D
|
||||
(0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E
|
||||
(0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F
|
||||
(0x30000, 0x3134F), # CJK Unified Ideographs Extension G
|
||||
(0xF900, 0xFAFF), # CJK Compatibility Ideographs
|
||||
(0x3300, 0x33FF), # CJK Compatibility
|
||||
(0x3200, 0x32FF), # Enclosed CJK Letters and Months
|
||||
(0x3100, 0x312F), # Bopomofo
|
||||
(0x31A0, 0x31BF), # Bopomofo Extended
|
||||
(0x3040, 0x309F), # Hiragana
|
||||
(0x30A0, 0x30FF), # Katakana
|
||||
(0x31F0, 0x31FF), # Katakana Phonetic Extensions
|
||||
(0xAC00, 0xD7AF), # Hangul Syllables
|
||||
(0x1100, 0x11FF), # Hangul Jamo
|
||||
(0xA960, 0xA97F), # Hangul Jamo Extended-A
|
||||
(0xD7B0, 0xD7FF), # Hangul Jamo Extended-B
|
||||
(0x4E00, 0x9FFF), # CJK Unified Ideographs
|
||||
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
|
||||
(0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B
|
||||
(0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C
|
||||
(0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D
|
||||
(0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E
|
||||
(0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F
|
||||
(0x30000, 0x3134F), # CJK Unified Ideographs Extension G
|
||||
(0xF900, 0xFAFF), # CJK Compatibility Ideographs
|
||||
(0x3300, 0x33FF), # CJK Compatibility
|
||||
(0x3200, 0x32FF), # Enclosed CJK Letters and Months
|
||||
(0x3100, 0x312F), # Bopomofo
|
||||
(0x31A0, 0x31BF), # Bopomofo Extended
|
||||
(0x3040, 0x309F), # Hiragana
|
||||
(0x30A0, 0x30FF), # Katakana
|
||||
(0x31F0, 0x31FF), # Katakana Phonetic Extensions
|
||||
(0xAC00, 0xD7AF), # Hangul Syllables
|
||||
(0x1100, 0x11FF), # Hangul Jamo
|
||||
(0xA960, 0xA97F), # Hangul Jamo Extended-A
|
||||
(0xD7B0, 0xD7FF), # Hangul Jamo Extended-B
|
||||
]
|
||||
|
||||
|
||||
code_point = ord(char)
|
||||
return any(start <= code_point <= end for start, end in cjk_ranges)
|
||||
|
||||
|
||||
# LoRA-specific methods
|
||||
async def get_letter_counts(self) -> Dict[str, int]:
|
||||
"""Get count of LoRAs for each letter of the alphabet"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
data = cache.raw_data
|
||||
|
||||
|
||||
# Define letter categories
|
||||
letters = {
|
||||
'#': 0, # Numbers
|
||||
'A': 0, 'B': 0, 'C': 0, 'D': 0, 'E': 0, 'F': 0, 'G': 0, 'H': 0,
|
||||
'I': 0, 'J': 0, 'K': 0, 'L': 0, 'M': 0, 'N': 0, 'O': 0, 'P': 0,
|
||||
'Q': 0, 'R': 0, 'S': 0, 'T': 0, 'U': 0, 'V': 0, 'W': 0, 'X': 0,
|
||||
'Y': 0, 'Z': 0,
|
||||
'@': 0, # Special characters
|
||||
'漢': 0 # CJK characters
|
||||
"#": 0, # Numbers
|
||||
"A": 0,
|
||||
"B": 0,
|
||||
"C": 0,
|
||||
"D": 0,
|
||||
"E": 0,
|
||||
"F": 0,
|
||||
"G": 0,
|
||||
"H": 0,
|
||||
"I": 0,
|
||||
"J": 0,
|
||||
"K": 0,
|
||||
"L": 0,
|
||||
"M": 0,
|
||||
"N": 0,
|
||||
"O": 0,
|
||||
"P": 0,
|
||||
"Q": 0,
|
||||
"R": 0,
|
||||
"S": 0,
|
||||
"T": 0,
|
||||
"U": 0,
|
||||
"V": 0,
|
||||
"W": 0,
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0,
|
||||
"@": 0, # Special characters
|
||||
"漢": 0, # CJK characters
|
||||
}
|
||||
|
||||
|
||||
# Count models for each letter
|
||||
for lora in data:
|
||||
model_name = lora.get('model_name', '')
|
||||
model_name = lora.get("model_name", "")
|
||||
if not model_name:
|
||||
continue
|
||||
|
||||
|
||||
first_char = model_name[0].upper()
|
||||
|
||||
|
||||
if first_char.isdigit():
|
||||
letters['#'] += 1
|
||||
letters["#"] += 1
|
||||
elif first_char in letters:
|
||||
letters[first_char] += 1
|
||||
elif self._is_cjk_character(first_char):
|
||||
letters['漢'] += 1
|
||||
letters["漢"] += 1
|
||||
elif not first_char.isalnum():
|
||||
letters['@'] += 1
|
||||
|
||||
letters["@"] += 1
|
||||
|
||||
return letters
|
||||
|
||||
|
||||
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
|
||||
"""Get trigger words for a specific LoRA file"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
|
||||
for lora in cache.raw_data:
|
||||
if lora['file_name'] == lora_name:
|
||||
civitai_data = lora.get('civitai', {})
|
||||
return civitai_data.get('trainedWords', [])
|
||||
|
||||
if lora["file_name"] == lora_name:
|
||||
civitai_data = lora.get("civitai", {})
|
||||
return civitai_data.get("trainedWords", [])
|
||||
|
||||
return []
|
||||
|
||||
async def get_lora_usage_tips_by_relative_path(self, relative_path: str) -> Optional[str]:
|
||||
|
||||
async def get_lora_usage_tips_by_relative_path(
|
||||
self, relative_path: str
|
||||
) -> Optional[str]:
|
||||
"""Get usage tips for a LoRA by its relative path"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
|
||||
for lora in cache.raw_data:
|
||||
file_path = lora.get('file_path', '')
|
||||
file_path = lora.get("file_path", "")
|
||||
if file_path:
|
||||
# Convert to forward slashes and extract relative path
|
||||
file_path_normalized = file_path.replace('\\', '/')
|
||||
relative_path = relative_path.replace('\\', '/')
|
||||
file_path_normalized = file_path.replace("\\", "/")
|
||||
relative_path = relative_path.replace("\\", "/")
|
||||
# Find the relative path part by looking for the relative_path in the full path
|
||||
if file_path_normalized.endswith(relative_path) or relative_path in file_path_normalized:
|
||||
return lora.get('usage_tips', '')
|
||||
|
||||
if (
|
||||
file_path_normalized.endswith(relative_path)
|
||||
or relative_path in file_path_normalized
|
||||
):
|
||||
return lora.get("usage_tips", "")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
"""Find LoRAs with duplicate SHA256 hashes"""
|
||||
return self.scanner._hash_index.get_duplicate_hashes()
|
||||
|
||||
|
||||
def find_duplicate_filenames(self) -> Dict:
|
||||
"""Find LoRAs with conflicting filenames"""
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
|
||||
async def get_random_loras(
|
||||
self,
|
||||
count: int,
|
||||
model_strength_min: float = 0.0,
|
||||
model_strength_max: float = 1.0,
|
||||
use_same_clip_strength: bool = True,
|
||||
clip_strength_min: float = 0.0,
|
||||
clip_strength_max: float = 1.0,
|
||||
locked_loras: Optional[List[Dict]] = None,
|
||||
pool_config: Optional[Dict] = None,
|
||||
count_mode: str = "fixed",
|
||||
count_min: int = 3,
|
||||
count_max: int = 7,
|
||||
use_recommended_strength: bool = False,
|
||||
recommended_strength_scale_min: float = 0.5,
|
||||
recommended_strength_scale_max: float = 1.0,
|
||||
seed: Optional[int] = None,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get random LoRAs with specified strength ranges.
|
||||
|
||||
Args:
|
||||
count: Number of LoRAs to select (if count_mode='fixed')
|
||||
model_strength_min: Minimum model strength
|
||||
model_strength_max: Maximum model strength
|
||||
use_same_clip_strength: Whether to use same strength for clip
|
||||
clip_strength_min: Minimum clip strength
|
||||
clip_strength_max: Maximum clip strength
|
||||
locked_loras: List of locked LoRA dicts to preserve
|
||||
pool_config: Optional pool config for filtering
|
||||
count_mode: How to determine count ('fixed' or 'range')
|
||||
count_min: Minimum count for range mode
|
||||
count_max: Maximum count for range mode
|
||||
use_recommended_strength: Whether to use recommended strength from usage_tips
|
||||
recommended_strength_scale_min: Minimum scale factor for recommended strength
|
||||
recommended_strength_scale_max: Maximum scale factor for recommended strength
|
||||
seed: Optional random seed for reproducible/unique randomization per execution
|
||||
|
||||
Returns:
|
||||
List of LoRA dicts with randomized strengths
|
||||
"""
|
||||
import random
|
||||
import json
|
||||
|
||||
# Use a local Random instance to avoid affecting global random state
|
||||
# This ensures each execution with a different seed produces different results
|
||||
rng = random.Random(seed)
|
||||
|
||||
def get_recommended_strength(lora_data: Dict) -> Optional[float]:
|
||||
"""Parse usage_tips JSON and extract recommended strength"""
|
||||
try:
|
||||
usage_tips = lora_data.get("usage_tips", "")
|
||||
if not usage_tips:
|
||||
return None
|
||||
tips_data = json.loads(usage_tips)
|
||||
return tips_data.get("strength")
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
def get_recommended_clip_strength(lora_data: Dict) -> Optional[float]:
|
||||
"""Parse usage_tips JSON and extract recommended clip strength"""
|
||||
try:
|
||||
usage_tips = lora_data.get("usage_tips", "")
|
||||
if not usage_tips:
|
||||
return None
|
||||
tips_data = json.loads(usage_tips)
|
||||
return tips_data.get("clipStrength")
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
if locked_loras is None:
|
||||
locked_loras = []
|
||||
|
||||
# Determine target count based on count_mode
|
||||
if count_mode == "fixed":
|
||||
target_count = count
|
||||
else:
|
||||
target_count = rng.randint(count_min, count_max)
|
||||
|
||||
# Get available loras from cache
|
||||
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||
available_loras = cache.raw_data if cache else []
|
||||
|
||||
# Apply pool filters if provided
|
||||
if pool_config:
|
||||
available_loras = await self._apply_pool_filters(
|
||||
available_loras, pool_config
|
||||
)
|
||||
|
||||
# Calculate slots needed (total - locked)
|
||||
locked_count = len(locked_loras)
|
||||
slots_needed = target_count - locked_count
|
||||
|
||||
if slots_needed < 0:
|
||||
slots_needed = 0
|
||||
# Too many locked, trim to target
|
||||
locked_loras = locked_loras[:target_count]
|
||||
|
||||
# Filter out locked LoRAs from available pool
|
||||
locked_names = {lora["name"] for lora in locked_loras}
|
||||
available_pool = [
|
||||
l for l in available_loras if l["file_name"] not in locked_names
|
||||
]
|
||||
|
||||
# Ensure we don't try to select more than available
|
||||
if slots_needed > len(available_pool):
|
||||
slots_needed = len(available_pool)
|
||||
|
||||
# Random sample
|
||||
selected = []
|
||||
if slots_needed > 0:
|
||||
selected = rng.sample(available_pool, slots_needed)
|
||||
|
||||
# Generate random strengths for selected LoRAs
|
||||
result_loras = []
|
||||
for lora in selected:
|
||||
if use_recommended_strength:
|
||||
recommended_strength = get_recommended_strength(lora)
|
||||
if recommended_strength is not None:
|
||||
scale = rng.uniform(
|
||||
recommended_strength_scale_min, recommended_strength_scale_max
|
||||
)
|
||||
model_str = round(recommended_strength * scale, 2)
|
||||
else:
|
||||
model_str = round(
|
||||
rng.uniform(model_strength_min, model_strength_max), 2
|
||||
)
|
||||
else:
|
||||
model_str = round(
|
||||
rng.uniform(model_strength_min, model_strength_max), 2
|
||||
)
|
||||
|
||||
if use_same_clip_strength:
|
||||
clip_str = model_str
|
||||
elif use_recommended_strength:
|
||||
recommended_clip_strength = get_recommended_clip_strength(lora)
|
||||
if recommended_clip_strength is not None:
|
||||
scale = rng.uniform(
|
||||
recommended_strength_scale_min, recommended_strength_scale_max
|
||||
)
|
||||
clip_str = round(recommended_clip_strength * scale, 2)
|
||||
else:
|
||||
clip_str = round(
|
||||
rng.uniform(clip_strength_min, clip_strength_max), 2
|
||||
)
|
||||
else:
|
||||
clip_str = round(
|
||||
rng.uniform(clip_strength_min, clip_strength_max), 2
|
||||
)
|
||||
|
||||
result_loras.append(
|
||||
{
|
||||
"name": lora["file_name"],
|
||||
"strength": model_str,
|
||||
"clipStrength": clip_str,
|
||||
"active": True,
|
||||
"expanded": abs(model_str - clip_str) > 0.001,
|
||||
"locked": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Merge with locked LoRAs
|
||||
result_loras.extend(locked_loras)
|
||||
|
||||
return result_loras
|
||||
|
||||
async def _apply_pool_filters(
|
||||
self, available_loras: List[Dict], pool_config: Dict
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Apply pool_config filters to available LoRAs.
|
||||
|
||||
Args:
|
||||
available_loras: List of all LoRA dicts
|
||||
pool_config: Dict with filter settings from LoRA Pool node
|
||||
|
||||
Returns:
|
||||
Filtered list of LoRA dicts
|
||||
"""
|
||||
from .model_query import FilterCriteria
|
||||
|
||||
filter_section = pool_config
|
||||
|
||||
# Extract filter parameters
|
||||
selected_base_models = filter_section.get("baseModels", [])
|
||||
tags_dict = filter_section.get("tags", {})
|
||||
include_tags = tags_dict.get("include", [])
|
||||
exclude_tags = tags_dict.get("exclude", [])
|
||||
folders_dict = filter_section.get("folders", {})
|
||||
include_folders = folders_dict.get("include", [])
|
||||
exclude_folders = folders_dict.get("exclude", [])
|
||||
license_dict = filter_section.get("license", {})
|
||||
no_credit_required = license_dict.get("noCreditRequired", False)
|
||||
allow_selling = license_dict.get("allowSelling", False)
|
||||
|
||||
# Build tag filters dict
|
||||
tag_filters = {}
|
||||
for tag in include_tags:
|
||||
tag_filters[tag] = "include"
|
||||
for tag in exclude_tags:
|
||||
tag_filters[tag] = "exclude"
|
||||
|
||||
# Build folder filter
|
||||
if include_folders or exclude_folders:
|
||||
filtered = []
|
||||
for lora in available_loras:
|
||||
folder = lora.get("folder", "")
|
||||
|
||||
# Check exclude folders first
|
||||
excluded = False
|
||||
for exclude_folder in exclude_folders:
|
||||
if folder.startswith(exclude_folder):
|
||||
excluded = True
|
||||
break
|
||||
|
||||
if excluded:
|
||||
continue
|
||||
|
||||
# Check include folders
|
||||
if include_folders:
|
||||
included = False
|
||||
for include_folder in include_folders:
|
||||
if folder.startswith(include_folder):
|
||||
included = True
|
||||
break
|
||||
if not included:
|
||||
continue
|
||||
|
||||
filtered.append(lora)
|
||||
|
||||
available_loras = filtered
|
||||
|
||||
# Apply base model filter
|
||||
if selected_base_models:
|
||||
available_loras = [
|
||||
lora
|
||||
for lora in available_loras
|
||||
if lora.get("base_model") in selected_base_models
|
||||
]
|
||||
|
||||
# Apply tag filters
|
||||
if tag_filters:
|
||||
criteria = FilterCriteria(tags=tag_filters)
|
||||
available_loras = self.filter_set.apply(available_loras, criteria)
|
||||
|
||||
# Apply license filters
|
||||
# no_credit_required=True means keep only models where credit is NOT required
|
||||
# (i.e., allowNoCredit=True, which is bit 0 = 1 in license_flags)
|
||||
if no_credit_required:
|
||||
available_loras = [
|
||||
lora
|
||||
for lora in available_loras
|
||||
if bool(lora.get("license_flags", 127) & (1 << 0))
|
||||
]
|
||||
|
||||
# allow_selling=True means keep only models where selling generated content is allowed
|
||||
if allow_selling:
|
||||
available_loras = [
|
||||
lora
|
||||
for lora in available_loras
|
||||
if bool(lora.get("license_flags", 127) & (1 << 1))
|
||||
]
|
||||
|
||||
return available_loras
|
||||
|
||||
async def get_cycler_list(
|
||||
self,
|
||||
pool_config: Optional[Dict] = None,
|
||||
sort_by: str = "filename"
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get filtered and sorted LoRA list for cycling.
|
||||
|
||||
Args:
|
||||
pool_config: Optional pool config for filtering (filters dict)
|
||||
sort_by: Sort field - 'filename' or 'model_name'
|
||||
|
||||
Returns:
|
||||
List of LoRA dicts with file_name and model_name
|
||||
"""
|
||||
# Get cached data
|
||||
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||
available_loras = cache.raw_data if cache else []
|
||||
|
||||
# Apply pool filters if provided
|
||||
if pool_config:
|
||||
available_loras = await self._apply_pool_filters(
|
||||
available_loras, pool_config
|
||||
)
|
||||
|
||||
# Sort by specified field
|
||||
if sort_by == "model_name":
|
||||
available_loras = sorted(
|
||||
available_loras,
|
||||
key=lambda x: (x.get("model_name") or x.get("file_name", "")).lower()
|
||||
)
|
||||
else: # Default to filename
|
||||
available_loras = sorted(
|
||||
available_loras,
|
||||
key=lambda x: x.get("file_name", "").lower()
|
||||
)
|
||||
|
||||
# Return minimal data needed for cycling
|
||||
return [
|
||||
{
|
||||
"file_name": lora["file_name"],
|
||||
"model_name": lora.get("model_name", lora["file_name"]),
|
||||
}
|
||||
for lora in available_loras
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .downloader import get_downloader
|
||||
from .downloader import get_downloader, DownloadProgress
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,9 +77,15 @@ class MetadataArchiveManager:
|
||||
progress_callback("download", f"Downloading from {url}")
|
||||
|
||||
# Custom progress callback to report download progress
|
||||
async def download_progress(progress):
|
||||
async def download_progress(progress, snapshot=None):
|
||||
if progress_callback:
|
||||
progress_callback("download", f"Downloading archive... {progress:.1f}%")
|
||||
if isinstance(progress, DownloadProgress):
|
||||
percent = progress.percent_complete
|
||||
elif isinstance(snapshot, DownloadProgress):
|
||||
percent = snapshot.percent_complete
|
||||
else:
|
||||
percent = float(progress or 0)
|
||||
progress_callback("download", f"Downloading archive... {percent:.1f}%")
|
||||
|
||||
success, result = await downloader.download_file(
|
||||
url=url,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import os
|
||||
import logging
|
||||
from .model_metadata_provider import (
|
||||
ModelMetadataProviderManager,
|
||||
ModelMetadataProvider,
|
||||
ModelMetadataProviderManager,
|
||||
SQLiteModelMetadataProvider,
|
||||
CivitaiModelMetadataProvider,
|
||||
FallbackMetadataProvider
|
||||
CivArchiveModelMetadataProvider,
|
||||
FallbackMetadataProvider,
|
||||
RateLimitRetryingProvider,
|
||||
)
|
||||
from .settings_manager import settings
|
||||
from .settings_manager import get_settings_manager
|
||||
from .metadata_archive_manager import MetadataArchiveManager
|
||||
from .service_registry import ServiceRegistry
|
||||
|
||||
@@ -21,7 +24,8 @@ async def initialize_metadata_providers():
|
||||
provider_manager.default_provider = None
|
||||
|
||||
# Get settings
|
||||
enable_archive_db = settings.get('enable_metadata_archive_db', False)
|
||||
settings_manager = get_settings_manager()
|
||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||
|
||||
providers = []
|
||||
|
||||
@@ -53,26 +57,27 @@ async def initialize_metadata_providers():
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
|
||||
|
||||
# Register CivArchive provider, but do NOT add to fallback providers
|
||||
# Register CivArchive provider, and all add to fallback providers
|
||||
try:
|
||||
from .model_metadata_provider import CivArchiveModelMetadataProvider
|
||||
civarchive_provider = CivArchiveModelMetadataProvider()
|
||||
provider_manager.register_provider('civarchive', civarchive_provider)
|
||||
logger.debug("CivArchive metadata provider registered (not included in fallback)")
|
||||
civarchive_client = await ServiceRegistry.get_civarchive_client()
|
||||
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
|
||||
provider_manager.register_provider('civarchive_api', civarchive_provider)
|
||||
providers.append(('civarchive_api', civarchive_provider))
|
||||
logger.debug("CivArchive metadata provider registered (also included in fallback)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
|
||||
|
||||
# Set up fallback provider based on available providers
|
||||
if len(providers) > 1:
|
||||
# Always use Civitai API first, then Archive DB
|
||||
ordered_providers = []
|
||||
ordered_providers.extend([p[1] for p in providers if p[0] == 'civitai_api'])
|
||||
ordered_providers.extend([p[1] for p in providers if p[0] == 'sqlite'])
|
||||
# Always use Civitai API (it has better metadata), then CivArchive API, then Archive DB
|
||||
ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
|
||||
ordered_providers.extend([p for p in providers if p[0] == 'civitai_api'])
|
||||
ordered_providers.extend([p for p in providers if p[0] == 'civarchive_api'])
|
||||
ordered_providers.extend([p for p in providers if p[0] == 'sqlite'])
|
||||
|
||||
if ordered_providers:
|
||||
fallback_provider = FallbackMetadataProvider(ordered_providers)
|
||||
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
|
||||
logger.debug(f"Fallback metadata provider registered with {len(ordered_providers)} providers, Civitai API first")
|
||||
elif len(providers) == 1:
|
||||
# Only one provider available, set it as default
|
||||
provider_name, provider = providers[0]
|
||||
@@ -87,7 +92,8 @@ async def update_metadata_providers():
|
||||
"""Update metadata providers based on current settings"""
|
||||
try:
|
||||
# Get current settings
|
||||
enable_archive_db = settings.get('enable_metadata_archive_db', False)
|
||||
settings_manager = get_settings_manager()
|
||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||
|
||||
# Reinitialize all providers with new settings
|
||||
provider_manager = await initialize_metadata_providers()
|
||||
@@ -103,14 +109,24 @@ async def get_metadata_archive_manager():
|
||||
base_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
return MetadataArchiveManager(base_path)
|
||||
|
||||
def _wrap_provider_with_rate_limit(provider_name: str | None, provider: ModelMetadataProvider) -> ModelMetadataProvider:
|
||||
if isinstance(provider, (FallbackMetadataProvider, RateLimitRetryingProvider)):
|
||||
return provider
|
||||
return RateLimitRetryingProvider(provider, label=provider_name)
|
||||
|
||||
|
||||
async def get_metadata_provider(provider_name: str = None):
|
||||
"""Get a specific metadata provider or default provider"""
|
||||
"""Get a specific metadata provider or default provider with rate-limit handling."""
|
||||
|
||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||
|
||||
if provider_name:
|
||||
return provider_manager._get_provider(provider_name)
|
||||
|
||||
return provider_manager._get_provider()
|
||||
|
||||
provider = (
|
||||
provider_manager._get_provider(provider_name)
|
||||
if provider_name
|
||||
else provider_manager._get_provider()
|
||||
)
|
||||
|
||||
return _wrap_provider_with_rate_limit(provider_name, provider)
|
||||
|
||||
async def get_default_metadata_provider():
|
||||
"""Get the default metadata provider (fallback or single provider)"""
|
||||
|
||||
@@ -9,7 +9,9 @@ from datetime import datetime
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
|
||||
|
||||
from ..services.settings_manager import SettingsManager
|
||||
from ..utils.civitai_utils import resolve_license_payload
|
||||
from ..utils.model_utils import determine_base_model
|
||||
from .errors import RateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -74,7 +76,7 @@ class MetadataSyncService:
|
||||
files = meta.get("files")
|
||||
images = meta.get("images")
|
||||
source = meta.get("source")
|
||||
return bool(files) and bool(images) and source != "archive_db"
|
||||
return bool(files) and bool(images) and source not in ("archive_db", "civarchive")
|
||||
|
||||
async def update_model_metadata(
|
||||
self,
|
||||
@@ -88,11 +90,11 @@ class MetadataSyncService:
|
||||
existing_civitai = local_metadata.get("civitai") or {}
|
||||
|
||||
if (
|
||||
civitai_metadata.get("source") == "archive_db"
|
||||
not self.is_civitai_api_metadata(civitai_metadata)
|
||||
and self.is_civitai_api_metadata(existing_civitai)
|
||||
):
|
||||
logger.info(
|
||||
"Skip civitai update for %s (%s)",
|
||||
"Skip civitai update for %s (%s) - existing metadata is higher quality",
|
||||
local_metadata.get("model_name", ""),
|
||||
existing_civitai.get("name", ""),
|
||||
)
|
||||
@@ -134,6 +136,17 @@ class MetadataSyncService:
|
||||
):
|
||||
local_metadata.setdefault("civitai", {})["creator"] = model_data["creator"]
|
||||
|
||||
merged_civitai = local_metadata.get("civitai") or {}
|
||||
civitai_model = merged_civitai.get("model")
|
||||
if not isinstance(civitai_model, dict):
|
||||
civitai_model = {}
|
||||
|
||||
license_payload = resolve_license_payload(model_data)
|
||||
civitai_model.update(license_payload)
|
||||
|
||||
merged_civitai["model"] = civitai_model
|
||||
local_metadata["civitai"] = merged_civitai
|
||||
|
||||
local_metadata["base_model"] = determine_base_model(
|
||||
civitai_metadata.get("baseModel")
|
||||
)
|
||||
@@ -153,7 +166,12 @@ class MetadataSyncService:
|
||||
model_data: Dict[str, Any],
|
||||
update_cache_func: Callable[[str, str, Dict[str, Any]], Awaitable[bool]],
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Fetch metadata for a model and update both disk and cache state."""
|
||||
"""Fetch metadata for a model and update both disk and cache state.
|
||||
|
||||
Callers should hydrate ``model_data`` via ``MetadataManager.hydrate_model_data``
|
||||
before invoking this method so that the persisted payload retains all known
|
||||
metadata fields.
|
||||
"""
|
||||
|
||||
if not isinstance(model_data, dict):
|
||||
error = f"Invalid model_data type: {type(model_data)}"
|
||||
@@ -162,42 +180,127 @@ class MetadataSyncService:
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
enable_archive = self._settings.get("enable_metadata_archive_db", False)
|
||||
previous_source = model_data.get("metadata_source") or (model_data.get("civitai") or {}).get("source")
|
||||
|
||||
try:
|
||||
provider_attempts: list[tuple[Optional[str], MetadataProviderProtocol]] = []
|
||||
sqlite_attempted = False
|
||||
|
||||
if model_data.get("civitai_deleted") is True:
|
||||
if not enable_archive or model_data.get("db_checked") is True:
|
||||
if previous_source in (None, "civarchive"):
|
||||
try:
|
||||
provider_attempts.append(("civarchive_api", await self._get_provider("civarchive_api")))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve civarchive provider: %s", exc)
|
||||
|
||||
if enable_archive and model_data.get("db_checked") is not True:
|
||||
try:
|
||||
provider_attempts.append(("sqlite", await self._get_provider("sqlite")))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve sqlite provider: %s", exc)
|
||||
|
||||
if not provider_attempts:
|
||||
if not enable_archive:
|
||||
error_msg = "CivitAI model is deleted and metadata archive DB is not enabled"
|
||||
else:
|
||||
elif model_data.get("db_checked") is True:
|
||||
error_msg = "CivitAI model is deleted and not found in metadata archive DB"
|
||||
return (False, error_msg)
|
||||
metadata_provider = await self._get_provider("sqlite")
|
||||
else:
|
||||
error_msg = "CivitAI model is deleted and no archive provider is available"
|
||||
return False, error_msg
|
||||
else:
|
||||
metadata_provider = await self._get_default_provider()
|
||||
provider_attempts.append((None, await self._get_default_provider()))
|
||||
|
||||
civitai_metadata, error = await metadata_provider.get_model_by_hash(sha256)
|
||||
if not civitai_metadata:
|
||||
if error == "Model not found":
|
||||
civitai_metadata: Optional[Dict[str, Any]] = None
|
||||
metadata_provider: Optional[MetadataProviderProtocol] = None
|
||||
provider_used: Optional[str] = None
|
||||
last_error: Optional[str] = None
|
||||
civitai_api_not_found = False
|
||||
|
||||
for provider_name, provider in provider_attempts:
|
||||
try:
|
||||
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or (provider_name or provider.__class__.__name__)
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
|
||||
civitai_metadata_candidate, error = None, str(exc)
|
||||
|
||||
if provider_name == "sqlite":
|
||||
sqlite_attempted = True
|
||||
|
||||
is_default_provider = provider_name is None
|
||||
|
||||
if civitai_metadata_candidate:
|
||||
civitai_metadata = civitai_metadata_candidate
|
||||
metadata_provider = provider
|
||||
provider_used = provider_name
|
||||
break
|
||||
|
||||
if is_default_provider and error == "Model not found":
|
||||
civitai_api_not_found = True
|
||||
|
||||
last_error = error or last_error
|
||||
|
||||
if civitai_metadata is None or metadata_provider is None:
|
||||
if sqlite_attempted:
|
||||
model_data["db_checked"] = True
|
||||
|
||||
if civitai_api_not_found:
|
||||
model_data["from_civitai"] = False
|
||||
model_data["civitai_deleted"] = True
|
||||
model_data["db_checked"] = enable_archive
|
||||
model_data["db_checked"] = sqlite_attempted or (enable_archive and model_data.get("db_checked", False))
|
||||
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||
|
||||
data_to_save = model_data.copy()
|
||||
data_to_save.pop("folder", None)
|
||||
await self._metadata_manager.save_metadata(file_path, data_to_save)
|
||||
|
||||
default_error = (
|
||||
"CivitAI model is deleted and metadata archive DB is not enabled"
|
||||
if model_data.get("civitai_deleted") and not enable_archive
|
||||
else "CivitAI model is deleted and not found in metadata archive DB"
|
||||
if model_data.get("civitai_deleted") and (model_data.get("db_checked") is True or sqlite_attempted)
|
||||
else "No provider returned metadata"
|
||||
)
|
||||
|
||||
error_msg = (
|
||||
f"Error fetching metadata: {error} (model_name={model_data.get('model_name', '')})"
|
||||
f"Error fetching metadata: {last_error or default_error} "
|
||||
f"(model_name={model_data.get('model_name', '')})"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
model_data["from_civitai"] = True
|
||||
model_data["civitai_deleted"] = civitai_metadata.get("source") == "archive_db"
|
||||
model_data["db_checked"] = enable_archive
|
||||
if provider_used is None:
|
||||
model_data["civitai_deleted"] = False
|
||||
elif civitai_api_not_found:
|
||||
model_data["civitai_deleted"] = True
|
||||
model_data["db_checked"] = enable_archive and (
|
||||
civitai_metadata.get("source") == "archive_db" or sqlite_attempted
|
||||
)
|
||||
source = civitai_metadata.get("source") or "civitai_api"
|
||||
if source == "api":
|
||||
source = "civitai_api"
|
||||
elif provider_used == "civarchive_api" and source != "civarchive":
|
||||
source = "civarchive"
|
||||
elif provider_used == "sqlite":
|
||||
source = "archive_db"
|
||||
model_data["metadata_source"] = source
|
||||
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||
|
||||
readable_source = {
|
||||
"civitai_api": "CivitAI API",
|
||||
"civarchive": "CivArchive API",
|
||||
"archive_db": "Archive Database",
|
||||
}.get(source, source)
|
||||
|
||||
logger.info(
|
||||
"Fetched metadata for %s via %s",
|
||||
model_data.get("model_name", ""),
|
||||
readable_source,
|
||||
)
|
||||
|
||||
local_metadata = model_data.copy()
|
||||
local_metadata.pop("folder", None)
|
||||
|
||||
@@ -213,6 +316,7 @@ class MetadataSyncService:
|
||||
"preview_url": local_metadata.get("preview_url"),
|
||||
"civitai": local_metadata.get("civitai"),
|
||||
}
|
||||
|
||||
model_data.update(update_payload)
|
||||
|
||||
await update_cache_func(file_path, file_path, local_metadata)
|
||||
@@ -221,6 +325,16 @@ class MetadataSyncService:
|
||||
error_msg = f"Error fetching metadata - Missing key: {exc} in model_data={model_data}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except RateLimitError as exc:
|
||||
provider_label = exc.provider or "metadata provider"
|
||||
wait_hint = (
|
||||
f"; retry after approximately {int(exc.retry_after)}s"
|
||||
if exc.retry_after and exc.retry_after > 0
|
||||
else ""
|
||||
)
|
||||
error_msg = f"Rate limited by {provider_label}{wait_hint}"
|
||||
logger.warning(error_msg)
|
||||
return False, error_msg
|
||||
except Exception as exc: # pragma: no cover - error path
|
||||
error_msg = f"Error fetching metadata: {exc}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
@@ -252,15 +366,6 @@ class MetadataSyncService:
|
||||
+ (f" with version: {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
|
||||
primary_model_file: Optional[Dict[str, Any]] = None
|
||||
for file_info in civitai_metadata.get("files", []):
|
||||
if file_info.get("primary", False) and file_info.get("type") == "Model":
|
||||
primary_model_file = file_info
|
||||
break
|
||||
|
||||
if primary_model_file and primary_model_file.get("hashes", {}).get("SHA256"):
|
||||
metadata["sha256"] = primary_model_file["hashes"]["SHA256"].lower()
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
await self.update_model_metadata(
|
||||
metadata_path,
|
||||
@@ -353,4 +458,3 @@ class MetadataSyncService:
|
||||
results["verified_as_duplicates"] = False
|
||||
|
||||
return results
|
||||
|
||||
|
||||
55
py/services/misc_scanner.py
Normal file
55
py/services/misc_scanner.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.models import MiscMetadata
|
||||
from ..config import config
|
||||
from .model_scanner import ModelScanner
|
||||
from .model_hash_index import ModelHashIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MiscScanner(ModelScanner):
|
||||
"""Service for scanning and managing misc files (VAE, Upscaler)"""
|
||||
|
||||
def __init__(self):
|
||||
# Define supported file extensions (combined from VAE and upscaler)
|
||||
file_extensions = {'.safetensors', '.pt', '.bin', '.ckpt', '.pth'}
|
||||
super().__init__(
|
||||
model_type="misc",
|
||||
model_class=MiscMetadata,
|
||||
file_extensions=file_extensions,
|
||||
hash_index=ModelHashIndex()
|
||||
)
|
||||
|
||||
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve the sub-type based on the root path."""
|
||||
if not root_path:
|
||||
return None
|
||||
|
||||
if config.vae_roots and root_path in config.vae_roots:
|
||||
return "vae"
|
||||
|
||||
if config.upscaler_roots and root_path in config.upscaler_roots:
|
||||
return "upscaler"
|
||||
|
||||
return None
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
"""Adjust metadata during scanning to set sub_type."""
|
||||
sub_type = self._resolve_sub_type(root_path)
|
||||
if sub_type:
|
||||
metadata.sub_type = sub_type
|
||||
return metadata
|
||||
|
||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Adjust entries loaded from the persisted cache to ensure sub_type is set."""
|
||||
sub_type = self._resolve_sub_type(
|
||||
self._find_root_for_file(entry.get("file_path"))
|
||||
)
|
||||
if sub_type:
|
||||
entry["sub_type"] = sub_type
|
||||
return entry
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get misc root directories (VAE and upscaler)"""
|
||||
return config.misc_roots
|
||||
55
py/services/misc_service.py
Normal file
55
py/services/misc_service.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from ..utils.models import MiscMetadata
|
||||
from ..config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MiscService(BaseModelService):
|
||||
"""Misc-specific service implementation (VAE, Upscaler)"""
|
||||
|
||||
def __init__(self, scanner, update_service=None):
|
||||
"""Initialize Misc service
|
||||
|
||||
Args:
|
||||
scanner: Misc scanner instance
|
||||
update_service: Optional service for remote update tracking.
|
||||
"""
|
||||
super().__init__("misc", scanner, MiscMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, misc_data: Dict) -> Dict:
|
||||
"""Format Misc data for API response"""
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = misc_data.get("sub_type", "vae")
|
||||
|
||||
return {
|
||||
"model_name": misc_data["model_name"],
|
||||
"file_name": misc_data["file_name"],
|
||||
"preview_url": config.get_preview_static_url(misc_data.get("preview_url", "")),
|
||||
"preview_nsfw_level": misc_data.get("preview_nsfw_level", 0),
|
||||
"base_model": misc_data.get("base_model", ""),
|
||||
"folder": misc_data["folder"],
|
||||
"sha256": misc_data.get("sha256", ""),
|
||||
"file_path": misc_data["file_path"].replace(os.sep, "/"),
|
||||
"file_size": misc_data.get("size", 0),
|
||||
"modified": misc_data.get("modified", ""),
|
||||
"tags": misc_data.get("tags", []),
|
||||
"from_civitai": misc_data.get("from_civitai", True),
|
||||
"usage_count": misc_data.get("usage_count", 0),
|
||||
"notes": misc_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": misc_data.get("favorite", False),
|
||||
"update_available": bool(misc_data.get("update_available", False)),
|
||||
"civitai": self.filter_civitai_data(misc_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
"""Find Misc models with duplicate SHA256 hashes"""
|
||||
return self.scanner._hash_index.get_duplicate_hashes()
|
||||
|
||||
def find_duplicate_filenames(self) -> Dict:
|
||||
"""Find Misc models with conflicting filenames"""
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user